近期参与的 Web 项目随着代码规模增加,Go 版本升级后,臃肿到不支持跑测试用例。于是做了部分模块功能拆分,尝试避免之前碰到的问题。我对历史项目中的主要问题简单做了总结,而且会谈谈从哪些方面考虑并改进。但这不是标准,每个人有每个人的风格和逻辑,只是以我的经验简单记录。碰到的主要问题
程序高度封装,对配置加载,命令行参数解析包 flag 初始化,routers注册等执行顺序没有控制权
历史项目中,配置初始化、用作命令行解析的 flag 初始化使用第三方库,高度封装,随时随地使用时候调用,这虽然很方便。但是由于程序运行、单元测试对配置的依赖,自己不能掌控何时加载配置,go testing 模块变动(go 语言设计 testing 的bug),flag.Parse 执行顺序优先于 testing.Init(), 导致执行单个用例测试出现了以下错误: https://github.com/golang/go/issues/31859调整时发现很麻烦,除非重新组织代码,自己控制配置加载。以上问题暴露的出一个写代码的纠结点,高度封装的项目工程虽然代码量少,使用方便,但灵活性大大降低,给调用者的选择太少了,处理一些特殊问题时很麻烦。两种方式好坏优劣待定,还是要根据需求去选择。我重写过程中,放弃了高度封装的形式。日志跟踪不友好
以前对日志处理只是简单的随意按照级别打印或输出到指定位置,用户访问 Web 服务日志无法跟踪集成,因为每个请求到来时,是一个 goroutine, 有自己的 context,多条日志打印或输出顺序不固定,没法对同一个用户操作日志有效过滤筛选。另外,如果以微服务形式部署应用,免不了要对每个请求的调用链路进行跟踪,常需要将跟踪日志记录集成,常用方法是将跟踪 ID 记录到整个调用链的日志消息中,历史工程这些处理都没有。目录结构
项目 目录结构 是代码「可读性、可维护性」体现的属性之一。一个层次清晰的结构可以让项目接手人一眼看懂,很快了解这个项目。且随着时间的推移,代码规模的增加,项目结构不会混乱,保持组织良好。Go 应用程序项目布局虽然没有官方标准,但在 Go 生态中形成了一种比较受欢迎的布局,可以参考:https://github.com/golang-standards/project-layout/blob/master/README_zh.md以下是我的项目布局,目前还没有调整到最优,做一个简单说明。 该目录表示项目提供api。内部对 api 分类,分为 http 接口和 grpc 接口,可以根据需求增加或调整。http 接口中的目录介绍会在 web 服务代码结构中说明。项目中应用的可执行文件,各应用入口。/cmd/http 表示 http 接口入口。私有应用程序代码,实现我项目中的一些业务单元核心功能函数,这部分代码不希望其他人在其应用程序或库中导入外部应用程序可以使用的库代码,其他项目也可导入共同使用。PaaS 系统容器编排部署配置和模板。develop 表示测试环境模板,prod 是生产环境模板。Web 服务代码结构
http 服务主要代码,提供给 main 使用,这些控制整个服务流程,伪代码包括过程如下:// Init 应用初始化
func Init(ctx context.Context, opts ...Option) func() {
加载配置
err := config.LoadGlobalConfig(o.ConfigFile)
...
cfg := config.GetGlobalConfig()
初始化日志
loggerCall, err := InitLogger()
handleError(err)
初始化基础服务提供
initService()
srv := &http.Server{
Addr: addr,
Handler: InitWeb(),
...
}
go func() {
logger.Printf(ctx, "HTTP service start,listening address:[%s]", addr)
err := srv.ListenAndServe()
...
}()
}
//InitWeb 初始化web引擎
func InitWeb() *gin.Engine {
app := gin.New()
app.NoMethod(middleware.NoMethodHandler())
app.NoRoute(middleware.NoRouteHandler())
崩溃恢复
app.Use(middleware.Recovery())
权限校验
app.Use(middleware.auth())
设置跟踪ID
app.Use(middleware.Trace()
访问日志
app.Use(middleware.Logger()
跨域请求
if cfg.CORS.Enable {
app.Use(middleware.CORS())
}
// 注册/api路由
registerRouter(app)
return app
}
main 函数调用此初始化程序,实现了整个流程控制。历史工程中充分使用了 go package 的 init 函数,所有路由在 init 函数中初始化,类似:user.gofunc init(){
r.Get("/users/:id",func (c *gin.Context) {
})
}
直接在 main 函数中使用 “_” 引入该包,会先调用包中的初始化函数,这种使用方式让导入的包直接初始化,没有在程序中自己控制流程。跟踪 id 上下文,用户 id 上下文创建等操作。接口需要的操作函数封装,调用 internal 中的服务。日志
通过 contex 跟踪,日志中附加了 「跟踪 id」,「访问用户 user_id」。
这样日志中携带了 trace_id,user_id 等信息,可以方便的对跟踪与日志记录集成。