跳到主要内容

项目结构化

本章将介绍如何对项目进行结构化设计。

前言

到目前为止,我们已经利用 Go-Sail 构建了一个简单的登录服务,具备了读取配置、热重载与分布式链路追踪等基础能力。但你可能已经注意到一个潜在问题:我们当前的大部分代码都集中在 main.go 文件中。这并不是错误或不专业,而是为了让教程内容更直接、简明。在实际项目开发中,为了提升代码的可读性、维护性及保障项目的健康可持续演进,我们必须对项目进行正确的结构划分。

文件拆分

首先,我们将庞大的 main.go 文件拆分为若干小单元。遵循业界流行的 MVC 模式,将代码分为控制器(在 Go 生态中通常称为“处理函数”)、数据模型。由于我们是纯 API 服务,自然不需要 “View” 模块。此外,还增加了“路由”、“服务”、“配置”等模块。

处理函数(Handlers)

handlers.go
package main

import (
"github.com/gin-gonic/gin"
)

func Login(c *gin.Context) {
LoginSvc(c)
}

func ThirdPartyNotify(c *gin.Context) {
ThirdPartyNotifySvc(c)
}

中间件(Middlewares)

middlewares.go
package main

import (
"github.com/gin-gonic/gin"
sailConstants "github.com/keepchen/go-sail/v3/constants"
"github.com/keepchen/go-sail/v3/sail"
)

...

// ValidateToken 验证用户 Token
func ValidateToken() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.Request.Header.Get("Authorization")
if token != "this-is-a-valid-token" {
sail.Response(c).Wrap(sailConstants.ErrAuthorizationTokenInvalid, nil).Send()
return
}

c.Next()
}
}

...

模型(Models)

models.go
package main

type User struct {
Username string `gorm:"column:username;type:varchar(100);NOT NULL;comment:username"`
Password string `gorm:"column:password;type:varchar(1024);NOT NULL;comment:password"`
}

func (User) TableName() string {
return "users"
}

路由(Routes)

routes.go
package main

import (
"github.com/gin-gonic/gin"
sailMiddleware "github.com/keepchen/go-sail/v3/http/middleware"
)

func RegisterRoutes(ginEngine *gin.Engine) {
ginEngine.Use(sailMiddleware.DetectUserAgentLanguage())

ginEngine.POST("/login", Login)
userGroup := ginEngine.Group("/user").Use(ValidateToken())
{
userGroup.GET("/balance", ...).
GET("/info", ...).
GET("/logout", ...)
}
ginEngine.POST("/third-party/notify", ThirdPartyNotify)
}

服务(Services)

services.go
package main

import (
"fmt"
"net/http"

"go.uber.org/zap"

"github.com/gin-gonic/gin"
"github.com/keepchen/go-sail/v3/sail"
sailConstants "github.com/keepchen/go-sail/v3/constants"
)

func LoginSvc(c *gin.Context) {
var loginRequest LoginRequest
if err := c.ShouldBind(&loginRequest); err != nil {
sail.LogTrace(c).Warn("绑定请求参数失败", zap.Error(err))
sail.Response(c).Wrap(sailConstants.ErrRequestParamsInvalid, nil).Send()
return
}

if code, err := loginRequest.Validator(); err != nil {
sail.Response(c).Wrap(code, nil, err.Error()).Send()
return
}

var user User
sail.GetDBR().Where(&User{Username: loginRequest.Username}).First(&user)
// 用户不存在
if len(loginRequest.Username) == 0 {
sail.Response(c).Wrap(sailConstants.ErrRequestParamsInvalid, nil).Send()
return
}
// 密码不匹配
if loginRequest.Password != user.Password {
sail.Response(c).Wrap(sailConstants.ErrRequestParamsInvalid, nil).Send()
return
}

headers := map[string]string{
"X-Request-Id": sail.LogTrace(c).RequestID(),
}
sail.Utils().HttpClient().SendRequest("POST", "https://....", nil, headers)

token := "this-is-a-valid-token"
sail.Response(c).Data(token)
}

func ThirdPartyNotifySvc(c *gin.Context) {
c.JSON(http.StatusOK, "SUCCESS")
}

错误(Errors)

errors.go
package main

