前一节,我们介绍了文章创建和修改页面编写和操作。但是并没有处理到图片的上传问题。这里我们介绍下如何配置来支持图片上传功能。

图片上传js处理

图片上传需要时候用到js,我们使用的富文本编辑器layedit默认是支持图片上传的,但是需要我们配置一下后端接收路径,我们打开app.js,修改一下layedit编辑器的初始化参数,增加图片上传的配置:

if($('#text-editor').length) {
    editorIndex = layedit.build('text-editor', {
        height: 450,
        uploadImage: {
            url: '/attachment/upload',
            type: 'post'
        }
    });
}

好啦,我们现在增加了uploadImage参数,这里声明提交的方式是post,并且设置了后端接收路径为/attachment/upload。接着我们只需要根据layedit定义的指定格式将处理结果返回回来,编辑器就会自动将图片插入到编辑器中了。

图片上传后端逻辑处理

上面我们定义了图片上传接收路径为/attachment/upload,我们根据这个路径,创建图片处理控制器,在controller下新建一个attachment.go 文件,并添加AttachmentUpload()函数:

package controller

import (
	"github.com/kataras/iris/v12"
	"irisweb/config"
	"irisweb/provider"
)

func AttachmentUpload(ctx iris.Context) {
	file, info, err := ctx.FormFile("file")
	if err != nil {
		ctx.JSON(iris.Map{
			"status": config.StatusFailed,
			"msg":    err.Error(),
		})
		return
	}
	defer file.Close()

	attachment, err := provider.AttachmentUpload(file, info)
	if err != nil {
		ctx.JSON(iris.Map{
			"status": config.StatusFailed,
			"msg":    err.Error(),
		})
		return
	}

	ctx.JSON(iris.Map{
		"code": config.StatusOK,
		"msg":  "",
		"data": iris.Map{
			"src": attachment.Logo,
			"title": attachment.FileName,
		},
	})
}

这个控制器,只负责接收用户提交上来的图片,判断是否是正常提交了图片,如果不是,则返回错误,如果是则将图片的文件转交给provider.AttachmentUpload()来处理。最后将处理结果返回给前端。

我们在provider文件夹中,创建一个attachment.go文件,并添加AttachmentUpload()函数和GetAttachmentByMd5()函数

package provider

import (
	"bufio"
	"bytes"
	"crypto/md5"
	"encoding/hex"
	"errors"
	"fmt"
	"github.com/nfnt/resize"
	"image"
	"image/gif"
	"image/jpeg"
	"image/png"
	"io"
	"irisweb/config"
	"irisweb/library"
	"irisweb/model"
	"log"
	"mime/multipart"
	"os"
	"path"
	"strconv"
	"strings"
	"time"
)

