Go Gin框架MVC分层脚手架:从路由到数据访问的工程化设计
说真的写Go Web服务的人第一周都很爽。Gin框架上手快路由一挂JSON一返服务就跑起来了。但等你的项目从两三个接口长到二三十个接口团队从一个人变成三四个人同时改代码那种“爽感”会迅速被一种窒息感替代handler里堆满了业务逻辑数据库连接散落在各个文件配置全靠改常量新来的同事问一句“这个项目入口在哪、中间件是怎么挂的”你半天讲不清楚。所以我写了这个脚手架。它不是一个能把所有功能都塞进去的全家桶而是一个基于Go语言Gin框架的MVC分层骨架把路由、控制器、服务层、数据访问层、中间件、配置管理、参数校验、统一响应这些最常见的需求提前搭好。拿它开新项目你只需要关心业务代码写在哪里不需要再纠结“这个文件放哪个目录、错误怎么统一返回、数据库连接怎么复用”。这篇文章我打算把这个脚手架的完整设计思路、每一层怎么分部、踩过哪些坑、以及怎么按自己项目改造成合适的样子一次说清楚。适合刚学Go想了解工程化怎么落地的同学也适合已经在写Gin但觉得项目结构有点乱、想彻底重构一遍的团队参考。1. 整体设计为什么Gin也需要MVC先说一个争议很多人认为Gin本身就有路由和handler为什么要搞MVC是不是多此一举我的回答很直接Gin提供的只是“路由到函数”这层能力它不管你的业务逻辑该怎么组织。直接往handler里写业务短平快但项目一膨胀就完蛋。MVC的核心不是那三个字母而是“分层”和“单一职责”。controller管接收请求、解析参数、返回响应service管业务规则、事务、领域逻辑repository/model层管数据持久化。这样一拆每个函数的目标就清楚多了测试也好写哪个环节出问题也好查。1.1 脚手架的分层架构我最终采用的分层结构是这样的project-root/ ├── main.go ├── config/ │ ├── config.go │ └── config.yaml ├── router/ │ └── router.go ├── middleware/ │ ├── cors.go │ ├── logger.go │ ├── recovery.go │ └── request_id.go ├── controller/ │ ├── user_controller.go │ └── ... ├── service/ │ ├── user_service.go │ └── ... ├── repository/ │ ├── user_repo.go │ └── ... ├── model/ │ ├── user.go │ └── ... ├── request/ │ ├── user_request.go │ └── ... ├── response/ │ ├── response.go │ └── ... ├── pkg/ │ ├── database/ │ ├── logger/ │ └── ... └── go.modcontroller和service接口层是骨架的主干repository把数据库操作隔离在业务之外response统一返回结构request里放参数校验的结构体。这样分每个目录的职责都很固定新人进来看一眼目录名字就知道该往哪里放代码。千万不要看不起这种“约定”很多项目死在自由发挥上。1.2 为什么路由要用分组路由分组这个设计在脚手架里算是最值得拿出来说的。func InitRouter() *gin.Engine { r : gin.New() r.Use(middleware.RequestID()) r.Use(middleware.Logger()) r.Use(middleware.Recovery()) r.Use(middleware.CORS()) apiGroup : r.Group(/api/v1) { health : apiGroup.Group(/health) health.GET(, controller.HealthCheck) user : apiGroup.Group(/user) user.GET(/:id, controller.GetUser) user.PUT(/:id, controller.UpdateUser) } return r }分组的意义不只是为了让URL好看。它让你可以针对不同的分组挂不同的中间件。比如/api/v1/admin挂一个AdminAuth/api/v1/open挂一个RateLimit/api/v1/user挂一个JWT校验。中间件的作用域和路由一起管理后期扩展、加权限、加限流都变得特别自然。如果你的项目有版本迭代需求路由分组还能解决一个很现实的问题老接口不能破坏新接口要上线。/api/v1和/api/v2各走各的分组互不干扰等到v1流量完全迁移走再统一摘掉老路由。这个在多人协作、接口对接频繁的场景下真的能省去不少扯皮的事。2. 配置管理别再把配置写死在代码里刚开始写Gin的小项目大家最喜欢把数据库地址、服务端口这种东西直接写在main.go里。写死一时爽一换环境就火葬场。测试环境要连测试库生产环境要连生产库每次发版都改代码迟早出事。我的方案是用config.yaml做默认配置用环境变量做覆盖两个配合起来用。2.1 配置文件的加载脚手架里我用了一个非常轻量的方式package config import ( log os gopkg.in/yaml.v3 ) type Config struct { Server ServerConfig yaml:server Database DatabaseConfig yaml:database Log LogConfig yaml:log } type ServerConfig struct { Port string yaml:port Mode string yaml:mode } type DatabaseConfig struct { Host string yaml:host Port string yaml:port User string yaml:user Password string yaml:password DBName string yaml:dbname MaxIdle int yaml:max_idle MaxOpen int yaml:max_open } func Load() *Config { cfg : Config{} data, err : os.ReadFile(config/config.yaml) if err ! nil { log.Fatalf(read config file failed: %v, err) } if err : yaml.Unmarshal(data, cfg); err ! nil { log.Fatalf(parse config file failed: %v, err) } return cfg }为什么不直接用Viper这类重量级配置库不是不能用而是这个脚手架定位是“轻量够用”。Viper的功能很强大远程配置、多格式支持都有但对于一个中小型项目来说YAML加环境变量覆盖已经覆盖了90%的需求。少引入一个依赖就少一个维护成本。等哪天真需要动态配置中心了再把Load函数替换掉也不影响整体结构。注意生产环境千万别把数据库密码写在yaml文件里提交到Git仓库哪怕是私有仓库。正确做法是让配置支持从环境变量读取Docker部署时注入环境变量敏感信息不进代码库。2.2 环境变量的覆盖逻辑配置文件里留的是默认值比如本地开发连本机数据库。部署到服务器时在启动命令前加上环境变量覆盖export DB_HOST10.0.0.1 export DB_PORT3306 export DB_USERprod_user export DB_PASSWORDxxxx ./myapp这种做法的好处是镜像或二进制文件是完全相同的不同的环境只是环境变量的值不同。我之前见过一个团队每套环境都重新build一个包因为配置编译进去了想想都头大。我给这个脚手架预留了一个读取环境变量的函数func getEnv(key, fallback string) string { if v, ok : os.LookupEnv(key); ok { return v } return fallback }然后Load的时候YAML读初值环境变量覆盖终值。这个逻辑写起来很简单但部署体验会好很多值得固化到脚手架里。3. 中间件把横切逻辑从业务里剥离中间件是Gin最实用的设计之一。日志、鉴权、恢复、跨域这些逻辑如果混到controller里每个接口都要重复写代码会很难看。中间件这种独立于业务之外的横切关注点本质上跟Spring MVC里的Filter/AOP是一个思路。有过C#或Java Web经验的人应该对这个模式很熟。3.1 必装的四个中间件请求日志中间件是排查问题最趁手的工具。每个请求进来记录请求ID、方法、路径、状态码、耗时方便追踪问题。Gin自带的Logger中间件很基础我一般会在它基础上包一层打出自定义的请求ID。Recovery中间件处理panic必装。线上服务最怕某个接口panic导致整个进程崩掉。Gin官方提供了Recovery中间件默认行为是恢复并返回500。我在脚手架里做了增强recover之后不仅返回JSON格式的错误还会把堆栈打到日志里方便排查。CORS中间件是前后端分离项目的刚需。主要处理浏览器跨域时的预检请求和响应头。有些坑是必须踩过才知道的Access-Control-Allow-Origin不能设置成*的同时还想带CookieAccess-Control-Allow-Methods必须显式声明PUT、DELETE等方法否则前端用这些方法请求时会被浏览器拦截。RequestID中间件是我强烈建议每个项目都有的。给每个请求生成一个唯一ID日志里打上这个ID排查问题时只要把请求ID扔给前端就能在几十万行日志里精确拉出这个请求从头到尾的处理链路。3.2 中间件的执行时机Gin中间件的执行机制看一遍源码你就明白了。中间件是按注册顺序形成一条处理链请求进来时从头往后执行处理完业务之后如果中间件里有c.Next()之后的代码再按逆序执行收尾逻辑。func Logger() gin.HandlerFunc { return func(c *gin.Context) { start : time.Now() c.Next() // 注意这里先放行 latency : time.Since(start) log.Printf([%s] %s %s %d %v, c.GetString(request_id), c.Request.Method, c.Request.URL.Path, c.Writer.Status(), latency, ) } }理解这个顺序很重要。当你需要在一个“鉴权中间件”里决定是否放行后续处理时你会写c.Abort()而不是return。Abort会阻止后续handler执行但不会跳过当前中间件后面的收尾代码。这个细节新手很容易搞混。经验中间件的执行顺序就是注册顺序。鉴权中间件要放在路由处理函数之前注册否则请求已经进入业务逻辑了鉴权还没执行等于白挂。我曾经把鉴权中间件挂在某个分组下面又在下级分组里重复挂了一个结果请求被校验两次性能倒是小事关键是逻辑混乱排查了半天。3.3 CORS的配置细节这个其实要单独拉出来说因为实际项目里跨域的问题比想象中多。CORS不是只加一个header就行涉及浏览器预检机制。前后端分离项目中前端如果用了Authorization头或者请求方法是PUT、DELETE浏览器会先发一个OPTIONS预检请求。如果服务端没有正确处理OPTIONS前端就会报CORS错误但后端日志里根本看不到这个错误因为它被浏览器拦了。我脚手架里的CORS中间件长这样func CORS() gin.HandlerFunc { return func(c *gin.Context) { origin : c.GetHeader(Origin) if origin ! { c.Header(Access-Control-Allow-Origin, origin) c.Header(Access-Control-Allow-Credentials, true) c.Header(Access-Control-Allow-Methods, GET, POST, PUT, PATCH, DELETE, OPTIONS) c.Header(Access-Control-Allow-Headers, Content-Type, Authorization, X-Requested-With) } if c.Request.Method http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) return } c.Next() } }这里故意把Access-Control-Allow-Origin设置成动态反射来源而不是直接写*。因为Allow-Credentials: true和Allow-Origin: *是不能共存的浏览器会直接拒绝带上Cookie的跨域请求。所以开发环境、生产环境我都推荐动态读Origin配合白名单校验。如果不想带Cookie只做token鉴权那Allow-Origin写*倒也没什么问题。记住一个原则不加鉴权的跨域配置都是给黑客留后门生产环境最好做一下校验。4. 数据访问层GORM的正确打开方式Go生态里操作MySQLGORM算是最主流的ORM库了。但在脚手架里怎么初始化一个生产够用的数据库连接很多人做得太随意了。一个简单的gorm.Open完事连接池参数没设置长连接不释放数据库连接数直接被拖垮。4.1 初始化连接池以下是脚手架里数据库初始化的核心代码package database import ( fmt time gorm.io/driver/mysql gorm.io/gorm gorm.io/gorm/logger ) func Init(cfg *config.DatabaseConfig) *gorm.DB { dsn : fmt.Sprintf(%s:%stcp(%s:%s)/%s?charsetutf8mb4parseTimeTruelocLocal, cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DBName, ) db, err : gorm.Open(mysql.Open(dsn), gorm.Config{ Logger: logger.Default.LogMode(logger.Info), }) if err ! nil { panic(err) } sqlDB, err : db.DB() if err ! nil { panic(err) } sqlDB.SetMaxIdleConns(cfg.MaxIdle) sqlDB.SetMaxOpenConns(cfg.MaxOpen) sqlDB.SetConnMaxLifetime(time.Hour) return db }注意parseTimeTrue和locLocal这两个参数。Go里时间类型对应MySQL的datetime如果你不设置parseTimeGORM查回来的时间会是[]byte还要手动转。locLocal是让驱动使用本地时区解决时区差8个小时的问题。连接池三个参数我解释一下SetMaxOpenConns最大打开的连接数。设太大会把数据库压垮设太小并发一高就连接等待。一般按数据库实例规格来配小项目10-20足够。SetMaxIdleConns最大空闲连接数。空闲连接越多突发流量时越不用排队建连。一般不超过MaxOpen的一半。SetConnMaxLifetime连接最大存活时间。这个最重要很多老项目线上报“too many connections”就因为没有设置这个参数。MySQL默认的wait_timeout是8小时超过这个时间服务端会主动断开连接GORM这边不知道连接池里还是留着失效连接下一次请求就会报invalid connection。设成1小时让连接池主动轮换问题立马消失。4.2 为什么时间格式是20060102这里顺便解释一个很多Go新手都会困惑的问题为什么Go语言格式化时间要用2006-01-02 15:04:05这个特定的字符串我第一次看到这个也想骂人后来查了官方文档才明白这不是一个随意的格式字符串而是一个“记忆参考时间”。Go语言设计者规定格式化时间就是按照本地时间的这个特殊时刻来写的1月2日3点4分5秒2006年标准时区MST。它把“月日时分秒年时区”的位置全部锁定了。所以你想输出2024-05-01 10:00:00这种格式就写2006-01-02 15:04:05想输出20240501100000就写20060102150405。项目里如果遇到JSON序列化时间显示成RFC3339格式比如2024-05-01T10:00:0008:00前端解析不一定习惯。如果希望统一输出2006-01-02 15:04:05我的做法是定义一个自定义时间类型type JsonTime time.Time func (t JsonTime) MarshalJSON() ([]byte, error) { formatted : fmt.Sprintf(\%s\, time.Time(t).Format(2006-01-02 15:04:05)) return []byte(formatted), nil }然后在模型里用JsonTime替代time.Time。这一点源于GORM中字段类型的设计很多初学者会在模型里反复纠结时间字段怎么格式化输出用一个自定义类型一次解决。4.3 基础Model设计仓库里我留了一个基础模型提供了主键ID、创建时间、更新时间、软删除标记这几个通用字段type BaseModel struct { ID uint gorm:primaryKey json:id CreatedAt JsonTime json:created_at UpdatedAt JsonTime json:updated_at DeletedAt gorm.DeletedAt gorm:index json:- }现在几乎每个表都需要这几个字段把它们放到嵌入结构体里从Model层就统一了。gorm.DeletedAt是GORM的软删除机制调用db.Delete(user)时不会物理删除而是把deleted_at字段置为非零值查询的时候自动加WHERE deleted_at IS NULL。这层逻辑对业务完全透明但数据恢复、审计溯源都方便很多强烈建议默认开启。5. 业务闭环从请求进来到响应返回前面讲了分层和基础组件这一节我用一个“创建用户”的需求把整个链路的代码走一遍。看完你应该能明白每一层该写什么、不该写什么。5.1 Request入参定义与校验请求参数是第一个关卡。新手喜欢直接在handler里写c.ShouldBindJSON(user)然后user就是一个model结构体。这种做法的问题很明显model是数据库映射字段往往是全量的请求参数可能只需要其中三个字段而且可能还需要额外的confirmPassword这种不在表里的字段。一旦接口一多model上全是标签一团乱麻。所以脚手架单独建了一个request包专门放每个接口的入参结构体package request type CreateUserRequest struct { Name string json:name binding:required,min2,max20 Email string json:email binding:required,email Phone string json:phone binding:required,len11 Password string json:password binding:required,min6,max32 }Gin的binding标签基于go-playground/validator支持required、min、max、email、len等等。这些内置校验器基本能满足日常校验需求如果还不够validator也支持自定义校验器后面我会专门讲一个踩坑案例。5.2 Controller只做“翻译官”controller的任务是接收HTTP参数、调用service、返回HTTP响应。它不应该包含任何“用户不能重复注册”这种业务判断这些都是service层的事。package controller type UserController struct { userService service.UserService } func NewUserController(userService service.UserService) *UserController { return UserController{userService: userService} } func (uc *UserController) Create(c *gin.Context) { var req request.CreateUserRequest if err : c.ShouldBindJSON(req); err ! nil { response.Fail(c, http.StatusBadRequest, err.Error()) return } user, err : uc.userService.CreateUser(c.Request.Context(), req) if err ! nil { response.Fail(c, http.StatusInternalServerError, err.Error()) return } response.Success(c, user) }你没看错controller就这么薄。参数错了返回400业务错了返回500成功了返回data。核心控制逻辑就这些。后面的service才是主力。这里把service定义成了接口是为了测试时方便mock。controller依赖接口不依赖具体实现单元测试时传入一个假service就能测HTTP层逻辑。接口的粒度不用太细一个业务域一个接口就行比如UserService里放CreateUser/GetUser/UpdateUser/DeleteUser。5.3 Service业务规则的真主场service是核心业务逻辑所在事务处理也在这层。比如创建用户时要校验邮箱是否被占用、要给密码加密、要写入数据库这些一气呵成最好在一个事务里。package service type UserService interface { CreateUser(ctx context.Context, req *request.CreateUserRequest) (*response.UserResponse, error) } type userService struct { userRepo repository.UserRepo } func NewUserService(userRepo repository.UserRepo) UserService { return userService{userRepo: userRepo} } func (s *userService) CreateUser(ctx context.Context, req *request.CreateUserRequest) (*response.UserResponse, error) { // 1. 校验业务规则 exists, err : s.userRepo.IsEmailExists(ctx, req.Email) if err ! nil { return nil, err } if exists { return nil, errors.New(email already registered) } // 2. 构造数据模型 hashedPwd, _ : bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) user : model.User{ Name: req.Name, Email: req.Email, Phone: req.Phone, Password: string(hashedPwd), } // 3. 调用仓库层落库 if err : s.userRepo.Create(ctx, user); err ! nil { return nil, err } // 4. 返回响应对象 return response.UserResponse{ ID: user.ID, Name: user.Name, Email: user.Email, Phone: user.Phone, }, nil }密码我用的golang.org/x/crypto/bcrypt。明文密码存库是绝对红线MD5和SHA256也不建议因为彩虹表很容易破解。bcrypt的一个特点是每次生成的hash值不同自带盐虽然计算慢一点但抗暴力破解能力远强于普通哈希。5.4 Repository数据访问的最终实现repository层目前看起来最简单就是CRUD但它隔离了整个数据源。将来如果把某张表的数据源从MySQL换成Redis或者对接一个远程API只需要改repository的实现service完全不用动。package repository type UserRepo interface { Create(ctx context.Context, user *model.User) error GetByID(ctx context.Context, id uint) (*model.User, error) IsEmailExists(ctx context.Context, email string) (bool, error) } type userRepo struct { db *gorm.DB } func NewUserRepo(db *gorm.DB) UserRepo { return userRepo{db: db} } func (r *userRepo) Create(ctx context.Context, user *model.User) error { return r.db.WithContext(ctx).Create(user).Error } func (r *userRepo) GetByID(ctx context.Context, id uint) (*model.User, error) { var user model.User err : r.db.WithContext(ctx).First(user, id).Error return user, err }注意这里每个方法都传了ctxGORM会把这个context带到数据库驱动层一旦请求超时或客户端断开数据库操作也会跟着取消不会游离在后台继续执行。这个习惯值得从第一天就养成。5.5 Response统一返回结构统一返回结构是我做接口设计时非常坚持的一点。前端对接多个后端接口时最烦的就是每个接口返回格式都不一样。我脚手架里的统一结构是{ code: 0, message: success, data: { } }code为0表示成功非0表示业务错误码。不直接用HTTP状态码做业务判断是因为HTTP状态码语义有限比如你没法区分“密码错误”和“用户被禁用”都是403。业务码放在body里前端拿到code后做精确处理。HTTP状态码只保留最基本的200成功、400参数错误、401未登录、403无权限、500服务异常。func Success(c *gin.Context, data interface{}) { c.JSON(http.StatusOK, gin.H{ code: 0, message: success, data: data, }) } func Fail(c *gin.Context, httpStatus int, message string) { c.JSON(httpStatus, gin.H{ code: httpStatus, message: message, data: nil, }) }这个响应封装很短但价值非常大。前端接口联调时只需要读取固定的三个字段也不用为每个接口单独写解析逻辑。6. 常见问题与排查技巧实录最后这部分是我长期用这个脚手架写业务之后沉淀下来的一些高频问题和解决方案。每个问题背后都是实打实的教训照着排查能帮你省几个晚上的时间。6.1 参数校验偶尔没生效场景我加了binding:required但前端传空字符串照样能进来。原因required校验只有在字段类型是非零值时才通过。空字符串、0、nil都会被判定为缺失。如果业务上规定空字符串合法那就不能直接required需要自己实现一个指针字段或者改为自定义校验器。排查办法先在handler里打印err的详细内容看是哪个字段触发了校验失败再对应调整。validator的错误消息默认是全英文的而且格式不友好建议封装一层错误翻译函数把字段名对应成中文提示。6.2 数据库连接泄漏场景压测时数据库连接数狂飙直到“too many connections”。原因多半是某个查询返回了错误但代码没关闭rows或者没处理sql.ErrNoRows。GORM的First如果找不到记录会返回gorm.ErrRecordNotFound不能直接忽略错误继续走。排查办法第一步看监控确认连接数是否随时间只增不减第二步查日志找出有没有未提交事务或未关闭的查询第三步检查代码里有没有在service里手动db.Begin()却没写defer tx.Rollback()的。事务必须要配对使用Commit或Rollback必须走一个否则连接会被一直占用。6.3 路由冲突场景写了/user/:id之后再写/user/search编译过了但启动时panic提示冲突。原因Gin的路由树是基于httprouter的改良版对通配符和静态路由有严格约束。你不能同时在一个位置既允许:id参数又允许search这种静态路径因为路由树会不知道应该匹配哪个。解决方案有两种一是把静态路由放在通配符路由之前声明Gin在某些情况下可以处理二是直接改变路径设计比如/user/search改为/user/preview/search或用查询参数/user?actionsearch。项目里最一劳永逸的方案是同一层级避免静态路径和参数路径共存。6.4 时间字段序列化问题场景model里定义的是time.Time接口返回JSON变成了2024-05-01T10:00:00.00008:00这种格式前端同学说不想要这个T。原因Go的encoding/json对time.Time默认序列化成RFC3339格式这个格式标准但对业务系统不算友好。解决方案参考我在4.2里写的JsonTime自定义类型。但注意一点自定义MarshalJSON之后反序列化也需要对应实现UnmarshalJSON否则前端用2006-01-02 15:04:05传时间给后端时你会报“parsing time ... cannot parse”。脚手架的pkg/utils里我留了一个完整的实现模板直接复制就能用。6.5 中间件里开协程注意安全场景有个日志中间件里放了个go func()想异步写日志结果panic了恢复不了直接把整个进程搞崩了。原因Recovery中间件只能恢复当前goroutine的panic你手动开的协程panic它是接不住的。除非在协程内部自己recover否则进程直接崩。解决方案异步任务必须自己捕获异常。更稳妥的做法是先用同步方式写日志等业务真正需要高吞吐的时候再引入消息队列不要在中间件里自作聪明开协程。这一点在写任何Gin项目时都成立业务代码里的每一条goroutine都要能承受panic导致的进程崩溃风险否则就别开。6.6 自动迁移到底要不要开场景开发阶段开AutoMigrate确实省事表结构变了重启就自动加列。但有次生产环境重新部署后表结构跟老代码不匹配业务全乱了。原因AutoMigrate只负责加不负责删也不负责数据转换。旧数据没适配新字段代码一升级就崩。经验开发环境开AutoMigrate提高效率生产环境一定要关掉用专门的数据库迁移工具比如golang-migrate/migrate来管理。每次发版前先跑SQL迁移再部署新代码。这个流程初期麻烦但长期来看比靠AutoMigrate维护表结构安全得多。6.7 跨域预检请求导致接口重复执行场景前端POST提交数据发现后端接口执行了两次。原因跨域请求时如果请求方法是POST并且Content-Type是application/json浏览器会先发一个OPTIONS预检请求。后端如果没对OPTIONS做拦截处理就会走到业务handler里执行了两次。解决方案全局CORS中间件里对OPTIONS直接AbortWithStatus(http.StatusNoContent)返回204不再往下走。这也是我在3.3里为什么不省略那个判断的原因。7. 收尾这个脚手架还能怎么改先泼一盆冷水网上号称“满足大部分场景”的脚手架太多了最后都是作者的场景不是你的场景。真正的用法是把它当起点而不是终点。我从这个脚手架里受益最大的不是它帮我“省了多少事”而是它强迫我提前把项目结构、接口规范、错误处理这些基础问题想清楚了后面写业务时不用反复推倒重来。最后分享一个实操习惯每次接到新需求先不急着写代码先看这个需求应该落在脚手架哪一层。入参校验变了就改request层数据库逻辑变了就改repository层业务规则变了就改service层返回结构变了就改response层。如果发现一个改动要同时动三层以上大概率是设计出了问题回头检查依赖方向。这个脚手架我在GitHub上已经迭代了几轮从最初只有路由和handler的版本到慢慢加入service接口、request/response分离、连接池配置、CORS中间件每一步都是被真实项目逼出来的。希望这篇文章能帮你少踩几个我踩过的坑不用再纠结“目录怎么分、配置怎么管、错误怎么返”这些问题把时间留给真正有价值的业务逻辑。后面有空我会再写一篇怎么结合Docker Compose把这个脚手架一键部署起来包括Nginx反代、MySQL、Redis的一整套本地环境到时候可以直接照抄。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →