ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Go后端RESTful API开发最佳实践:从项目布局到性能调优

Go后端RESTful API开发最佳实践:从项目布局到性能调优 做了快五年Go后端从单体Web服务到微服务都碰过踩过的RESTful API设计坑能装一箩筐。很多团队把项目搭起来容易真正写起来才发现边界模糊handler里塞了一堆业务逻辑错误处理散落在各处校验报错格式不统一改起来牵一发动全身。这里把我这些年总结的Golang RESTful API开发最佳实践完整写一遍涵盖项目布局、中间件链、错误处理、请求校验、数据库访问、日志配置、测试策略和性能调优八个板块适合准备从零搭建HTTP服务、或者想重构现有项目的Go开发者参考。这些实践不是教科书理论每一条背后都有具体的踩坑经历和trade-off考量。我会先讲为什么这样做再给出能直接用的代码和配置最后补充那些文档里通常不会写的细节。1. 先从项目布局说起目录结构决定了代码的成长方向1.1 按层分包与按模块分包的选择Golang项目布局的争论从来不缺热度最常见的两派是按层分包和按模块分包。按层分包长这样controllers/、models/、services/、repositories/所有handler放一个包所有model放一个包。按模块分包则是user/、order/、payment/每个模块自带handler、service、repository。我的实际感受是中小型项目用按层分包初期很顺手但一旦业务模块超过四五个代码定位会越来越吃力。你在controllers/user.go里改一个接口可能要同时改models/user.go、services/user.go、repositories/user.go虽然它们都在不同目录但因为业务上属于同一模块你得来回跳转。按模块分包则让同一领域的东西聚在一起新增一个业务模块就是新增一个目录删除模块就是删一个目录边界清晰很多。我比较推荐的折中方案是顶层先按模块分模块内部再做简单分层。每层通过package名字天然隔离了访问边界外部只能调用service层暴露的方法repository的实现细节完全隐藏。这样做的好处是依赖方向单向流动不会出现service调handler这种倒挂。1.2 依赖注入的轻量解法很多文章一上来就推荐用dig或者fx做依赖注入容器但对大部分API服务来说重量级DI框架反而增加了心智负担。我一般只在main.go里手动组装依赖这种方式代码量少、依赖关系一目了然func main() { cfg : config.Load() db : store.NewMySQL(cfg) repo : repository.NewUserRepo(db) service : service.NewUserService(repo) handler : handler.NewUserHandler(service) router : setupRouter(handler) router.Run(cfg.ListenAddr) }每新增一个依赖只在main函数里加一行。项目规模大了发现main函数太长、组装逻辑太多时再考虑引入容器也不迟。过早引入DI框架反而会让大家争论bean应该怎么注入这种和业务无关的问题。1.3 目录设计里最容易犯的错新手最常见的错误是把models当垃圾桶所有结构体全塞进去数据库实体、请求参数、响应结构体、内部传输对象全放一起。这会导致包越滚越大改了响应结构体可能影响数据库映射。我的做法是把结构体定义在离使用场景最近的包内handler层定义CreateUserRequest、UserResponse这类API视图结构体repository层定义数据库实体service层定义领域对象或者直接复用repository实体三个层之间的转换逻辑显式写在函数里别用反射搞自动映射。反射映射看着省事排查问题时你会疯掉——根本不知道哪个字段映射错了。2. 路由注册与中间件一个HTTP请求进来后的完整链路2.1 路由注册的演进路线用标准库写路由时最简单的做法是http.HandleFunc(/users, createUser) http.HandleFunc(/users/, getUser)路线很快就出问题方法路由要自己判断级别路径参数要自己解析/users/前缀匹配还会不小心吞掉/usersetting这种路径。所以很快大家转向了更灵活的路由库像Gin、Echo、Chi这些。它们底层大多是前缀树radix tree匹配支持路径参数、通配符和优先级匹配性能还远高于标准库的逐条遍历。这里我提醒一下路由尽量不要散落在每个handler文件里而是集中注册。可以有一个router.go也可以每个模块提供一个RegisterRoutes方法但最终都在setupRouter函数里统一挂载。原因很简单接手项目的人需要一眼能看到全站接口清单而不是去十几个文件里翻。2.2 中间件执行顺序一个容易被忽略的关键点中间件执行顺序是RESTful API开发的高频坑。先看Gin的示例router : gin.New() router.Use(gin.Logger(), gin.Recovery()) router.Use(RequestID()) router.Use(CORSMiddleware()) api : router.Group(/api, AuthMiddleware())中间件按注册顺序叠加但理解执行顺序要分两段看请求进来时按注册顺序执行注册越早的先执行然后所有中间件里如果有c.Next()它前面的代码在请求处理前执行后面的代码在handler返回后执行。这个顺序决定了日志中间件必须注册在恢复中间件之后否则panic发生时日志还没记上。我常用的中间件注册顺序是Recovery——兜底panic防止进程崩溃RequestID——给请求打ID后续日志全靠它串联CORS——应对跨域放在早期避免后续链路过早拒绝Logger——记录每个请求的路由、耗时、状态码Auth——鉴权需要放在业务中间件之前RateLimit——限流一般放在鉴权之后2.3 依赖注入的轻量解法另一个实战里反复遇到的点如何在中间件里传递用户身份信息。常见做法是在鉴权中间件里把用户信息塞进gin.Contextfunc AuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { token : c.GetHeader(Authorization) userID, err : parseToken(token) if err ! nil { c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse(http.StatusUnauthorized, invalid token)) return } c.Set(userID, userID) c.Next() } }handler里再通过c.GetUint64(userID)取出来。这种做法的代价是类型安全缺失——Set和Get是any类型取出来的东西类型不对不会编译报错只会在运行期panic。所以我把取值的逻辑也封装成一个函数func MustGetUserID(c *gin.Context) uint64 { v, exists : c.Get(userID) if !exists { panic(userID not found in context) } uid, ok : v.(uint64) if !ok { panic(userID wrong type) } return uid }有没有必要用context.Context传身份信息我的经验是仅靠context.Context无法解决这个问题因为context.Wit系列API要求不可变而请求处理过程中无法在中间件里优雅地更新context。Gin提供了c.Request.WithContext但修改后要重新赋值c.Request很多中间件不会这样处理。所以gin.Context的Set/Get本质上是请求级作用域变量适合传请求内的一次性数据不适合传业务参数。3. 统一错误响应每个API服务都要补上的一课3.1 为什么错误处理的现状通常很混乱Go的错误处理本身就是显式传递的所以最糟糕的开发方式就是把if err ! nil全都转换成HTTP 500if err ! nil { c.JSON(http.StatusInternalServerError, gin.H{error: err.Error()}) return }这样做的后果非常严重数据库连接错误对客户端暴露了内部细节用户输入错误被当成服务器故障日志里什么都查不到。更麻烦的是每个handler的错误响应结构都不一样有的返回{error: msg}有的返回{message: msg}前端对接时痛苦不堪。3.2 一个可复用的统一响应框架我给团队定的规范很固定所有接口无论成功失败都返回同一结构{ code: 0, message: success, data: {} }code是业务错误码0表示成功非0表示业务错误。HTTP状态码仍然表示传输层语义200表示请求已处理400表示客户端传参有问题401表示未认证403表示无权限500表示服务端异常。业务错误码用于客户端程序逻辑判断因为客户端不能依赖HTTP状态码做精细判断——比如一个校验失败可能是用户名重复、邮箱格式不对、密码太短这些是三种不同错误但HTTP状态码都是400。具体实现上我定义了一个BizError类型type BizError struct { HTTPCode int Code int Message string } func (e *BizError) Error() string { return e.Message } func NewBadRequest(code int, msg string) *BizError { return BizError{HTTPCode: http.StatusBadRequest, Code: code, Message: msg} }handler里遇到业务错误时var bizErr *BizError if errors.As(err, bizErr) { c.JSON(bizErr.HTTPCode, gin.H{code: bizErr.Code, message: bizErr.Message, data: nil}) return }其他未知错误统一记日志后返回500响应里不暴露内部细节。3.3 错误包装的实战经验真正交付之后你会发现光把错误抛回handler还不够。排查问题时最痛苦的是不知道这个错误从哪一层来的。所以我要求所有跨层调用的错误必须带上下文信息return fmt.Errorf(create user failed: %w, err)然后在handler里用errors.As解包errors.Is判断具体错误类型。这样日志里能看到完整调用链create user failed: ...同时错误类型还能被识别。经验之谈不要让业务代码直接暴露底层错误字符串。比如duplicate key错误MySQL的报错是个写死的一大串直接返回给客户端既难看又不安全。repository层要把这类错误翻译成自己的错误类型比如ErrDuplicateKeyservice层再决定返回什么提示给用户。4. 请求校验与参数绑定再花哨的框架也替代不了这一层4.1 用结构体tag声明校验规则Go社区最常用的校验方式是结构体tag validator库把校验规则声明在结构体字段上type CreateUserRequest struct { Username string json:username binding:required,min3,max32 Email string json:email binding:required,email Age int json:age binding:gte0,lte150 Password string json:password binding:required,min8 }Gin里使用ShouldBindJSON绑定var req CreateUserRequest if err : c.ShouldBindJSON(req); err ! nil { c.JSON(http.StatusBadRequest, gin.H{code: 40001, message: err.Error(), data: nil}) return }直接返回err.Error()通常会把validator库的英文tag原样抛给前端比如Key: CreateUserRequest.Email Error:Field validation for Email failed on the email tag。这种东西用户看不懂还暴露了内部结构体的字段名。前端的职责是给用户展示友好信息所以我们要把校验错误转换成用户看得懂的短语。核心思路是拿到validator包抛出的ValidationErrors逐条把field名翻译成中文或自定义描述func translateValidationError(err error) gin.H { var verrs validator.ValidationErrors if errors.As(err, verrs) { msgs : make(map[string]string) for _, fe : range verrs { msgs[fe.Field()] map[string]string{ required: 该字段必填, email: 邮箱格式不正确, min: fmt.Sprintf(最短长度 %s, fe.Param()), }[fe.Tag()] } return gin.H{code: 40001, message: 参数校验失败, data: msgs} } return gin.H{code: 40001, message: 参数格式错误, data: nil} }推荐在校验规则复杂时使用注册自定义validator的方式比如校验枚举值、时间区间、动态必填validate.RegisterValidation(enum, func(fl validator.FieldLevel) bool { allowed : strings.Split(fl.Param(), ,) val : fl.Field().String() for _, a : range allowed { if val a { return true } } return false })自定义validator的好处是复用性高新增一个字段要枚举校验只需在tag里写成binding:enumcreated,pending,failed不用改业务逻辑。4.2 场景差异同一结构体不同接口的校验规则业务里经常遇到同一个请求体另一处却要求字段必填的情况创建用户时phone可选但更新手机号接口必须传。两种做法做法一是定义两个结构体各自声明tag。做法二用binding的required配合自定义validator判断场景。我倾向做法一校验规则跟着接口走结构体不跨接口复用。即使会有部分字段重复代码多几行换来的是每个接口的校验规则清晰可查。另外还有一个容易踩坑的点ShouldBindJSON在遇到未知字段时默认不报错。如果团队要求严格前端传错字段名必须尽早暴露可以这样配置// Gin中全局只允许绑定字段包含在结构体内不会报错 // 必须显式设置disallowunknownfields decoder : json.NewDecoder(c.Request.Body) decoder.DisallowUnknownFields()注意这会和Gin的binding产生一些联动实际操作时最好封装一下。大多数项目其实可以接受未知字段静默忽略毕竟前端兼容旧字段是常事。我强烈建议在API里增加手动确认字段存在的逻辑如果请求结构体要求严格那么解析前先解码到map[string]json.RawMessage检查key是否存在之后再绑定。这在支付、订单这类对字段敏感的场景非常有用。5. 数据库访问层的组织方式连接池、事务与查询边界5.1 连接池参数别拍脑袋乱设Go的database/sql自带连接池但默认参数比较保守高并发场景下需要手动调优。我的参考配置db.SetMaxOpenConns(100) // 最大打开连接数 db.SetMaxIdleConns(25) // 最大空闲连接数 db.SetConnMaxLifetime(30 * time.Minute) // 连接最大存活时长 db.SetConnMaxIdleTime(5 * time.Minute) // 空闲超时SetMaxOpenConns的取值不能闭眼设100它取决于数据库实例的max_connections。比如MySQL默认151你开了两个服务实例每个100直接打满数据库连接。正确的计算方法是max_connections / 服务实例数 * 0.8。SetConnMaxLifetime很重要防止数据库侧主动断开后客户端还在用旧连接出现connection refused但实际数据库没挂的诡异现象。5.2 事务管理的正确姿势在service层直接操作*sql.DB时事务容易写成这样func CreateOrder(ctx context.Context, db *sql.DB, params OrderParams) error { tx, err : db.BeginTx(ctx, nil) if err ! nil { return err } defer tx.Rollback() // 业务逻辑... return tx.Commit() }代码能跑但问题在于业务逻辑分散在函数内部后续想在同一个事务里再执行一个repository方法时很别扭——要么把tx当参数到处传要么重新写一遍。我采用的方案是事务回调模式。定义一个WithTransaction函数把事务的执行细节集中起来业务方法只要传入回调func WithTransaction(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error { tx, err : db.BeginTx(ctx, nil) if err ! nil { return err } defer func() { if p : recover(); p ! nil { tx.Rollback() panic(p) } }() if err : fn(tx); err ! nil { tx.Rollback() return err } return tx.Commit() }service层需要事务的复杂操作变成这样// service层 func (s *OrderService) CreateOrder(ctx context.Context, params CreateOrderParams) error { return WithTransaction(ctx, s.db, func(tx *sql.Tx) error { if err : s.repo.CreateOrder(ctx, tx, params); err ! nil { return err } return s.repo.DeductStock(ctx, tx, params.ProductID, params.Quantity) }) }这样做的好处是事务的开启和提交集中在底层业务层不需要关心何时Rollback和Commit。5.3 查询超时用context控制SQL执行时间线上大量问题源于一个慢SQL把连接占住导致连接池耗尽。解决很难控制SQL执行超时很简单创建带超时的contextctx, cancel : context.WithTimeout(context.Background(), 3*time.Second) defer cancel() err : db.QueryRowContext(ctx, SELECT ...).Scan(user)如果SQL 3秒没返回连接会被自动释放业务收到context deadline exceeded日志里一查就知道哪个查询慢。这个习惯最好从第一行代码就开始养成等出问题再补会漏掉很多调用点。5.4 仓储接口抽象到什么程度才算合适很多人一上来就写完整套repository接口然后疯狂写mock。我的观点是接口抽象的目的是为了测试和替换实现不是为了给人观赏。如果一个项目只有一个数据库实现也没计划换数据库那直接导出具体的repository结构体就可不用定义接口。不过有一个例外当业务逻辑依赖多种存储时MySQL主库 Redis缓存 Elasticsearch索引必须定义接口。因为service层的代码需要面对抽象否则“先写缓存再写库”“先删索引再更新库”这类操作没法优雅切实现。我在第三层项目里常用的做法是repository层暴露具体实现service层通过接口接收依赖type UserRepo interface { GetByID(ctx context.Context, id uint64) (*User, error) Create(ctx context.Context, u *User) error }main函数里把具体实现转成接口赋值测试时用一个stub实现替代就能跑service层的单元测试。6. 日志与配置上线前必须补齐的工程能力6.1 结构化日志代替println开发时用fmt.Println输出日志很爽上线之后面对数百行无结构文本只能靠字符串搜索效率极低。现代Go项目建议直接用标准库log/slogGo 1.21自带开箱即用的结构化日志logger : slog.New(slog.NewJSONHandler(os.Stdout, nil)) slog.SetDefault(logger) slog.Info(user created, user_id, userID, duration_ms, 42, request_id, reqID, )JSON格式日志能被日志平台按字段索引查询、聚合、告警都能做。开发环境想人类可读用NewTextHandler代码里封装一个根据环境切换的setupLogger函数这是上线前最基础的工作。日志级别也有讲究业务逻辑里的日志默认Info但只有真正需要排查问题的地方才用Debug调试级日志不能喷得到处都是否则线上Debug一开日志量直接爆炸。ERROR级别的日志必须带上足够的上下文哪个用户、哪个请求ID、哪个函数、原始错误是什么缺一不可。6.2 请求日志与调用链ID接入请求日志中间件之后每个接口的耗时、状态码、客户端IP都会落日志func LoggerMiddleware(logger *slog.Logger) gin.HandlerFunc { return func(c *gin.Context) { start : time.Now() c.Next() logger.Info(http request, method, c.Request.Method, path, c.Request.URL.Path, status, c.Writer.Status(), duration_ms, time.Since(start).Milliseconds(), request_id, c.GetString(request_id), ) } }关键点是request id。我一般用一个前置中间件生成或透传X-Request-ID写入gin.Context产物就是一个请求从入口到数据库查询所有日志都带着同一个request id。这样排查问题时直接按request id搜索整条调用链一目了然。没有它跨服务排错基本靠猜时间戳。6.3 配置管理的三条原则配置管理的原则就三条外部注入、显式加载、禁止全局变量。配置来源优先级一般为命令行flag 环境变量 配置文件。在我的项目里最简单实用的方案是配置文件管静态数据数据库地址、日志级别、端口环境变量管敏感数据密码、密钥、token。配置文件可以用YAML或者JSON但一定要有个结构体统一承载不能让大家随手从环境变量里取字符串type Config struct { Port int yaml:port DBHost string yaml:db_host DBUser string yaml:db_user DBPassword string env:DB_PASSWORD }全局配置变量的反模式很常见config.Get().DBHost哪里都能用看起来方便实际是让配置变成隐藏依赖。正确的做法是在main函数里加载配置后作为参数传给需要它的构造函数。少数地方确实需要全局访问比如日志组件初始化那么至少把配置对象封装成只读的禁止运行时修改。7. 测试策略从handler到repository的覆盖面7.1 handler测试不走网络端口Gin的gin.CreateTestContext配合httptest可以完全不监听端口直接在内存里测func TestUserHandler_CreateUser(t *testing.T) { gin.SetMode(gin.TestMode) router : setupRouter(mockUserService{}) body : strings.NewReader({username:alice,email:ab.com,password:12345678}) req : httptest.NewRequest(http.MethodPost, /api/users, body) req.Header.Set(Content-Type, application/json) w : httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code ! http.StatusOK { t.Fatalf(status %d, want %d, body %s, w.Code, http.StatusOK, w.Body.String()) } // 验证返回结构体字段 }用httptest.NewRecorder捕获响应不需要真实端口测试速度和稳定性都好。handler测试重点验证的是路由是否匹配、校验规则是否生效、错误码是否合理、响应结构是否和前端约定一致。7.2 service层测试用stub替换仓储service层依赖repository接口测试时注入stub实现。注意stub要做的事情很简单不要引入第三方mock框架手工写一个带有期望返回的结构体即可type mockUserRepo struct { getUserResult *User getUserErr error createErr error } func (m *mockUserRepo) GetByID(ctx context.Context, id uint64) (*User, error) { return m.getUserResult, m.getUserErr } func (m *mockUserRepo) Create(ctx context.Context, u *User) error { return m.createErr }然后再测试service层各种边界仓储返回error、用户不存在、创建成功等。好处是测试跑得极快不依赖真实数据库覆盖率高。7.3 集成测试要隔离数据库真正验证SQL语句正确性只能依赖集成测试。我建议单独放一个integration目录通过build tag隔离//go:build integration package integration func TestCreateUserIntegration(t *testing.T) { // 连接真实测试库 // 执行迁移 // 插入数据、调用API、断言数据库 }跑的时候用go test -tagsintegration ./integration/。集成测试不能和单元测试混在一起否则每次go test ./...都要连数据库开发体验极差。7.4 表驱动测试覆盖边界的最佳武器Go社区对表驱动的偏好不是没有道理。一个handler的校验测试用表格列出各种输入和期望响应既清晰又容易扩展func TestUserHandler_CreateUserValidation(t *testing.T) { tests : []struct { name string body string wantCode int }{ {missing_username, {email:ab.com,password:12345678}, 400}, {invalid_email, {username:alice,email:bad,password:12345678}, 400}, {short_password, {username:alice,email:ab.com,password:1}, 400}, {success, {username:alice,email:ab.com,password:12345678}, 200}, } for _, tt : range tests { t.Run(tt.name, func(t *testing.T) { // 发送请求断言code }) } }这个习惯养成后你会发现测试的覆盖面逻辑非常清晰。8. 性能、文档与上线把RESTful API从能跑变得好用8.1 handler厚度控制别让路由层成为业务集散地见过太多handler里直接写SQL、直接操作Redis、直接调第三方接口的项目。handler的唯一职责是解析请求、调用service、写回响应。一个逻辑厚的handler难以测试、难以复用、代码结构也逐渐腐烂。判断handler是否需要瘦身的简单标准如果handler里的逻辑超过20行且包含if判断、循环、错误分支就应该下沉到service。这是个硬性提醒特别是那些先上线再说的项目越到后期越难拆分。8.2 API文档自动生成RESTful API最大的痛点是文档和代码脱节。用Swaggo风格的注释生成OpenAPI文档是Go生态的主流选择。在handler上写好注释// CreateUser 创建用户 // Summary 创建用户 // Tags user // Accept json // Produce json // Param request body CreateUserRequest true 请求参数 // Success 200 {object} Response{dataUserResponse} // Router /api/users [post] func CreateUser(c *gin.Context) { ... }然后用工具自动生成swagger.json前端可以直接导入到接口管理工具里调试。关键点在于要形成提交代码前跑一遍文档生成的CI检查否则注释写久了还是会烂。文档里的示例值要真实可用字段说明写清楚枚举含义这是减少前后端联调撕扯最有效的投入。8.3 优雅停机与部署形态生产环境的API服务升级最怕直接kill进程导致正在处理的请求被切断。Go的http server自带优雅停机能力srv : http.Server{ Addr: cfg.ListenAddr, Handler: router, } go srv.ListenAndServe() quit : make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) -quit ctx, cancel : context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err : srv.Shutdown(ctx); err ! nil { log.Fatal(server forced to shutdown:, err) }接口需要在关闭前把存量请求处理完新请求在这个窗口内会被拒绝。容器部署时配合的健康检查也要照顾这点关闭前要让负载均衡器摘掉流量否则可能出现服务还在接受新请求但已经在关闭的情况。8.4 性能优化顺序先测后调不要凭感觉优化。Go自带net/http/pprof上线前压一把把profile文件拉下来分析import _ net/http/pprof // 生产环境不要暴露pprof端口用内部才可访问的地址 go func() { http.ListenAndServe(127.0.0.1:6060, nil) }()压测时重点看三个指标堆分配量alloc_objects、阻塞时间block、Goroutine数量。先用pprof定位热点再考虑要不要引入缓存、加索引、优化SQL。绝大多数API服务瓶颈都在数据库查询和序列化上不会是某个中间件的几千行代码。另外一个自检项是JSON序列化利用率。Go的encoding/json性能虽已大幅提升但大面积反射序列化在极端流量下依然显著影响CPU。如果接口QPS很高考虑手动json.Marshal构造map或者用更高效的序列化方案。不过这属于最后一步先确保业务正确和可观测性到位再谈极致性能。9. 从我带过的项目里提炼的几点体会这些实践落地过程中最大的共性问题是团队总觉得最佳实践是大公司才需要的小项目随便写写就好。但事实正相反——项目越小越经不起混乱。等业务增长后再重构成本永远比一开始写对要高。我特别想说三件事。第一错误处理统一是投入产出比最高的事。很多项目重构时最大的障碍不是代码复杂而是你根本不知道一个错误从哪来、为什么到handler变成了500。从第一天就规定错误包装和响应格式后续每一个新人都能遵循排查问题效率会高出至少一个量级。第二不要为了“最佳实践”而过度设计。接口抽象、依赖注入、事件驱动这些手段只有在你确实遇到对应问题时才需要用。对一个只有三张表的项目强行引入DDD分层只会让团队写出大量无业务含义的胶水代码。第三把测试和文档当作代码的一部分来维护。很多团队做技术分享时最头疼的不是功能实现而是阅读一个老模块需要花两天。好的目录结构、统一的错误处理、自动生成的API文档、清晰的测试用例就是给别人包括三个月后的自己最好的项目说明书。最后分享一个小习惯我每次写完一个新接口都会花两分钟自查三件事——handler里是否有超出职责的逻辑、错误分支是否都返回了统一结构、日志里是否能通过request id串起整条请求链路。坚持半年你会发现自己的代码质量提升得比自己想的快得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进