import (
"fmt"
"sync"

sailConstants "github.com/keepchen/go-sail/v3/constants"
)

type ErrorCode int

func (v ErrorCode) Int() int {
return int(v)
}

const (
ErrUserNotExist ErrorCode = 1000
ErrUserAlreadyExist ErrorCode = 1001
ErrUsernameAndPasswordNotMatch ErrorCode = 1002
)

var codeMsgMap = sailConstants.MMBox{
//en
sailConstants.LanguageEnglish: {
ErrUserNotExist: "User not exist",
ErrUserAlreadyExist: "User already exist",
ErrUsernameAndPasswordNotMatch: "Username and password not match",
},
//zh-CN
sailConstants.LanguageChinesePRC: {
ErrUserNotExist: "用户不存在",
ErrUserAlreadyExist: "用户已经存在",
ErrUsernameAndPasswordNotMatch: "用户名或密码不正确",
},
//ja
sailConstants.LanguageJapanese: {
ErrUserNotExist: "ユーザーが存在しません",
ErrUserAlreadyExist: "ユーザーは既に存在します",
ErrUsernameAndPasswordNotMatch: "ユーザー名またはパスワードが正しくありません",
},
//other langugage
}


var once sync.Once

func init() {
once.Do(func() {
time.AfterFunc(time.Second*2, func() {
for language, msgMap := range codeMsgMap {
for code, msg := range msgMap {
sail.Code().Register(language.String(), code.Int(), msg)
}
}
})
})
}

类型(Types)

types.go
package main

import (
"fmt"

sailConstants "github.com/keepchen/go-sail/v3/constants"
)

type LoginRequest struct {
Username string `json:"username" form:"username" query:"username"`
Password string `json:"password" form:"password" query:"password"`
}

func (v LoginRequest) Validator() (sailConstants.ICodeType, error) {
if len(v.Username) == 0 {
return sailConstants.ErrRequestParamsInvalid, fmt.Errorf("用户名不能为空")
}

if len(v.Password) == 0 {
return sailConstants.ErrRequestParamsInvalid, fmt.Errorf("密码不能为空")
}

return sailConstants.ErrorNone, nil
}

配置(Config)

config.go
package main

import (
"gopkg.in/yaml.v2"

"github.com/keepchen/go-sail/v3/sail"
"github.com/keepchen/go-sail/v3/sail/config"


var conf config.Config

func ParseConfig() {
parseFn := func(content []byte, viaWatch bool) {
if viaWatch {
// 配置已被热更新,可以做一些事情...
}
fmt.Println("配置内容: ", string(content))
yaml.Unmarshal(content, &conf)
}
sail.Config(parseFn).ViaFile("./go-sail.config.local.yaml").Parse(parseFn)
}

主入口(Main)

main.go
package main

import (
"github.com/gin-gonic/gin"
"github.com/keepchen/go-sail/v3/sail"
"github.com/keepchen/go-sail/v3/http/api"
)

var (
afterFunc = func() {
sail.GetDBW().AutoMigrate(&User{})
var user User
sail.GetDBW().Where(&User{Username:"go-sail"}).First(&user)
if len(user.Username) == 0 {
passwordEncrypted, err := sail.Utils().RSA().Encrypt("password", publicKey)
sail.GetDBW().Create(&User{Username:"go-sail", password: passwordEncrypted})
}

sail.GetLogger("schedule").Info("记录一些日志...")
}
)

func main() {
ParseConfig()

options := &api.Option{
ForceHttpCode200: true,
DetectAcceptLanguage: true,
}
sail.WakeupHttp("go-sail", &conf).
SetupApiOption(options).
Hook(RegisterRoutes, nil, afterFunc).Launch()
}

如上所述,我们将原有的 main.go 文件拆分成了多个文件。但到目前为止,所有代码依然都属于 main 包,并且文件全部位于同一个目录。实际工程开发远不止于此,结构颗粒度通常更细。

目录结构

真实项目中,通常会按模块将目录划分清楚,所归属的 Go 包也会随之变化。在上文结构化的基础上,我们进一步升级项目结构。

初始化模块

首先使用 go mod 进行包管理。本教程下,我们以 tutorials 命名初始化模块,命令如下:

go mod init tutorials

处理函数(Handlers)

http/handlers/user.go
package handlers

import (
"tutorials/http/services"

"github.com/gin-gonic/gin"
)

func Login(c *gin.Context) {
services.LoginSvc(c)
}
http/handlers/third-party.go
package handlers

import (
"tutorials/http/services"

"github.com/gin-gonic/gin"
)

func ThirdPartyNotify(c *gin.Context) {
services.ThirdPartyNotifySvc(c)
}

中间件(Middlewares)

http/middlewares/authorization.go
package middlewares

import (
"github.com/gin-gonic/gin"
sailConstants "github.com/keepchen/go-sail/v3/constants"
"github.com/keepchen/go-sail/v3/sail"
)

// ValidateToken 验证用户 Token
func ValidateToken() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.Request.Header.Get("Authorization")
if token != "this-is-a-valid-token" {
sail.Response(c).Wrap(sailConstants.ErrAuthorizationTokenInvalid, nil).Send()
return
}

c.Next()
}
}

