golang web框架echo基础

本文讲述基于golang的web框架echo的基础知识。echo内置的功能较gin更多些,把常用的功能都内置到了框架中,如requestId等,但gin相对来讲更轻量,很多功能都需要找第三方插件或自己实现。根据我的经验,把历史项目从gin切换到echo很容易,改动很小。

# 1. 参数绑定

# 1.1 参数绑定

注意,echo的版本不同,参数绑定方式略有区别。

  • 旧版本做参数绑定时,query参数无需在struct中指定tag
  • 版本4做参数绑定时,query参数需要在struct中指定tag
    详见参数绑定 (opens new window)
parampath
param - source is route path parameter.

针对GET查询类接口,支持传入数组给接口的某个参数。如下示例则向接口传入了一个数组['name1', 'name2']给param_array参数:

/v1/api?param_array=name1&param_array=name2

但是需要注意,使用不同的框架,get查询接口接收数组参数的方式可能略有不同。

# 1.2 参数合法性校验

若想对参数做合法性校验,则实现步骤如下:

  1. 定义输入参数验证器
type CustomValidator struct {
	validator *validator.Validate
}

func (this *CustomValidator) Validate(i interface{}) error {
	return this.validator.Struct(i)
}
  1. 为路由对象配置验证器属性Validator
//支持输入参数的合法性校验
e.Validator = &CustomValidator{validator: validator.New()}
输入参数对应的struct
	Category *string `query:"Category" validate:"required"`
go-playground/validator
ctx.Validate
func PageNovel(ctx echo.Context) error {
	para := &ReqPageNovel{}
	if err := ctx.Bind(para); err != nil {
		log.Logger.Error("分页查询小说, 参数绑定失败", zap.Error(err))
		return c_common.Error(ctx, "参数错误")
	}
	log.Logger.Info("分页查询小说, 参数绑定完成", zap.Any("para", para))
	if err := ctx.Validate(para); err != nil {
		log.Logger.Error("分页查询小说, 参数校验, 失败", zap.Any("para", para), zap.Error(err))
		return c_common.Error(ctx, "参数错误")
	}
}

# 2. 集成swagger api接口文档

gin集成swagger

是以注释的方式编写API定义。 示例如下:

// @Summary 查询类别列表
// @Description 查询类别列表
// @Security ApiKeyAuth
// @Tags 类别
// @Accept  json
// @Produce  json
// @Success 200 {object} c_common.Result{data=[]m_novel.Category}
// @Router /api/category/listCategory [get]

注意如下几个特性:

swaggertype
//创建时间
CreatedAt *entity.JsonTime	`swaggertype:"string"`
接口响应对象
// @Success 200 {object} c_common.Result{data=[]m_novel.Category}
go get -u github.com/swaggo/swag/cmd/swag
模型的页面展示名称
type Resp struct {
    Code int
}//@name Response

注意, 该功能同样在swag工具的低版本如1.6.5中无效, 在版本1.7.0中生效

  1. 标识废弃的接口定义 示例:
// @Deprecated

建议将该注释放在首位,这样在工程代码中可以很明显地识别出废弃的接口。既然是废弃的接口,自然就不需要再关注了。

废弃的样式
  1. 接口授权登录 若需要对接口应用jwt token授权,那么需要加一行**// @Security ApiKeyAuth**

# 2.2 生成swagger文档

# 2.2.1 安装工具swag

go get github.com/swaggo/swag/cmd/swag

推荐安装最新版本的swag,否则可能会发生swaggo (opens new window)中的某些特性不生效(前文已经提到)。

# 2.2.2 生成swagger接口定义文档

swag init注释
(py3.6) wangshibiao@wangshibiao:/data/workspace/novel-api$ swag init
2021/03/17 15:45:46 Generate swagger docs....
2021/03/17 15:45:46 Generate general API Info, search dir:./
2021/03/17 15:45:46 create docs.go at  docs/docs.go
2021/03/17 15:45:46 create swagger.json at  docs/swagger.json
2021/03/17 15:45:46 create swagger.yaml at  docs/swagger.yaml
(py3.6) wangshibiao@wangshibiao:/data/workspace/novel-api$ 
(py3.6) wangshibiao@wangshibiao:/data/workspace/novel-api$ ls -l ./docs/
总用量 12
-rw-rw-r-- 1 wangshibiao wangshibiao 2627 3月  17 15:52 docs.go
-rw-rw-r-- 1 wangshibiao wangshibiao 1254 3月  17 15:52 swagger.json
-rw-rw-r-- 1 wangshibiao wangshibiao  588 3月  17 15:52 swagger.yaml
(py3.6) wangshibiao@wangshibiao:/data/workspace/novel-api$ 
docs
swag init

