Go Gin框架MVC分层脚手架:从路由到数据访问的工程化设计
2026/9/17 15:07:48 网站建设 项目流程

说真的,写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.mod

controller和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_HOST=10.0.0.1 export DB_PORT=3306 export DB_USER=prod_user export DB_PASSWORD=xxxx ./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不能设置成*的同时还想带Cookie,Access-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()而不是returnAbort会阻止后续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: trueAllow-Origin: *是不能共存的,浏览器会直接拒绝带上Cookie的跨域请求。所以开发环境、生产环境,我都推荐动态读Origin,配合白名单校验。如果不想带Cookie,只做token鉴权,那Allow-Origin*倒也没什么问题。记住一个原则:不加鉴权的跨域配置都是给黑客留后门,生产环境最好做一下校验。

4. 数据访问层:GORM的正确打开方式

Go生态里操作MySQL,GORM算是最主流的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:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", 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 }

注意parseTime=Trueloc=Local这两个参数。Go里时间类型对应MySQL的datetime,如果你不设置parseTime,GORM查回来的时间会是[]byte,还要手动转。loc=Local是让驱动使用本地时区,解决时区差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:00+08: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,min=2,max=20"` Email string `json:"email" binding:"required,email"` Phone string `json:"phone" binding:"required,len=11"` Password string `json:"password" binding:"required,min=6,max=32"` }

Gin的binding标签基于go-playground/validator,支持requiredminmaxemaillen等等。这些内置校验器基本能满足日常校验需求,如果还不够,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 }

注意这里每个方法都传了ctx,GORM会把这个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()的。事务必须要配对使用,CommitRollback必须走一个,否则连接会被一直占用。

6.3 路由冲突

场景:写了/user/:id之后,再写/user/search,编译过了但启动时panic,提示冲突。

原因:Gin的路由树是基于httprouter的改良版,对通配符和静态路由有严格约束。你不能同时在一个位置既允许:id参数又允许search这种静态路径,因为路由树会不知道应该匹配哪个。

解决方案有两种:一是把静态路由放在通配符路由之前声明,Gin在某些情况下可以处理;二是直接改变路径设计,比如/user/search改为/user/preview/search或用查询参数/user?action=search。项目里最一劳永逸的方案是:同一层级避免静态路径和参数路径共存

6.4 时间字段序列化问题

场景:model里定义的是time.Time,接口返回JSON变成了2024-05-01T10:00:00.000+08:00这种格式,前端同学说不想要这个T。

原因:Go的encoding/jsontime.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的一整套本地环境,到时候可以直接照抄。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询