模型(Models)

pkg/models/user.go
package models

type User struct {
Username string `gorm:"column:username;type:varchar(100);NOT NULL;comment:username"`
Password string `gorm:"column:password;type:varchar(1024);NOT NULL;comment:password"`
}

func (User) TableName() string {
return "users"
}

路由(Routes)

http/routes/routes.go
package routes

import (
"tutorials/http/middlewares"

"github.com/gin-gonic/gin"
sailMiddleware "github.com/keepchen/go-sail/v3/http/middleware"
)

func RegisterRoutes(ginEngine *gin.Engine) {
ginEngine.Use(sailMiddleware.DetectUserAgentLanguage())

ginEngine.POST("/login", Login)
userGroup := ginEngine.Group("/user").Use(middlewares.ValidateToken())
{
userGroup.GET("/balance", ...).
GET("/info", ...).
GET("/logout", ...)
}
ginEngine.POST("/third-party/notify", ThirdPartyNotify)
}

服务(Services)

http/services/user.go
package services

import (
"net/http"

"tutorials/http/types"
"tutorials/pkg/common"
"tutorials/pkg/models"

"go.uber.org/zap"

"github.com/gin-gonic/gin"
"github.com/keepchen/go-sail/v3/sail"
sailConstants "github.com/keepchen/go-sail/v3/constants"
)

func LoginSvc(c *gin.Context) {
var loginRequest types.LoginRequest
if err := c.ShouldBind(&loginRequest); err != nil {
sail.LogTrace(c).Warn("绑定请求参数失败", zap.Error(err))
sail.Response(c).Wrap(sailConstants.ErrRequestParamsInvalid, nil).Send()
return
}

if code, err := loginRequest.Validator(); err != nil {
sail.Response(c).Wrap(code, nil, err.Error()).Send()
return
}

var user User
sail.GetDBR().Where(&models.User{Username: loginRequest.Username}).First(&user)
// 用户不存在
if len(loginRequest.Username) == 0 {
sail.Response(c).Wrap(common.ErrUserNotExist, nil).Send()
return
}
// 密码不匹配
if loginRequest.Password != user.Password {
sail.Response(c).Wrap(sailConstants.ErrRequestParamsInvalid, nil).Send()
return
}

headers := map[string]string{
"X-Request-Id": sail.LogTrace(c).RequestID(),
}
sail.Utils().HttpClient().SendRequest("POST", "https://....", nil, headers)

token := "this-is-a-valid-token"
sail.Response(c).Data(token)
}
http/services/third-party.go
package services

import (
"net/http"

"github.com/gin-gonic/gin"
)

func ThirdPartyNotifySvc(c *gin.Context) {
c.JSON(http.StatusOK, "SUCCESS")
}

错误(Errors)

pkg/common/errors.go
package common

import (
"fmt"
"sync"

sailConstants "github.com/keepchen/go-sail/v3/constants"
)

type ErrorCode int

func (v ErrorCode) Int() int {
return int(v)
}

const (
ErrUserNotExist ErrorCode = 1000
ErrUserAlreadyExist ErrorCode = 1001
ErrUsernameAndPasswordNotMatch ErrorCode = 1002
)