# 2.3 生成swagger文档的访问路由

# 2.3.1 安装依赖echo-swagger

go get -u github.com/swaggo/echo-swagger

# 2.3.2 修改路由定义源文件

修改路由定义所在的源文件,共修改2处:

  • 修改import部分
    导入前面生成的docs目录对应的包, 那么import部分需要增加一行。示例如下:
_ "novel/docs"

按实际情况,将docs的路径改成具体的路径即可。加这条语句的目的,自然是去执行docs/docs.go的init方法喽。

  • 增加一项路由定义
e.GET("/swagger/*", echoSwagger.WrapHandler)

# 2.3.3 访问接口定义文档

http://localhost:8080/swagger/index.html

# 3. 支持跨域

echo提供了CORS跨域中间件来支持跨域。

默认

用法如下:

e.Use(middleware.CORS())

默认配置如下:

DefaultCORSConfig = CORSConfig{
  Skipper:      defaultSkipper,
  AllowOrigins: []string{"*"},
  AllowMethods: []string{echo.GET, echo.HEAD, echo.PUT, echo.PATCH, echo.POST, echo.DELETE},
}

可以看出,默认情况下,支持所有来源的跨域访问。

自定义

若默认的跨域配置无法满足实际需求(如仅允许有限的来源域名跨域访问),那么就需要自定义跨域配置,方法如下:

e := echo.New()
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
  AllowOrigins: []string{"https://labstack.com", "https://labstack.net"},
  AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept},
}))

# 4. 打印路由列表

服务启动过程中,echo并不会打印路由表。
若想打印路由表,那么在路由定义的末尾处补充如下逻辑即可:

	//打印路由列表
	routeList, _ := jsoniter.Marshal(e.Routes())
	prettyRouteList := pretty.Pretty(routeList)
	fmt.Printf("%s\n", prettyRouteList)

# 5. 配置前端页面文件的访问路由

若前端页面(如html、js、css等)也需要通过后台服务访问,那么需要定义静态文件的访问路由。
我本地的静态文件的目录结构如下:

(py3.6) wangshibiao@wangshibiao:/data/workspace/novel-api$ tree ./public/static/
./public/static/
└── test.html

0 directories, 1 file
(py3.6) wangshibiao@wangshibiao:/data/workspace/novel-api$

# 5.1 映射到目录

e.Static
	//配置静态文件的路由
	e.Static("/static", "public/static")

第1个参数是匹配url路由, 第2个参数对应到本地的某个静态文件目录。

那么访问public/static/test.html的url地址为http://localhost:8080/static/test.html

# 5.2 映射到文件

e.File
e.File("/a.html", "public/static/test.html")

那么访问public/static/test.html的url地址为http://localhost:8080/a.html

# 6. 支持模板渲染

若希望前端页面通过服务端渲染,那么就需要配置模板引擎。

建议首页使用模板渲染,带来更好的用户体验。因为按这种方式,可以把首页所需的数据在渲染的时候就加载到页面中。这样,就避免用户请求到首页后,再请求接口动态渲染首页。

配置步骤如下:

# 6.1 实现echo.Renderer接口

/************* start 实现 echo.Renderer 接口: 用于支持模板渲染 *********/
type Template struct {
	templates *template.Template
}

func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
	return t.templates.ExecuteTemplate(w, name, data)
}

/************* end 实现 echo.Renderer 接口: 用于支持模板渲染 *********/

# 6.2 配置模板引擎

	//配置模板引擎
	e.Renderer = &Template{
		templates: template.Must(template.ParseGlob("public/template/*.html")),
	}
模板文件的路径

# 6.3 定义路由

需要使用模板渲染的页面
	//配置首页的路由
	e.GET("/", controller.Index)

# 6.4 创建模板页面

在模板文件目录下创建模板文件back.html, 内容如下:

(py3.6) wangshibiao@wangshibiao:/data/workspace/novel-api$ cat ./public/template/back.html 
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>back</title>
</head>
<body>

{{.title}}
</body>
</html>(py3.6) wangshibiao@wangshibiao:/data/workspace/novel-api$
双大括号

# 6.5 渲染模板页面

ctx.JSONctx.Render
//渲染首页入口页面
func Index(ctx echo.Context) error {
	serviceName := config.GlobalConfig.ServiceName
	//配置前端服务页面
	if serviceName == "novelFront" {
		return ctx.Render(http.StatusOK, "front.html", g.MapStrAny{
			"title": "我是前端页面",
		})
	} else if serviceName == "novelBack" { //配置后台管理页面
		return ctx.Render(http.StatusOK, "back.html", g.MapStrAny{
			"title": "我是后台管理页面",
		})
	}

	return c_common.Error(ctx, "系统异常")
}
title

