本文讲述基于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¶m_array=name2
但是需要注意,使用不同的框架,get查询接口接收数组参数的方式可能略有不同。
# 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
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中生效
- 标识废弃的接口定义 示例:
// @Deprecated
建议将该注释放在首位,这样在工程代码中可以很明显地识别出废弃的接口。既然是废弃的接口,自然就不需要再关注了。
废弃的样式
- 接口授权登录 若需要对接口应用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 安全相关中间件
认证相关
基本认证: 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 跨域
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