51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

golang web框架echo基础

# golang web框架echo基础 {#golang-web框架echo基础}

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

echo框架的教程详见echo框架 (opens new window)

# 1. 参数绑定 {#_1-参数绑定}

# 1.1 参数绑定 {#_1-1-参数绑定}

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

  • 旧版本做参数绑定时,query参数无需在struct中指定tag
  • 版本4做参数绑定时,query参数需要在struct中指定tag
    详见参数绑定 (opens new window)

注意,官网中针对path参数的示例有误, 现纠正如下:
若要绑定path参数,struct对应的tag为param而不是path,对应官网的文字说明如下:

param - source is route path parameter.

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

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

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

# 1.2 参数合法性校验 {#_1-2-参数合法性校验}

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

  1. 定义输入参数验证器

    type CustomValidator struct { validator *validator.Validate }

    func (this *CustomValidator) Validate(i interface{}) error { return this.validator.Struct(i) }

  2. 为路由对象配置验证器属性Validator

    //支持输入参数的合法性校验 e.Validator = &CustomValidator{validator: validator.New()}

  3. 输入参数对应的struct定义校验规则
    示例如下:

     Category *string `query:"Category" validate:"required"`
    

支持的校验规则: 因为使用的验证框架是go-playground/validator,那么请前往[官方文档](https://github.com/go-playground/validator}查看。

  1. 调用校验方法
    调用ctx.Bind方法绑定完参数后,执行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接口文档 {#_2-集成swagger-api接口文档}

集成过程同gin集成swagger类似。
官方文档echo-swagger (opens new window)

# 2.1 按规范 (opens new window)编写api定义 {#_2-1-按规范编写api定义}

是以注释的方式编写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]

注意如下几个特性:

  1. 自定义字段类型
    支持使用tagswaggertype修改字段类型,示例如下:

    //创建时间 CreatedAt *entity.JsonTime swaggertype:"string"

  2. 自定义接口响应对象中成员变量的数据类型
    示例如下:

    // @Success 200 {object} c_common.Result{data=[]m_novel.Category}

注意, 该功能在swag工具的低版本如1.6.5中无效, 在版本1.7.0中生效。执行如下命令完成升级: go get -u github.com/swaggo/swag/cmd/swag
详情请前往官网响应对象中的模型组合 (opens new window)

  1. 自定义模型的页面展示名称
    示例:

    type Resp struct { Code int }//@name Response

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

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

    // @Deprecated

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

废弃的接口会以废弃的样式显示在接口定义文档中。见下图:
swagger废弃接口

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

# 2.2 生成swagger文档 {#_2-2-生成swagger文档}

# 2.2.1 安装工具swag {#_2-2-1-安装工具swag}

执行命令go get github.com/swaggo/swag/cmd/swag安装swag命令行工具。

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

# 2.2.2 生成swagger接口定义文档 {#_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-生成swagger文档的访问路由}

# 2.3.1 安装依赖echo-swagger {#_2-3-1-安装依赖echo-swagger}

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

# 2.3.2 修改路由定义源文件 {#_2-3-2-修改路由定义源文件}

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

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

_ "novel/docs"

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

  • 增加一项路由定义

    e.GET("/swagger/*", echoSwagger.WrapHandler)

# 2.3.3 访问接口定义文档 {#_2-3-3-访问接口定义文档}

服务启动后,访问http://localhost:8080/swagger/index.html, 即可访问到接口定义文档。

# 3. 支持跨域 {#_3-支持跨域}

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

# 3.1 使用默认的跨域配置 {#_3-1-使用默认的跨域配置}

用法如下:

e.Use(middleware.CORS())

默认配置如下:

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

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

# 3.2 自定义跨域配置 {#_3-2-自定义跨域配置}

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

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. 打印路由列表 {#_4-打印路由列表}

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

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

# 5. 配置前端页面文件的访问路由 {#_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 映射到目录 {#_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 映射到文件 {#_5-2-映射到文件}

使用方法e.File
示例如下:

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

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

# 6. 支持模板渲染 {#_6-支持模板渲染}

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

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

配置步骤如下:

# 6.1 实现echo.Renderer接口 {#_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 配置模板引擎 {#_6-2-配置模板引擎}

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

将如上代码中模板文件的路径修改为实际的路径即可。

# 6.3 定义路由 {#_6-3-定义路由}

需要使用模板渲染的页面定义路由。路由定义方法不变。

	//配置首页的路由
	e.GET("/", controller.Index)

# 6.4 创建模板页面 {#_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 渲染模板页面 {#_6-5-渲染模板页面}

同开发接口的逻辑相同,只需要将之前的ctx.JSON改为ctx.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, "系统异常")
}

ctx.Render的功能: 将请求路由到的指定的模板文件,并携带模型数据,渲染的时候会将模型数据注入到模板文件中。本例中的模型数据是title

# 7. 中间件 {#_7-中间件}

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

# 7.1 安全相关中间件 {#_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 跨域 {#_7-2-跨域}

CORS

# 7.3 性能相关 {#_7-3-性能相关}

Gzip: 对HTTP响应进行压缩

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

# 7.4 请求日志 {#_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 反向代理 {#_7-5-反向代理}

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

# 7.6 Recover中间件 {#_7-6-recover中间件}

当请求处理过程中发生panic异常,Recover中间件会捕获到该异常,并打印出堆栈中的错误信息。同时将当前的请求转交给 HTTPErrorHandler处理(返回给用户端的数据为:{"message":"Internal Server Error"})。

若不使用Recover中间件会有哪些问题呢:

  1. 当前请求若发生panic异常, 则会立刻中断,后续的逻辑没有机会执行。导致用户端没有收到任何响应(chrome开发工具的network中的status显示为failed)。
    echo panic异常
  2. 正因为上述原因,导致请求日志中间件并不会记录到请求日志,进而丢失了用户现场的数据,为问题原因的排查提升了难度。

框架自带的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 (重定向) 中间件 {#_7-7-redirect-重定向-中间件}

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

# 7.8 Request ID (请求ID) 中间件 {#_7-8-request-id-请求id-中间件}

Request ID用于追踪日志,方便排查问题。
RequestID中间件用来生成一个请求ID,并设置到响应的header头X-Request-ID中。

# 7.8.1 配置RequestID中间件 {#_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()是提供分布式ID的工具函数。

# 7.8.2 封装通用的日志函数 {#_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日志库),将zap.New(core, caller, development, GetLoggerGlobalOption())改为zap.New(core, caller, development, GetLoggerGlobalOption(), zap.AddCallerSkip(1)),即补充一项设置zap.AddCallerSkip(1),否则日志文件中显示的文件行数与实际不符。

# 7.8.3 调用日志打印函数 {#_7-8-3-调用日志打印函数}

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

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

# 7.9 Bodydump请求体转储 {#_7-9-bodydump请求体转储}

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

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

# 7.10 Rewrite (重写) 中间件 {#_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 (会话) 中间件 {#_7-11-session-会话-中间件}

支持session的各种存储方式

# 7.12 static中间件 {#_7-12-static中间件}

static中间件的功能e.static的区别是?

static中间件的功能没有验证通过

# 8. 支持Http2 {#_8-支持http2}

详见HTTP2 (opens new window).

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

  • http1
    http1中支持流式响应的方法:使用分块传输Chunked transfer encoding实现,详见流式响应 (opens new window)
  • http2
    http2原生支持流式响应(分帧传输),详见[HTTP2](http://echo.topgoer.com/%E8%8F%9C%E8%B0%B1/HTTP2.html 自动生成https证书).
赞(2)
未经允许不得转载:工具盒子 » golang web框架echo基础