# 7. 中间件

echo提供了很多常用的中间件。
中间件分为2种类型:对应的方法分别为pre(路由匹配前)和use(路由匹配后)。

# 7.1 安全相关中间件

  1. 认证相关
    基本认证: BasicAuth
    基于开源访问控制库Casbin的认证(功能完善): Casbin Auth
    密钥认证: KeyAuth JSON Web Token (JWT) 认证: JWT

  2. 防攻击相关
    CSRF

CSRF是攻击者借助cookie取得服务端的信任,详见:CSRF攻击与防御 (opens new window)

Xss中间件: middleware.Secure()

XSS是攻击者向前端页面注入代码, 详见XSS攻击及防御 (opens new window)

# 7.2 跨域

CORS

# 7.3 性能相关

Gzip: 对HTTP响应进行压缩

发现的问题,若采用gzip压缩后,swagger接口文档无法正常访问,原因未知

# 7.4 请求日志

echo自带的日志不支持归档,所以建议使用第三方日志库,如zap等。
此处不使用自带的日志中间件,而使用zap自定义一个日志中间件,示例如下:

	//日志中间件
	e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(ctx echo.Context) error {
			startTime := time.Now()

			//执行action业务逻辑
			if err := next(ctx); err != nil {
				log.Logger.Error("用户访问日志, action返回错误", zap.Error(err))
			}

			//请求耗时
			duration := time.Since(startTime)
			formParams, _ := ctx.FormParams()
			log.Logger.Info("用户访问日志", zap.String("uri", ctx.Request().URL.Path), zap.Any("method", ctx.Request().Method), zap.Any("queryParaList", ctx.QueryParams()), zap.Any("formParaList", formParams), zap.Any("header", ctx.Request().Header), zap.Any("userAgent", ctx.Request().UserAgent()), zap.Any("cookies", ctx.Request().Cookies()), zap.String("ip", ctx.RealIP()), zap.Any("ipLocation", ip_location.GetIpLocationString(ctx.RealIP())), zap.Any("输入字节数", ctx.Request().Header.Get(echo.HeaderContentLength)), zap.Any("响应字节数", ctx.Response().Size), zap.Any("duration", duration), zap.Any("durationNanosecond", int64(duration)))

			return nil
		}
	})

生成的日志格式如下:

{"level":"INFO","time":"2021-03-19 13:29:00.264","caller":"middleware/api_log_middleware.go:28","message":"用户访问日志","serviceName":"novelBack","uri":"/api/novel/pageNovel","method":"GET","queryParaList":{"category":["言情"],"pageNo":["1"],"pageSize":["13"]},"formParaList":{"category":["言情"],"pageNo":["1"],"pageSize":["13"]},"header":{"Accept":["application/json"],"Accept-Encoding":["gzip, deflate, br"],"Accept-Language":["zh-CN,zh;q=0.9,en;q=0.8"],"Connection":["keep-alive"],"Cookie":["_csrf=lrTukgDjcrFw2YfM6hfZ9BpM07utRsus; news_uid=4e1bedfe-1735-437c-8f60-abb64895d38d; _csrf=qXonsW9cLTeigH9NlY44Ns5jnI8cjmRf"],"Referer":["http://localhost:8080/swagger/index.html"],"User-Agent":["Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36"]},"userAgent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36","cookies":[{"Name":"_csrf","Value":"lrTukgDjcrFw2YfM6hfZ9BpM07utRsus","Path":"","Domain":"","Expires":"0001-01-01T00:00:00Z","RawExpires":"","MaxAge":0,"Secure":false,"HttpOnly":false,"SameSite":0,"Raw":"","Unparsed":null},{"Name":"news_uid","Value":"4e1bedfe-1735-437c-8f60-abb64895d38d","Path":"","Domain":"","Expires":"0001-01-01T00:00:00Z","RawExpires":"","MaxAge":0,"Secure":false,"HttpOnly":false,"SameSite":0,"Raw":"","Unparsed":null},{"Name":"_csrf","Value":"qXonsW9cLTeigH9NlY44Ns5jnI8cjmRf","Path":"","Domain":"","Expires":"0001-01-01T00:00:00Z","RawExpires":"","MaxAge":0,"Secure":false,"HttpOnly":false,"SameSite":0,"Raw":"","Unparsed":null}],"ip":"::1","ipLocation":"","输入字节数":"","响应字节数":4467,"duration":"9.640961ms","durationNanosecond":9640961}

