引言

微服务算是一个 Go 语言的主要应用场景, 与 Java 不同 Go 语言生态中并不存在一个像 Spring 那样具有绝对统治力的后端框架. 一般大家都是按照自己的业务需求, 组合一些工具库来实现微服务. 这次就带大家一起来实现一个简单实用的项目模板.

首先分享下我平常写微服务时常用的库:

  1. 日志: github.com/sirupsen/logrus
  2. 命令行: github.com/urfave/cli/v2
  3. 服务器: github.com/gofiber/fiber/v2
  4. HTTP 客户端: github.com/go-resty/resty/v2
  5. 参数校验: github.com/go-playground/validator/v10
  6. JWT: github.com/dgrijalva/jwt-go
  7. MySQL: github.com/go-sql-driver/mysql
  8. 错误处理: github.com/gota33/errors
  9. 初始化工具: github.com/gota33/initializr

这次的项目模板准备支持的特性:

  1. 命令行启动
  2. 优雅退出
  3. 配置仓库
  4. 访问日志
  5. JWT Token
  6. 结构化错误处理
  7. 请求校验

完整的项目代码: 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() 读出其中的内容.

这套流程乍看下来没什么, 但细想之下还是有两个问题.

  1. 每加一个参数需要修改三个地方:
    1. 声明参数名常量
    2. 拼接环境变量名
    3. 读取参数值
  2. 读取时需要靠肉眼确认取值函数, 比如用 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 取代之前声明的参数名常量, 其中的两个工具函数:

  1. Get(): 用来帮助做类型转换
  2. 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...

看起来不错, 那么接下来是不是可以开始写业务路由了呢? 且慢, 一般来说只要是微服务, 还是得具备最基础的可观测性才行. 不然数量一多就会形成微服务黑洞, 堪称运维噩梦. 这里我们还是先实现三个最基础的可观测性接口:

  1. GET /healthz 健康检查
  2. GET /metrics Prometheus 格式的性能指标
  3. 访问日志

健康检查

// 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:

  1. query 来自 fiber, 用来取 url 中的 query 参数
  2. validate 来自 validator, 用来声明该字段的校验规则, 这里声明了两条规则, 必填和只包含unicode 字母
  3. 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 类型的结构化错误. 关于这个包的使用可以看下我的前两篇文章:

  1. 重新思考错误处理 Part 1
  2. 重新思考错误处理 Part 2

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)
	})
}

可以发现两部分变化频率不同的代码被混在了一起:

  1. 几乎不变的: 路由声明, 参数解析, 参数校验, JSON 编码
  2. 经常变化的: 业务代码

这就是一个明确的信号: 代码该拆了!

我们新建一个 internal/service 目录来存放业务代码, 这里也顺便提下 Go 项目如何组织业务代码. 由于不少人是 Java 转 Go 的, 所以我就以一般的 Java 项目做对比吧.

Java 中一个简单的 web 项目会包含这些目录:

  1. src/main/java/com/demo/controller
  2. src/main/java/com/demo/model/entity
  3. src/main/java/com/demo/model/dao
  4. src/main/java/com/demo/service

可以看出基本上是按照代码功能做的目录划分, 比如: 路由, 实体定义, 数据访问, 业务逻辑…

那么 Go 中又如何呢?

  1. internal/server/router.go
  2. internal/service/srv_1/service.go
  3. internal/service/srv_1/entity.go
  4. 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)
	})
}

现在就可以比较直观地发现, 每个业务模块都由三个部分组成:

  1. Service 实例
  2. 1 个父路由
  3. 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 配置仓库

最后来稍微提一下配置相关的话题, 因为篇幅已经很长就不彻底展开了.

首先依然解释下为什么要用配置仓库:

  1. 配置量较大时不适合用命令行传递
  2. 配置结构较复杂时也不适合用命令行, 比如: 列表, 结构体, …
  3. 不利于不同服务建共享配置, 比如: 数据库, Redis, …
  4. 不利于在不同环境下切换配置, 比如: 开发环境, 生产环境, …
  5. 看不到配置的历史变更记录, 自然也没法回滚

如何搭建一个配置仓库可以参考我以前写的一篇文章: 在 Golang 项目中使用 Spring Cloud Config Server 管理配置

如果想用 Jsonnet 书写配置, 可以参考这两篇:

  1. 用 Go 实现配置中心 Part 1
  2. 用 Go 实现配置中心 Part 2

如果已经有了一个配置仓库, 那么该如何在服务中使用呢?

以 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 字, 不过还是有部分内容没能写完

  1. 返回自定义的 StatusCode 和 Header
  2. 在编译时注入 AppName 和 Version 变量
  3. 业务日志中间件
  4. 调用其他微服务的 Http Client
  5. 数据库访问, 以及如何简化事务处理代码
  6. CI/CD 流水线
  7. Dockerfile
  8. Kubernetes 配置

不过限于篇幅, 只能等下篇文章补全啦.

最后, 谢谢阅读, 欢迎评论!