var codeMsgMap = sailConstants.MMBox{
//en
sailConstants.LanguageEnglish: {
ErrUserNotExist: "User not exist",
ErrUserAlreadyExist: "User already exist",
ErrUsernameAndPasswordNotMatch: "Username and password not match",
},
//zh-CN
sailConstants.LanguageChinesePRC: {
ErrUserNotExist: "用户不存在",
ErrUserAlreadyExist: "用户已经存在",
ErrUsernameAndPasswordNotMatch: "用户名或密码不正确",
},
//ja
sailConstants.LanguageJapanese: {
ErrUserNotExist: "ユーザーが存在しません",
ErrUserAlreadyExist: "ユーザーは既に存在します",
ErrUsernameAndPasswordNotMatch: "ユーザー名またはパスワードが正しくありません",
},
//other langugage
}


var once sync.Once

func init() {
once.Do(func() {
time.AfterFunc(time.Second*2, func() {
for language, msgMap := range codeMsgMap {
for code, msg := range msgMap {
sail.Code().Register(language.String(), code.Int(), msg)
}
}
})
})
}

类型(Types)

http/types/user.go
package types

import (
"fmt"

sailConstants "github.com/keepchen/go-sail/v3/constants"
)

type LoginRequest struct {
Username string `json:"username" form:"username" query:"username"`
Password string `json:"password" form:"password" query:"password"`
}

func (v LoginRequest) Validator() (sailConstants.ICodeType, error) {
if len(v.Username) == 0 {
return sailConstants.ErrRequestParamsInvalid, fmt.Errorf("用户名不能为空")
}

if len(v.Password) == 0 {
return sailConstants.ErrRequestParamsInvalid, fmt.Errorf("密码不能为空")
}

return sailConstants.ErrorNone, nil
}

配置(Config)

config/config.go
package config

import (
"gopkg.in/yaml.v2"

"github.com/keepchen/go-sail/v3/sail"
sailConfig "github.com/keepchen/go-sail/v3/sail/config"


var conf sailConfig.Config

func ParseConfig() {
parseFn := func(content []byte, viaWatch bool) {
if viaWatch {
// 配置已被热更新,可以做一些事情...
}
fmt.Println("配置内容: ", string(content))
yaml.Unmarshal(content, &conf)
}
sail.Config(parseFn).ViaFile("./go-sail.config.local.yaml").Parse(parseFn)
}

func Get() *sailConfig.Config {
return &conf
}

主入口(Main)

main.go
package main

import (
"tutorials/config"
"tutorials/http/routes"
"tutorials/pkg/models"

"github.com/gin-gonic/gin"
"github.com/keepchen/go-sail/v3/sail"
"github.com/keepchen/go-sail/v3/http/api"
)

var (
afterFunc = func() {
sail.GetDBW().AutoMigrate(&models.User{})
var user User
sail.GetDBW().Where(&models.User{Username:"go-sail"}).First(&user)
if len(user.Username) == 0 {
passwordEncrypted, err := sail.Utils().RSA().Encrypt("password", publicKey)
sail.GetDBW().Create(&User{Username:"go-sail", password: passwordEncrypted})
}

sail.GetLogger("schedule").Info("记录一些日志...")
}
)

func main() {
config.ParseConfig()

options := &api.Option{
ForceHttpCode200: true,
DetectAcceptLanguage: true,
}
sail.WakeupHttp("go-sail", config.Get()).
SetupApiOption(options).
Hook(routes.RegisterRoutes, nil, afterFunc).Launch()
}

当结构整理完成后,我们可通过如下命令清理和管理依赖包:

go mod tidy

通过这样的结构优化后,我们可以看到:每个文件都各司其职,项目变得非常清晰。基于这样的结构扩展功能会变得异常容易。

最终,整个项目目录结构大致如下:

tutorials/
├── config
│   └── config.go
├── main.go
├── http
│   ├── handlers
│   │   ├── user.go
│   │   └── third-party.go
│   ├── middlewares
│   │   └── authorization.go
│   ├── routes
│   │   └── routes.go
│   ├── services
│   │   ├── user.go
│   │   └── third-party.go
│   ├── types
│   │   └── user.go
└── pkg
   ├── common
   │ └── errors.go
└── models
└── user.go
提示

Go-Sail 对你的项目结构并不做强制规定,结构组织完全取决于你自己。毕竟,世界上没有“最优解”,适合自己的才是最好的。