# 7.5 反向代理

Proxy: 可以实现nginx反向代理的功能

# 7.6 Recover中间件

{"message":"Internal Server Error"}
failed请求日志中间件并不会记录到请求日志

框架自带的Recover中间件是把日志打印到标准输出,这不利于日志的集中管理,我们希望统一输出到日志文件中。为此,建议创建一个自定义的Recover中间件,代码如下:

/**
异常捕获中间件
*/
func RecoverMiddleware() echo.MiddlewareFunc {
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(ctx echo.Context) error {
			defer func() {
				if r := recover(); r != nil {
					err, ok := r.(error)
					if !ok {
						err = fmt.Errorf("%v", r)
					}

					//4KB
					stackSize := 4 << 10
					stack := make([]byte, stackSize)
					length := runtime.Stack(stack, true)
					log.Logger.Error("异常捕获中间件, 发生panic异常", zap.Error(err), zap.ByteString("stack", stack[:length]))

					//交给HTTPErrorHandler处理后续流程: 返回给用户{"message":"Internal Server Error"}
					ctx.Error(err)
				}
			}()

			//执行action业务逻辑
			return next(ctx)
		}
	}
}

# 7.7 Redirect (重定向) 中间件

支持常用的几种重定向需求,如http->https、www.domain.com->domain.com等。
详情参考Redirect (重定向) 中间件 (opens new window)

# 7.8 Request ID (请求ID) 中间件

X-Request-ID

# 7.8.1 配置RequestID中间件

若使用默认配置,示例如下:

e.Use(middleware.RequestID())

默认配置采用的是随机字符串作为requestId,重复的可能性很小,但为了万无一失,建议采用分布式ID。代码如下:

	//Request ID (请求ID) 中间件
	e.Use(middleware.RequestIDWithConfig(middleware.RequestIDConfig{
		Generator: func() string {
			//生成当前请求的标识ID
			return util.GenUniqueId().String()
		},
	}))
util.GenUniqueId()

# 7.8.2 封装通用的日志函数

封装一组通用的日志打印函数,支持传入ctx上下文对象,统一将requestId打印到日志中。
示例如下:

package log

import (
	"github.com/labstack/echo/v4"
	"go.uber.org/zap"
)

/**
打印Info级别的日志
 */
func Info(ctx echo.Context, msg string, fields ...zap.Field) {
	if ctx != nil {
		fields = append(fields, zap.Any("requestId", ctx.Response().Header().Get("X-Request-Id")))
	}
	Logger.Info(msg, fields...)
}

/**
打印Error级别的日志
 */
func Error(ctx echo.Context, msg string, fields ...zap.Field) {
	if ctx != nil {
		fields = append(fields, zap.Any("requestId", ctx.Response().Header().Get("X-Request-Id")))
	}
	Logger.Error(msg, fields...)
}

/**
打印Warn级别的日志
 */
func Warn(ctx echo.Context, msg string, fields ...zap.Field) {
	if ctx != nil {
		fields = append(fields, zap.Any("requestId", ctx.Response().Header().Get("X-Request-Id")))
	}
	Logger.Warn(msg, fields...)
}
zap.New(core, caller, development, GetLoggerGlobalOption())zap.New(core, caller, development, GetLoggerGlobalOption(), zap.AddCallerSkip(1))zap.AddCallerSkip(1)

# 7.8.3 调用日志打印函数

在action中,将context对象逐级向下层传递,调用日志函数的时候将上下文对象作为参数即可。   示例如下:

log.Info(ctx, "分页查询小说列表, 完成", zap.Any("category", category), zap.Any("pageNo", pageNo), zap.Any("pageSize", pageSize))

# 7.9 Bodydump请求体转储

想获取请求的输入body和响应body的内容,那么可以借助Bodydump中间件.

e := echo.New()
e.Use(middleware.BodyDump(func(c echo.Context, reqBody, resBody []byte) {
}))

# 7.10 Rewrite (重写) 中间件

向后兼容
e.Pre(middleware.Rewrite(map[string]string{
  "/old":              "/new",
  "/api/*":            "/$1",
  "/js/*":             "/public/javascripts/$1",
  "/users/*/orders/*": "/user/$1/order/$2",
}))

星号中捕获的值可以通过索引检索,例如 $1, $2 等等。
需要使用pre注册.

# 7.11 Session (会话) 中间件

支持session的各种存储方式

# 7.12 static中间件

static中间件e.static
static中间件

# 8. 支持Http2

http1和http2都支持流式响应,但是有所区别:

Chunked transfer encoding