func AttachmentUpload(file multipart.File, info *multipart.FileHeader) (*model.Attachment, error) {
	db := config.DB
	//获取宽高
	bufFile := bufio.NewReader(file)
	img, imgType, err := image.Decode(bufFile)
	if err != nil {
		//无法获取图片尺寸
		fmt.Println("无法获取图片尺寸")
		return nil, err
	}
	imgType = strings.ToLower(imgType)
	width := uint(img.Bounds().Dx())
	height := uint(img.Bounds().Dy())
	fmt.Println("width = ", width, " height = ", height)
	//只允许上传jpg,jpeg,gif,png
	if imgType != "jpg" && imgType != "jpeg" && imgType != "gif" && imgType != "png" {
		return nil, errors.New(fmt.Sprintf("不支持的图片格式:%s。", imgType))
	}
	if imgType == "jpeg" {
		imgType = "jpg"
	}

	fileName := strings.TrimSuffix(info.Filename, path.Ext(info.Filename))
	log.Printf(fileName)

	_, err = file.Seek(0, 0)
	if err != nil {
		return nil, err
	}
	//获取文件的MD5,检查数据库是否已经存在,存在则不用重复上传
	md5hash := md5.New()
	bufFile = bufio.NewReader(file)
	_, err = io.Copy(md5hash, bufFile)
	if err != nil {
		return nil, err
	}
	md5Str := hex.EncodeToString(md5hash.Sum(nil))
	_, err = file.Seek(0, 0)
	if err != nil {
		return nil, err
	}

	attachment, err := GetAttachmentByMd5(md5Str)
	if err == nil {
		if attachment.Status != 1 {
			//更新status
			attachment.Status = 1
			err = attachment.Save(db)
			if err != nil {
				return nil, err
			}
		}
		//直接返回
		return attachment, nil
	}

	//如果图片宽度大于750,自动压缩到750, gif 不能处理
	buff := &bytes.Buffer{}

	if width > 750 && imgType != "gif" {
		newImg := library.Resize(750, 0, img, resize.Lanczos3)
		width = uint(newImg.Bounds().Dx())
		height = uint(newImg.Bounds().Dy())
		if imgType == "jpg" {
			// 保存裁剪的图片
			_ = jpeg.Encode(buff, newImg, nil)
		} else if imgType == "png" {
			// 保存裁剪的图片
			_ = png.Encode(buff, newImg)
		}
	} else {
		_, _ = io.Copy(buff, file)
	}

	tmpName := md5Str[8:24] + "." + imgType
	filePath := strconv.Itoa(time.Now().Year()) + strconv.Itoa(int(time.Now().Month())) + "/" + strconv.Itoa(time.Now().Day()) + "/"

	//将文件写入本地
	basePath := config.ExecPath + "public/uploads/"
	//先判断文件夹是否存在,不存在就先创建
	_, err = os.Stat(basePath + filePath)
	if err != nil && os.IsNotExist(err) {
		err = os.MkdirAll(basePath+filePath, os.ModePerm)
		if err != nil {
			return nil, err
		}
	}

	originFile, err := os.OpenFile(basePath + filePath + tmpName, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
	if err != nil {
		//无法创建
		return nil, err
	}

	defer originFile.Close()

	_, err = io.Copy(originFile, buff)
	if err != nil {
		//文件写入失败
		return nil, err
	}

	//生成宽度为250的缩略图
	thumbName := "thumb_" + tmpName

	newImg := library.ThumbnailCrop(250, 250, img)
	if imgType == "jpg" {
		_ = jpeg.Encode(buff, newImg, nil)
	} else if imgType == "png" {
		_ = png.Encode(buff, newImg)
	} else if imgType == "gif" {
		_ = gif.Encode(buff, newImg, nil)
	}

	thumbFile, err := os.OpenFile(basePath + filePath + thumbName, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
	if err != nil {
		//无法创建
		return nil, err
	}

	defer thumbFile.Close()

	_, err = io.Copy(thumbFile, buff)
	if err != nil {
		//文件写入失败
		return nil, err
	}

	//文件上传完成
	attachment = &model.Attachment{
		Id:           0,
		FileName:     fileName,
		FileLocation: filePath + tmpName,
		FileSize:     int64(info.Size),
		FileMd5:      md5Str,
		Width:        width,
		Height:       height,
		Status:       1,
	}
	attachment.GetThumb()

	err = attachment.Save(db)
	if err != nil {
		return nil, err
	}

	return attachment, nil
}

func GetAttachmentByMd5(md5 string) (*model.Attachment, error) {
	db := config.DB
	var attach model.Attachment

	if err := db.Where("`status` != 99").Where("`file_md5` = ?", md5).First(&attach).Error; err != nil {
		return nil, err
	}

	attach.GetThumb()

	return &attach, nil
}

因为是图片处理,所以这里的代码量有点多。因为我们要考虑到上传的图片有jpg、png、gif等,因此需要分别引入这些包,来解析图片。

上面通过解析图片,获取到图片的宽高和图片文件大小,也计算了图片的md5值。为了防止用户多次重复上传同一张图片,我们根据图片的md5值来判断图片是否重复,如果上传的是同一个图片,则不再进行后续处理,直接返回已经存储在服务器上的图片路径回去即可。

为了减轻服务器压力,我们在上传图片的时候,对图片尺寸大小做了判断,如果宽度大于750像素,则自动压缩到宽度为750像素的图片。再根据图片上传的日期,自动按年月和一个随机名称,存储到服务器的目录中。如果目录不存在,则先创建。

同时在处理图片的时候,也给每个图片都生成了一个宽度为250像素的缩略图,这个缩略图将会按居中裁剪方式生成。

上面我们注意到了,图片的缩放处理、裁剪处理,我们都使用了一个library/image的图片处理函数,因为图片处理相对比较复杂,我们将图片的缩放、裁剪处理,单独抽出到了library中。我们现在library创建一个image.go文件,来存放图片处理函数:

package library

import (
	"fmt"
	"github.com/nfnt/resize"
	"github.com/oliamb/cutter"
	"image"
)

func ThumbnailCrop(minWidth, minHeight uint, img image.Image) image.Image {
	origBounds := img.Bounds()
	origWidth := uint(origBounds.Dx())
	origHeight := uint(origBounds.Dy())
	newWidth, newHeight := origWidth, origHeight

	// Return original image if it have same or smaller size as constraints
	if minWidth >= origWidth && minHeight >= origHeight {
		return img
	}

	if minWidth > origWidth {
		minWidth = origWidth
	}

	if minHeight > origHeight {
		minHeight = origHeight
	}

	// Preserve aspect ratio
	if origWidth > minWidth {
		newHeight = uint(origHeight * minWidth / origWidth)
		if newHeight < 1 {
			newHeight = 1
		}
		//newWidth = minWidth
	}

	if newHeight < minHeight {
		newWidth = uint(newWidth * minHeight / newHeight)
		if newWidth < 1 {
			newWidth = 1
		}
		//newHeight = minHeight
	}

	if origWidth > origHeight {
		newWidth = minWidth
		newHeight = 0
	}else {
		newWidth = 0
		newHeight = minHeight
	}

	thumbImg := resize.Resize(newWidth, newHeight, img, resize.Lanczos3)
	//return CropImg(thumbImg, int(minWidth), int(minHeight))
	return thumbImg
}

func Resize(width, height uint, img image.Image, interp resize.InterpolationFunction) image.Image {
	return resize.Resize(width, height, img, interp)
}

func Thumbnail(width, height uint, img image.Image, interp resize.InterpolationFunction) image.Image {
	return resize.Thumbnail(width, height, img, interp)
}

func CropImg(srcImg image.Image, dstWidth, dstHeight int) image.Image {
	//origBounds := srcImg.Bounds()
	//origWidth := origBounds.Dx()
	//origHeight := origBounds.Dy()

	dstImg, err := cutter.Crop(srcImg, cutter.Config{
		Height: dstHeight,      // height in pixel or Y ratio(see Ratio Option below)
		Width:  dstWidth,       // width in pixel or X ratio
		Mode:   cutter.Centered, // Accepted Mode: TopLeft, Centered
		//Anchor: image.Point{
		//	origWidth / 12,
		//	origHeight / 8}, // Position of the top left point
		Options: 0, // Accepted Option: Ratio
	})
	fmt.Println()
	if err != nil {
		fmt.Println("Cannot crop image:" + err.Error())
		return srcImg
	}
	return dstImg
}

这个图片处理文件,是我从别人那里抄来的。它支持图片缩放、裁剪的多种形式。图片缩放我们使用了github.com/nfnt/resize包,图片裁剪我们使用了github.com/oliamb/cutter包。这里不做详细介绍,知道它能裁剪图片和缩放图片就行。

图片上传路由处理

上面这些逻辑处理完,我们还需要添加路由,才能真正提供给前端访问,修改route/base.go文件,添加如下代码:

attachment := app.Party("/attachment", controller.Inspect)
{
  attachment.Post("/upload", controller.AttachmentUpload)
}

这里同样的,我们也定义了它为一个路由分组,是为了以后方便扩展。

测试结果验证

上面操作完成了,我们重启一下项目,打不一篇文章,在文章中添加图片,看看能不能正常上传图片。如果不出意外,我们就可以看到图片出现在编辑框中了。

完整的项目示例代码托管在GitHub上,需要查看完整的项目代码可以到github.com/fesiong/goblog 上查看,也可以直接fork一份来在上面做修改。