# 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¶m_array=name2
但是需要注意,使用不同的框架,get查询接口接收数组参数的方式可能略有不同。
# 1.2 参数合法性校验 {#_1-2-参数合法性校验}
若想对参数做合法性校验,则实现步骤如下:
-
定义输入参数验证器
type CustomValidator struct { validator *validator.Validate }
func (this *CustomValidator) Validate(i interface{}) error { return this.validator.Struct(i) }
-
为路由对象配置验证器属性Validator
//支持输入参数的合法性校验 e.Validator = &CustomValidator{validator: validator.New()}
-
为
输入参数对应的struct
定义校验规则
示例如下:Category *string `query:"Category" validate:"required"`
支持的校验规则: 因为使用的验证框架是go-playground/validator
,那么请前往[官方文档](https://github.com/go-playground/validator}查看。
-
调用校验方法
调用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]
注意如下几个特性:
-
自定义字段类型
支持使用tagswaggertype
修改字段类型,示例如下://创建时间 CreatedAt *entity.JsonTime
swaggertype:"string"
-
自定义
接口响应对象
中成员变量的数据类型
示例如下:// @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)
-
自定义
模型的页面展示名称
示例:type Resp struct { Code int }//@name Response
注意, 该功能同样在swag工具的低版本如1.6.5中无效, 在版本1.7.0中生效
-
标识废弃的接口定义 示例:
// @Deprecated
建议将该注释放在首位,这样在工程代码中可以很明显地识别出废弃的接口。既然是废弃的接口,自然就不需要再关注了。
废弃的接口会以废弃的样式
显示在接口定义文档中。见下图:
- 接口授权登录 若需要对接口应用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-安全相关中间件}
-
认证相关
基本认证: BasicAuth
基于开源访问控制库Casbin的认证(功能完善): Casbin Auth
密钥认证: KeyAuth JSON Web Token (JWT) 认证: JWT -
防攻击相关
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中间件会有哪些问题呢:
- 当前请求若发生panic异常, 则会立刻中断,后续的逻辑没有机会执行。导致用户端没有收到任何响应(chrome开发工具的network中的status显示为
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 (重定向) 中间件 {#_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}
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证书).