从零开始搭建一个简单实用的微服务模板 Part 1
引言
微服务算是一个 Go 语言的主要应用场景, 与 Java 不同 Go 语言生态中并不存在一个像 Spring 那样具有绝对统治力的后端框架. 一般大家都是按照自己的业务需求, 组合一些工具库来实现微服务. 这次就带大家一起来实现一个简单实用的项目模板.
首先分享下我平常写微服务时常用的库:
- 日志: github.com/sirupsen/logrus
- 命令行: github.com/urfave/cli/v2
- 服务器: github.com/gofiber/fiber/v2
- HTTP 客户端: github.com/go-resty/resty/v2
- 参数校验: github.com/go-playground/validator/v10
- JWT: github.com/dgrijalva/jwt-go
- MySQL: github.com/go-sql-driver/mysql
- 错误处理: github.com/gota33/errors
- 初始化工具: github.com/gota33/initializr
这次的项目模板准备支持的特性:
- 命令行启动
- 优雅退出
- 配置仓库
- 访问日志
- JWT Token
- 结构化错误处理
- 请求校验
完整的项目代码: https://github.com/gota33/micro-service
项目结构
Root
│ .gitignore
│ go.mod
│ go.sum
│ LICENSE
│ README.md
│
├───cmd
│ └───cli/main.go
│
└───internal
├───cli
│ │ cli.go
│ │
│ └───config/mysql/v1/mysql.go
│
├───server
│ router.go
│ server.go
│
└───service
├───auth/entity.go
└───demo/service.go
STEP 0 初始化
首先初始化项目, 这里用的版本是 go1.18
$ mkdir micro-service
$ cd micro-service
$ go mod init server
STEP 1 命令行
接着来写启动命令行, 这里的实现很简单, 主要是生成一个用于优雅退出的 Context.
// cmd/cli/main.go
import (
"github.com/gota33/initializr"
"github.com/sirupsen/logrus"
"server/internal/cli"
)
func main() {
ctx, cancel := initializr.GracefulContext()
defer cancel()
if err := cli.Run(ctx); err != nil {
logrus.WithError(err).Fatal("Exit with error")
}
}
为了保持 main()
简单直观, 这里把具体的参数声明放在 server/internal/cli
这个包下面. 这里之所以选 internal 目录, 是因为并没有需要导出的包, 所以干脆全部隐藏起来.
下面来看看如何声明启动参数, 这里需要用到 github.com/urfave/cli/v2
这个库.
// internal/cli/cli.go
package cli
import (
"os"
. "github.com/urfave/cli/v2"
)
var (
AppName = "app"
Version = "dev"
cli = &App{
Name: AppName,
Version: Version,
Action: func(c *Context) (err error) {
logrus.WithFields(logrus.Fields{
"app": AppName,
"version": Version,
}).Info("Hi!")
return
},
}
)
func Run(ctx context.Context) (err error) {
return cli.RunContext(ctx, os.Args)
}
这里将 AppName 和 Version 声明成了公开变量, 是为了之后编译的时候可以动态指定, 配合 CI/CD 工具就不需要每次手动更新版本号了.
程序启动后会调用 Action 里注册的函数, 通过 c.Context
就可以获取到之前在 main()
中传入用于优雅退出的 Context.
此时运行程序会输出一条欢迎信息:
$ go run cmd/cli/main.go
# time="2022-04-23T00:09:49+08:00" level=info msg="Hi!" app=app version=dev
OK, 启动成功! 下面我们就以 level (日志级别) 为例看下如何添加命令行参数.
// internal/cli/cli.go
// ...
const (
FlagNameLevel = "level"
EnvPrefix = "APP_"
)
var (
cli = &App{
Name: AppName,
Version: Version,
Flags: []Flag{
&StringFlag{
Name: FlagNameLevel,
EnvVars: []string{EnvPrefix + "LEVEL"},
Value: "info",
},
},
Action: func(c *Context) (err error) {
logrus.WithFields(logrus.Fields{
"app": AppName,
"version": Version,
"level": c.String(FlagNameLevel),
}).Info("Hi!")
return
},
}
)
// ...
这里我们声明了一个 string 类型的参数 level, 并且绑定了环境变量 APP_LEVEL, 最后在日志中通过 c.String()
读出其中的内容.
这套流程乍看下来没什么, 但细想之下还是有两个问题.
- 每加一个参数需要修改三个地方:
- 声明参数名常量
- 拼接环境变量名
- 读取参数值
- 读取时需要靠肉眼确认取值函数, 比如用
c.Int()
就可能报错
下面我们就用 go1.18 中新加入的泛型来优化下初始化参数的流程.
// internal/cli/cli.go
// ...
type flagName[T any] string
func (name flagName[T]) Get(c *Context) T {
return c.Value(string(name)).(T)
}
func (name flagName[T]) Envs() []string {
chars := []rune(EnvPrefix + name)
for i, c := range chars {
if c == '-' {
chars[i] = '_'
} else {
chars[i] = unicode.ToUpper(c)
}
}
return []string{string(chars)}
}
// ...
这里我们创建一个辅助类型 flagName 取代之前声明的参数名常量, 其中的两个工具函数:
Get()
: 用来帮助做类型转换Envs()
: 用来拼接与参数绑定的 Env, 例如:config-url
会被绑定到APP_CONFIG_URL
那么如何使用呢? 我们来看看改造后的代码.
// internal/cli/cli.go
// ...
const EnvPrefix = "APP_"
var (
AppName = "app"
Version = "dev"
flagLevel = flagName[string]("level")
cli = &App{
Name: AppName,
Version: Version,
Flags: []Flag{
&StringFlag{
Name: string(flagLevel),
EnvVars: flagLevel.Envs(),
Value: "info",
},
},
Action: func(c *Context) (err error) {
logrus.WithFields(logrus.Fields{
"app": AppName,
"version": Version,
"level": flagLevel.Get(c),
}).Info("Hi!")
return
},
}
)
// ...
虽然由于第三方库的限制, 声明 flagLevel 时依然要注意类型映射, 但是使用时就无需顾虑了. 因为声明只会发生一次, 但使用的次数很可能大于一, 所以总体复杂度还是下降了. 等到 github.com/urfave/cli
支持泛型之后就不需要这样曲线救国了.
好了, 下面我们就用 level 参数初始化 logrus 的日志级别吧. 用 App 的 Before 变量就可以注册适用于所有子命令的初始化函数了.
// internal/cli/cli.go
// ...
var (
// ...
cli = &App{
// ...
Before: func(c *Context) (err error) {
var lvl logrus.Level
if lvl, err = logrus.ParseLevel(flagLevel.Get(c)); err != nil {
return
}
logrus.SetLevel(lvl)
return
},
}
)
// ...
处理好了日志的初始化, 接着我们来处理服务器的初始化. 添加用于启动 HTTP 服务器的子命令, 对应的设置在 Commands 这个变量, 格式和 App 结构体基本一致.
var (
// ...
flagHttp = flagName[string]("http")
cli = &App{
// ...
Commands: []*Command{
{
Name: "server",
Flags: []Flag{
&StringFlag{
Name: string(flagHttp),
EnvVars: flagHttp.Envs(),
Value: ":8080",
},
},
Action: func(c *Context) (err error) {
config := server.Config{
Addr: flagHttp.Get(c),
}
return server.Run(c.Context, config)
},
},
},
}
)
// ...
STEP 2 服务器
服务器相关的代码放在 internal/server 目录中, 其中 server.go 属于不怎么变化的通用实现, router.go 就是和业务相关的路由了.
这里顺便提一句, 如果写代码时拿不太准怎么拆分, 可以先遵循一项最简单的规则: 按变化频率高低拆分.
下面回归正题, 我们先把服务器的初始化和优雅退出做好.
// internal/server/server.go
import (
"context"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gota33/initializr"
"github.com/sirupsen/logrus"
)
const (
timeout = 10 * time.Second
)
type Config struct {
Addr string
}
func Run(ctx context.Context, c Config) (err error) {
srv := fiber.New(fiber.Config{
IdleTimeout: timeout,
ReadTimeout: timeout,
WriteTimeout: timeout,
})
listen := func() error {
return srv.Listen(c.Addr)
}
shutdown := func() {
if shutdownErr := srv.Shutdown(); shutdownErr != nil {
logrus.WithError(shutdownErr).Warn("Shutdown server error")
}
}
return initializr.Run(ctx, listen, shutdown)
}
初始化 HTTP 服务器时有一点要留心, 无论是这里使用的 fiber 还是标准库中的 http, 都是没有默认超时时间的. 也就是说, 在优雅退出时, 只要还有一个链接是活跃状态, 服务器就会无限等下去. 这里我设置了 10s 的超时, 最长等待不会超过 30s, 大家可以根据实际需要设置.
那么 initializr.Run()
又是做什么的呢? 首先它会接收 listen 作为启动参数, 如果启动失败 (比如: 端口已被占用), 则会直接返回 error, 若启动成功它就会 block 住直到 ctx 结束, 之后调用 shutdown 进入退出流程. 退出流程一般是不应该被中断的, 所以也不会返回 error.
至此我们的服务器已经可以启动和优雅退出了, 也可以通过 -http 指定要使用的端口, 默认为 :8080
$ go run cmd/cli/main.go server -http=:8080
#
# ┌───────────────────────────────────────────────────┐
# │ Fiber v2.32.0 │
# │ http://127.0.0.1:8080 │
# │ (bound on host 0.0.0.0 and port 8080) │
# │ │
# │ Handlers ............. 6 Processes ........... 1 │
# │ Prefork ....... Disabled PID .............. 2284 │
# └───────────────────────────────────────────────────┘
#
Ctrl-C
# Try exit...
看起来不错, 那么接下来是不是可以开始写业务路由了呢? 且慢, 一般来说只要是微服务, 还是得具备最基础的可观测性才行. 不然数量一多就会形成微服务黑洞, 堪称运维噩梦. 这里我们还是先实现三个最基础的可观测性接口:
GET /healthz
健康检查GET /metrics
Prometheus 格式的性能指标- 访问日志
健康检查
// internal/server/server.go
// ...
func Run(ctx context.Context, c Config) (err error) {
// ...
srv.Get(endpointHealth, health())
// ...
}
func health() fiber.Handler {
return func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusOK)
}
}
这里说明下为何要声明一个单独的初始化函数给 healthz, 明明它只是返回 200 而已. 这是因为: 不同的微服务是否健康, 其评判标准也不同, 如果这个服务依赖了 MySQL, 那么当 MySQL 连接不上时, 其实也是不健康的. 这里单独声明一个函数就是为此扩展改留下空间.
Metrics
// internal/server/server.go
import (
// ...
"github.com/gofiber/adaptor/v2"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// ...
func Run(ctx context.Context, c Config) (err error) {
// ...
srv.Get(endpointMetrics, metrics())
// ...
}
func metrics() fiber.Handler {
return adaptor.HTTPHandler(promhttp.Handler())
}
promhttp 包会给我们的应用加上默认的 metrics: CPU, memory, GC 什么的, 如果要添加自定义 metrics 也是可以的, 可以参考这份文档
访问日志
// internal/server/server.go
import (
// ...
"github.com/gofiber/fiber/v2/middleware/logger"
)
// ...
func Run(ctx context.Context, c Config) (err error) {
// ...
srv.Use(logger.New())
// ...
}
这里为了方便就直接用 fiber 提供的 logger 中间件了, 当然也可以参考它的实现, 自己写个中间件用 logrus 做访问日志.
业务路由
好了, 终于可以开始触及到业务代码了. 由于业务路由更新频率较高, 我们新开一个 router.go 文件.
// internal/server/router.go
package server
import "github.com/gofiber/fiber/v2"
type router struct {
fiber.Router
config Config
}
func (r router) setup() (err error) {
r.Get("hello", func(c *fiber.Ctx) error {
message := c.Query("name", "guest")
return c.JSON(map[string]string{"hello": message})
})
return
}
这部分代码还是比较一目了然的. 我们扩展了 fiber.Router 给它添加了一个 setup() 函数, 用来初始化所有的业务路由.
这里之所以把 Config 也传进来, 是因为实际的业务中我们经常需要依赖一些外部服务, 比如 MySQL, Redis 等等, 这些引用就是通过 Config 结构体来传递的.
这里的演示代码注册了一个 GET /hello
的路由, 如果存在 name=xxx 参数就会返回 {"hello": "xxx"}
, 否则返回 {"hello": "guest"}
.
下面我们来调用这个初始化函数.
// internal/server/server.go
// ...
func Run(ctx context.Context, c Config) (err error) {
// ...
r := router{Router: srv, config: c}
r.setup()
// ...
}
现在启动服务器, 看看这个 API 是否能正常工作.
$ curl http://localhost:8080/hello?name=gota
# {"hello":"gota"}
$ curl http://localhost:8080/hello
# {"hello":"guest"}
看起来运行结果完全符合我们的预期, 是时候继续向前推进了. 下一步是添加参数校验, 毕竟 name 中出现特殊字符的话还是比较奇怪的. 我们主要用 github.com/go-playground/validator/v10
来实现声明式校验.
首先要声明出请求和响应的结构体, 相对于手动获取请求参数, 这种方式也更加直观.
// internal/server/router.go
// ...
type HelloRequest struct {
Name string `query:"name" validate:"required,alphaunicode"`
}
type HelloResponse struct {
Hello string `json:"hello"`
}
这里解释下结构体中的各种 Label:
query
来自 fiber, 用来取 url 中的 query 参数validate
来自 validator, 用来声明该字段的校验规则, 这里声明了两条规则, 必填和只包含unicode 字母json
来自标准库, 表示 JSON 编码时的字段名
然后来调整下业务实现.
// internal/server/router.go
// ...
type router struct {
fiber.Router
config Config
validate *validator.Validate
}
func (r router) setup() {
r.Get("hello", func(c *fiber.Ctx) (err error) {
var req HelloRequest
if err = c.QueryParser(&req); err != nil {
return
}
if err = r.validate.Struct(req); err != nil {
return
}
return c.JSON(HelloResponse{Hello: req.Name})
})
}
这里用 QueryParser 来解析请求参数到 HelloRequest, 然后用 validate.Struct()
来校验该结构体, 最后用 c.JSON()
来编码 HelloResponse. 当然也不要忘记补上 router.validate 的初始化.
// internal/server/server.go
// ...
func Run(ctx context.Context, c Config) (err error) {
// ...
r := router{
// ...
validate: validator.New(),
}
// ...
}
来看看效果吧
$ curl http://localhost:8080/hello?name=gota
# {"hello":"gota"}
$ curl http://localhost:8080/hello
# Key: 'HelloRequest.Name' Error:Field validation for 'Name' failed on the 'required' tag
$ curl http://localhost:8080/hello?name=e!
# Key: 'HelloRequest.Name' Error:Field validation for 'Name' failed on the 'alphaunicode' tag
嗯… 虽然确实把不合法的参数挡下来了, 但是返回格式却乱了套. 因为 fiber 默认的错误处理器返回的是 error string 而不是 JSON.
其实里面还有一个隐藏的问题, 如果调用 curl 时带着 -i 就会发现返回的状态码是 500 而不是期望的 400.
所以下面的任务就是实现一个自定义的错误处理器, 返回结构化的错误信息.
错误处理
fiber 的 Config 结构体中提供了 ErrorHandler 来设置错误处理器.
// internal/server/server.go
// ...
func Run(ctx context.Context, c Config) (err error) {
srv := fiber.New(fiber.Config{
// ...
ErrorHandler: handleError,
})
}
接着来实现 handlerError()
, 这部分代码量稍大, 我们分两部分看.
// internal/server/server.go
// ...
func handleError(c *fiber.Ctx, cause error) error {
// Part 1
var (
err error
fiberErr *fiber.Error
validateErrs validator.ValidationErrors
)
switch {
case errors.As(cause, &fiberErr):
var status errors.StatusCode
switch fiberErr.Code {
case http.StatusBadRequest:
status = errors.InvalidArgument
case http.StatusNotFound:
status = errors.NotFound
// Handle more codes
default:
status = errors.Unknown
}
err = errors.Annotate(fiberErr, status)
case errors.As(cause, &validateErrs):
details := errors.BadRequest{
FieldViolations: make([]errors.FieldViolation, len(validateErrs)),
}
for i, subErr := range validateErrs {
details.FieldViolations[i] = errors.FieldViolation{
Field: subErr.Field(),
Description: subErr.Error(),
}
}
err = errors.WithBadRequest(cause, details)
default:
err = cause
}
// Part 2
buf := &bytes.Buffer{}
enc := errors.NewEncoder(json.NewEncoder(buf))
if encErr := enc.Encode(err); encErr != nil {
return fiber.DefaultErrorHandler(c, encErr)
}
return c.
Status(errors.Code(err).Http()).
JSON(json.RawMessage(buf.Bytes()))
}
Part 1 用于转换错误类型, 将五花八门的错误转换为 errors.annotated
类型的结构化错误. 关于这个包的使用可以看下我的前两篇文章:
Part 2 主要是将转换后的错误编码成 JSON, 并设置相应的状态码.
如果之后要整合更多的第三方错误只要在 switch 中添加相应的转换就可以了.
很好, 来看看结构化的错误响应长啥样吧.
$ curl -i http://localhost:8080/hello?name=e!
# HTTP/1.1 400 Bad Request
# Date: Sat, 23 Apr 2022 12:15:33 GMT
# Content-Type: application/json
# Content-Length: 365
# {"error":{"code":400,"message":"Key: 'HelloRequest.Name' Error:Field validation for 'Name' failed on the 'alphaunicode' tag","status":"INVALID_ARGUMENT","details":[{"@type":"type.googleapis.com/google.rpc.BadRequest","fieldViolations":[{"field":"Name","description":"Key: 'HelloRequest.Name' Error:Field validation for 'Name' failed on the 'alphaunicode' tag"}]}]}}
附上格式化之后的 JSON
{
"error": {
"code": 400,
"message": "Key: 'HelloRequest.Name' Error:Field validation for 'Name' failed on the 'alphaunicode' tag",
"status": "INVALID_ARGUMENT",
"details": [
{
"@type": "type.googleapis.com/google.rpc.BadRequest",
"fieldViolations": [
{
"field": "Name",
"description": "Key: 'HelloRequest.Name' Error:Field validation for 'Name' failed on the 'alphaunicode' tag"
}
]
}
]
}
}
分离业务
搞定格式化错误之后, 让我们再回看下之前的代码. 由于我们只是简单地输出一行欢迎信息, 业务代码少到可以忽略不计, 所以全部堆在路由里还看不出什么问题. 但是如果是一个成熟地业务模块, 那很可能是下面这样.
// internal/server/router.go
// ...
func (r router) setup() {
r.Get("hello", func(c *fiber.Ctx) (err error) {
var (
req demo.HelloRequest
res demo.HelloResponse
)
if err = c.QueryParser(&req); err != nil {
return
}
if err = r.validate.Struct(req); err != nil {
return
}
// 许多行业务代码
res.Hello = req.Name
// ...
// ...
return c.JSON(res)
})
}
可以发现两部分变化频率不同的代码被混在了一起:
- 几乎不变的: 路由声明, 参数解析, 参数校验, JSON 编码
- 经常变化的: 业务代码
这就是一个明确的信号: 代码该拆了!
我们新建一个 internal/service 目录来存放业务代码, 这里也顺便提下 Go 项目如何组织业务代码. 由于不少人是 Java 转 Go 的, 所以我就以一般的 Java 项目做对比吧.
Java 中一个简单的 web 项目会包含这些目录:
- src/main/java/com/demo/controller
- src/main/java/com/demo/model/entity
- src/main/java/com/demo/model/dao
- src/main/java/com/demo/service
- …
可以看出基本上是按照代码功能做的目录划分, 比如: 路由, 实体定义, 数据访问, 业务逻辑…
那么 Go 中又如何呢?
- internal/server/router.go
- internal/service/srv_1/service.go
- internal/service/srv_1/entity.go
- internal/service/srv_n/service.go
- …
由于 Go 中代码可访问性的最小单位是 package, 比 Java 中的 Class 粒度更粗一些. 因此文件组织上就可以做得更加紧凑. 因此我们一般都按业务做目录上的划分, 按功能做文件上的划分. 如果走的是 RESTful 规范, 目录名一般就是资源名. 当然, 如果业务真的特别简单, 也可以直接放在一个文件中.
由于这也不是什么硬性的规定, 所以只要记得之前的规则就可以了: 按照代码变化的频率做切分.
好了, 稍微啰嗦了一会儿, 我们还是回头来看业务代码吧.
// internal/service/demo/service.go
package demo
import "context"
type Service struct{}
func New() Service {
return Service{}
}
type HelloRequest struct {
Name string `query:"name" validate:"required,alphaunicode"`
}
type HelloResponse struct {
Hello string `json:"hello"`
}
func (srv Service) Hello(ctx context.Context, req HelloRequest) (res HelloResponse, err error) {
// 很多业务逻辑
res = HelloResponse{Hello: req.Name}
return
}
这里之所以声明一个 Service 结构体, 主要还是为了在同模块的不同业务代码之间共享一些资源引用, 比如: MySQL, Redis ….
接下来把路由部分也同步更新一下.
// internal/server/router.go
// ...
func (r router) setup() {
r.demo()
}
func (r router) demo() {
srv := demo.New()
g := r.Group("demo")
g.Get("hello", func(c *fiber.Ctx) (err error) {
var (
req demo.HelloRequest
res demo.HelloResponse
)
if err = c.QueryParser(&req); err != nil {
return
}
if err = r.validate.Struct(req); err != nil {
return
}
if res, err = srv.Hello(c.Context(), req); err != nil {
return
}
return c.JSON(res)
})
}
现在就可以比较直观地发现, 每个业务模块都由三个部分组成:
- Service 实例
- 1 个父路由
- N 个子路由
这时我们发现, 分离业务代码之后暴露出了一个明显的问题, 除了一行代码以外, 其他部分几乎都是样板代码. 下面我们就来解决这个问题, 依然是用 go1.18 新加入的泛型功能.
// internal/server/server.go
// ...
func handler[Request any, Response any](h func(context.Context, Request) (Response, error)) fiber.Handler {
validate := validator.New()
return func(c *fiber.Ctx) (err error) {
var (
req Request
res Response
)
if err = c.QueryParser(&req); err != nil {
return
}
// More parsers here...
if err = validate.Struct(req); err != nil {
return
}
if res, err = h(c.Context(), req); err != nil {
return
}
return c.JSON(res)
}
}
上面的代码将 HelloRequest 和 HelloResponse 这两个具体的类型声明成了 Request any 和 Response any 这两个泛型类型, 并且返回了一个 fiber.Handler, 其余的代码则和之前一样.
上面的代码虽然看起来改动不大, 但是却能大幅减少我们声明路由的工作量.
// internal/server/router.go
// ...
func (r router) demo() {
srv := demo.New()
g := r.Group("demo")
g.Get("hello", handler(srv.Hello))
}
是不是看起来舒服多了?
用户鉴权
既然是业务代码, 那么用户鉴权自然也是不可或缺的一环. 这里我们就以 JWT 为例, 其他的鉴权方式只要做相应的变通就可以了. 这里我们用 github.com/dgrijalva/jwt-go
来处理 JWT 的解析.
首先, 我们来对用户 Token 进行建模. 这里就遇到一个新的问题, 由于这是一个所有业务公用的结构体, 所以我们需要声明一个单独的包来存放这类公用结构体或者函数. 一般常见的作法, 是在项目根目录中放一个全局公用的 utils, 然后里面塞满各种结构体和函数. 不过我认为这种做法可维护性其实不怎么样.
所以我们不妨转换一下思路, 从业务角度考虑, 用户鉴权也算是一个业务, 只不过是一个不对外暴露的内部业务. 所以我们在 internal/service/auth 中实现对应的逻辑, 只是不需要注册对外的路由罢了.
// internal/service/auth/entity.go
package auth
import (
"context"
"strings"
"github.com/dgrijalva/jwt-go"
"github.com/gota33/errors"
)
type ctxKey int
const (
unknown ctxKey = iota
userKey
)
var parser = &jwt.Parser{}
type User struct {
jwt.StandardClaims
Authorization string `json:"-"`
Nick string `json:"nick,omitempty"`
}
func (u *User) FromJWT(str string) (err error) {
u.Authorization = str
token := strings.TrimPrefix(str, "Bearer ")
_, _, err = parser.ParseUnverified(token, u)
return
}
func (u User) WithContext(ctx context.Context) context.Context {
return context.WithValue(ctx, userKey, u)
}
func (u *User) FromContext(ctx context.Context) (err error) {
var ok bool
if *u, ok = ctx.Value(userKey).(User); !ok {
return errors.Unauthenticated
}
return
}
上面的代码主要实现了两块逻辑;
解析 JWT 字符串:
由于大部分情况下 JWT 通过 Authorization 传递, 此时会带有一个 “Bearer " 前缀, 所以这里也就顺便处理了这种情况. 同时由于校验 JWT 合法性的情况一般由网关负责, 这里就使用 ParseUnverified()
跳过了校验步骤, 直接解析. 如果存在必须校验的情况可以使用 ParseWithClaims()
来解析.
存取 User:
因为 User 的生命周期一般与 Request 一致, 所以我们就用 Request 的 Context 来承载解析后的 User. 这里实现了对应的存取函数 WithContext()
和 FromContext()
.
另外, 之所以要在 User 中保存原始 JWT, 是为了在需要时可以向下游服务传递.
接口准备完毕, 下面我们就通过中间件来让 User 发挥作用吧.
// internal/server/server.go
// ...
func Run(ctx context.Context, c Config) (err error) {
// ...
srv.Use(initUserContext)
srv.Use(initAuthContext)
// ...
}
// ...
func initUserContext(c *fiber.Ctx) (err error) {
c.SetUserContext(c.Context())
return c.Next()
}
func initAuthContext(c *fiber.Ctx) (err error) {
if token := c.Get(fiber.HeaderAuthorization); token != "" {
var user auth.User
if err = user.FromJWT(token); err != nil {
return errors.Annotate(err, errors.Unauthenticated)
}
c.SetUserContext(user.WithContext(c.UserContext()))
}
return c.Next()
}
解释下为什么要使用两个中间件. fiber 中的 c.Context()
实际上是底层的 fasthttp 返回的一个 Context, 并且这个 Context 是不能被覆盖的, 所以要通过 c.SetUserContext()
来设置. 之所以开一个单独的中间件做这件事是由于除了 User 是 Request Scope 外其他有些东西也是相同的 Scope, 比如: 日志附加字段, 需要向下游传递的 Header 等等.
另一个中间件就很直接了, 从 Header 中取 JWT, 如果存在的话解析并且附加到 Context 中. 如果出错则返回 401.
同时不要忘了把 handler 中的 context 也换成 UserContext.
// internal/server/server.go
// ...
func handler[Request any, Response any](h func(context.Context, Request) (Response, error)) fiber.Handler {
// ...
return func(c *fiber.Ctx) (err error) {
// ...
if res, err = h(c.UserContext(), req); err != nil {
return
}
return c.JSON(res)
}
}
现在我们就可以在业务代码中访问到当前的 User 了.
// internal/service/demo/service.go
func (srv Service) Hello(ctx context.Context, req HelloRequest) (res HelloResponse, err error) {
var user auth.User
if err = user.FromContext(ctx); err != nil {
return
}
res = HelloResponse{Hello: req.Name}
return
}
STEP 3 配置仓库
最后来稍微提一下配置相关的话题, 因为篇幅已经很长就不彻底展开了.
首先依然解释下为什么要用配置仓库:
- 配置量较大时不适合用命令行传递
- 配置结构较复杂时也不适合用命令行, 比如: 列表, 结构体, …
- 不利于不同服务建共享配置, 比如: 数据库, Redis, …
- 不利于在不同环境下切换配置, 比如: 开发环境, 生产环境, …
- 看不到配置的历史变更记录, 自然也没法回滚
- …
如何搭建一个配置仓库可以参考我以前写的一篇文章: 在 Golang 项目中使用 Spring Cloud Config Server 管理配置
如果想用 Jsonnet 书写配置, 可以参考这两篇:
如果已经有了一个配置仓库, 那么该如何在服务中使用呢?
以 MySQL 为例, 假如我们有如下配置:
{
"app": {
"name": "demo"
},
"mysql": {
"database": "demo",
"host": "localhost",
"port": "3306",
"username": "user",
"password": "mypassword",
"maxIdle": 1,
"maxOpen": 20,
"params": ["loc=Asia/Shanghai", "parseTime=true", "interpolateParams=true"]
}
}
首先来实现配置的解析
// internal/cli/config/mysql/v1/mysql.go
package v1
import (
"context"
"database/sql"
"strings"
"time"
"github.com/go-sql-driver/mysql"
"github.com/gota33/initializr"
"github.com/sirupsen/logrus"
)
type MySQLOptions struct {
Host string `json:"host"`
Port string `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
Password string `json:"password"`
MaxOpen int `json:"maxOpen"`
MaxIdle int `json:"maxIdle"`
Params []string `json:"params"`
}
func New(res initializr.Resource, key string) (db *sql.DB, close func(), err error) {
var opts MySQLOptions
if err = res.Scan(key, &opts); err != nil {
return
}
params := make(map[string]string, len(opts.Params))
for _, param := range opts.Params {
kv := strings.SplitN(param, "=", 2)
params[kv[0]] = kv[1]
}
c := mysql.NewConfig()
c.User = opts.Username
c.Passwd = opts.Password
c.Net = "tcp"
c.Addr = opts.Host + ":" + opts.Port
c.DBName = opts.Database
c.Params = params
if db, err = sql.Open("mysql", c.FormatDSN()); err != nil {
return
}
if opts.MaxOpen > 0 {
db.SetMaxOpenConns(opts.MaxOpen)
}
if opts.MaxIdle > 0 {
db.SetMaxIdleConns(opts.MaxIdle)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err = db.PingContext(ctx); err != nil {
return
}
close = func() {
if closeErr := db.Close(); closeErr != nil {
logrus.WithError(err).Warnf("Close MySQL error")
}
}
return
}
接着来修改命令行, 使其可以从配置仓库获取配置.
// internal/cli/cli.go
// ...
var (
// ...
flagConfigUrl = flagName[string]("config-url")
// ...
cli = &App{
// ...
Commands: []*Command{
{
Name: "server",
Flags: []Flag{
// ...
&StringFlag{
Name: string(flagConfigUrl),
EnvVars: flagConfigUrl.Envs(),
Value: "",
},
},
Action: runServer,
},
},
}
)
// ...
func runServer(c *Context) (err error) {
var (
config server.Config
res initializr.Resource
closeRDS func()
)
if configUrl := flagConfigUrl.Get(c); configUrl != "" {
if res, err = initializr.FromJsonRemote(configUrl); err != nil {
return
}
if config.RDS, closeRDS, err = initmysql.New(res, "rds"); err != nil {
return
}
defer closeRDS()
}
config.Addr = flagHttp.Get(c)
return server.Run(c.Context, config)
}
// ...
上面的代码向命令行添加了一个参数 config-url
表示配置仓库的地址, 例如: http://localhost:8081/go/demo-dev.json
在加入初始化外部依赖的逻辑之后, 启动服务器的代码在放在 var 里就显得太臃肿了, 这里我们把它独立成一个 runServer()
函数. 如果 config-url 不为空, 则通过其中的 url 读取配置 JSON, 生成对应的实例, 并且在程序结束前释放相应的资源.
总结
虽然写了快 25k 字, 不过还是有部分内容没能写完
- 返回自定义的 StatusCode 和 Header
- 在编译时注入 AppName 和 Version 变量
- 业务日志中间件
- 调用其他微服务的 Http Client
- 数据库访问, 以及如何简化事务处理代码
- CI/CD 流水线
- Dockerfile
- Kubernetes 配置
不过限于篇幅, 只能等下篇文章补全啦.
最后, 谢谢阅读, 欢迎评论!