浅谈-api项目设计
从事api后端接口开发也有五六年时间了,都没有好好的整理下api项目架构模板以及如何从零开始设计。
抽空写个文章记录下,顺便检查下自己对这块的理解,如有不正确的地方,欢迎底下友好交流。
本文的目的是检查自己对架构设计的理解,思考架构设计的意义和常用的设计思想
按照软件工程流程。我们需要需求分析(之前的步骤忽略)、概要设计、详细设计、编码和测试。
本文主要谈概要设计这块。以最基础的博客网站为例,参考煎鱼大佬编写的go语言编程之旅。
go语言编程之旅源文地址:前言
一、项目结构目录设计
我使用的web框架是gin
go get http://github.com/gin-gonic/gin v1.9.0
使用上面命令下载并将gin添加到go.mod文件中
go_api_framework
├── configs
├── docs
├── global
├── internal
│ ├── dao
│ ├── middleware
│ ├── model
│ ├── routers
│ └── service
├── pkg
├── tmp
├── scripts
├── tests
└── third_party
- configs:配置文件。
- docs:文档集合。
- global:全局变量。
- internal:内部模块。
- dao:数据访问层(Database Access Object),所有与数据相关的操作都会在 dao 层进行,例如 MySQL、ElasticSearch 等。
- middleware:HTTP 中间件。
- model:模型层,用于存放 model 对象。
- routers:路由相关逻辑处理。
- service:项目核心业务逻辑。
- pkg:项目相关的模块包。
- tmp:项目生成的临时文件。
- scripts:各类构建,安装,分析等操作的脚本。
- tests:各种测试相关文件,包括性能测试,压力测试等等
- third_party:第三方的资源工具,例如 Swagger UI。
这里我将storage改名为tmp,临时文件我更习惯用tmp命名
下面简单说明下外层目录的创建逻辑
1、除了业务逻辑相关的代码,为了实现系统的高可扩展性,我们需要对系统进行部分配置,如启用或禁用某些组件。所以需要编写配置相关的代码。于是就有了configs目录,存放配置相关的文件,可以是json、yaml、xml等任何可识别的格式。并且可以根据不同的运行环境编写多个配置文件
2、为了配合前后端开发,我们需要定义接口,编写接口文档。于是就有了docs目录。用来存放编写的接口文档,推荐使用swagger自动生成。(因为很多开发都不喜欢写文档。。。)
3、我们编写业务的时候会用到一些全局变量,这些变量作用于整个系统。于是就有了global目录,存放全局变量文件。如日志logger、setting配置等等
4、编写业务逻辑,这些是整个系统的核心。于是有了internal目录,存放业务核心内容
5、编写系统功能过程中会使用到一些模块包,于是有了pkg目录(package)和一些第三方开源软件,于是有了third_party。以及会生成一些临时文件,于是有了tmp目录。
6、系统设计和编码完成后,需要对系统进行构建和部署,于是有了scripts目录。
7、部署构建完成之后,需要对系统进行测试,测试是否能够实现需求描述的功能,于是有了tests目录。
上述简单描述了创建外层目录
下面描述下internal内部目录的创建逻辑
可以从自上而下去理解,也可以自下而上去理解。我采用自下而上的方式来说明
在model中编写sql等数据库相关语句
在dao中对这些数据库语句进行封装
在service中编写业务逻辑调用dao中封装的数据库操作
在routers中对入参和出参进行校验以及日志打印等并调用svc处理业务逻辑。
编写过程中可能使用到一些中间件,创建middleware目录存放这些中间件
二、添加常用功能
1、添加配置
配置我使用viper来解析
go get http://github.com/spf13/viper v1.15.0
1.1、创建config.yaml文件来保存具体配置
在configs目录下添加配置文件,内容如下,可以根据需要修改
Server: 服务相关配置 RunMode: debug 运行模式 HttpPort: 8000 监听端口 ReadTimeout: 60 读超时时间,秒为单位 WriteTimeout: 60 写超时时间,秒为单位 App: 接口相关配置 DefaultPageSize: 10 默认每页大小 MaxPageSize: 100 最大的页数 LogSavePath: tmp/logs 日志存放路径 LogFileName: app 日志文件名称 LogFileExt: .log 日志文件后缀 Database: 数据库相关配置 DBType: mysql 数据库类型 Username: 数据库账号 Password: 数据库密码 Host: 数据库ip和端口号 DBName: 数据库名称 TablePrefix: blog_ 数据库表前缀 Charset: utf8 数据库编码 ParseTime: True 是否解析时间 MaxIdleConns: 10 最大的idle连接数 MaxOpenConns: 30 最大的数据库连接数
1.2、创建配置结构体实现解析
添加完配置,在pkg目录下新建setting目录,创建setting文件
该文件自定义一个结构体,嵌套了viper。实例化的时候添加配置路径、文件名和后缀。实例化viper解析配置文件。并实现ReadSection解析不同部分的配置
package setting import ( "github.com/spf13/viper" ) type Setting struct { viper *viper.Viper } func NewSetting() (*Setting, error) { vp := viper.New() vp.SetConfigName("config") vp.AddConfigPath("configs/") vp.SetConfigType("yaml") err := vp.ReadInConfig() if err != nil { return nil, err } return &Setting{vp}, nil } func (s *Setting) ReadSection(k string, v interface{}) error { err := s.viper.UnmarshalKey(k, v) if err != nil { return err } return nil }
我们的配置分为三部分,创建一个section文件来解析不同部分的配置
package setting import "time" /* 在section中定义要使用的配置结构体,通过setting进行解析。 */ type ServerSetting struct { HttpPort string RunMode string ReadTimeout time.Duration WriteTimeout time.Duration } type AppSetting struct { DefaultPageSize int MaxPageSize int LogSavePath string LogFileName string LogFileExt string } type DataBaseSetting struct { ParseTime bool MaxIdleConns int MaxOpenConns int DBType string UserName string Password string Host string DBName string TablePrefix string Charset string }
1.3、实例化全局配置
在global目录下创建setting文件,实例化全局配置
var ( ServerSetting *setting.ServerSetting AppSetting *setting.AppSetting DatabaseSetting *setting.DataBaseSetting )
1.4、项目启动时初始化配置
在main文件中创建初始化配置函数,在init中调用该函数,完成配置初始化
package main func init() { err := setupSettings() if err != nil { log.Fatalf("init.setupSetting err:%v", err) } } func setupSettings() error { s, err := setting.NewSetting() if err != nil { return err } err = s.ReadSection("Server", &global.ServerSetting) if err != nil { return err } err = s.ReadSection("App", &global.AppSetting) if err != nil { return err } err = s.ReadSection("Database", &global.DatabaseSetting) if err != nil { return err } global.ServerSetting.ReadTimeout *= time.Second global.ServerSetting.WriteTimeout *= time.Second return nil }
这么做的原因是为了提高可扩展性,将不同部分的配置分开,当我们需要添加新的配置时,只需要在config.yaml文件新增配置,然后将section对应结构体上添加配置即可
2、添加日志
日志采用go自带的log,做了下封装
采用lumberjack实现日志滚动压缩
go get "http://gopkg.in/natefinch/lumberjack.v2"
2.1、在pkg目录下创建logger文件,内容如下
package logger import ( "context" "encoding/json" "fmt" "github.com/gin-gonic/gin" "io" "log" "runtime" "time" ) type Level int8 type Fields map[string]interface{} const ( LevelDebug Level = iota LevelInfo LevelWarn LevelError LevelFatal LevelPanic ) func (l Level) String() string { switch l { case LevelDebug: return "debug" case LevelInfo: return "info" case LevelWarn: return "warn" case LevelError: return "error" case LevelFatal: return "fatal" case LevelPanic: return "panic" } return "" } type Logger struct { newLogger *log.Logger ctx context.Context fields Fields callers []string } func NewLogger(w io.Writer, prefix string, flag int) *Logger { l := log.New(w, prefix, flag) return &Logger{newLogger: l} } func (l *Logger) clone() *Logger { nl := *l return &nl } func (l *Logger) WithFields(f Fields) *Logger { ll := l.clone() if ll.fields == nil { ll.fields = make(Fields) } for k, v := range f { ll.fields[k] = v } return ll } func (l *Logger) WithContext(ctx context.Context) *Logger { ll := l.clone() ll.ctx = ctx return ll } func (l *Logger) WithCaller(skip int) *Logger { ll := l.clone() pc, file, line, ok := runtime.Caller(skip) if ok { f := runtime.FuncForPC(pc) ll.callers = []string{fmt.Sprintf("%s: %d %s", file, line, f.Name())} } return ll } func (l *Logger) WithCallersFrames() *Logger { maxCallerDepth := 25 minCallerDepth := 1 callers := []string{} pcs := make([]uintptr, maxCallerDepth) depth := runtime.Callers(minCallerDepth, pcs) frames := runtime.CallersFrames(pcs[:depth]) for frame, more := frames.Next(); more; frame, more = frames.Next() { callers = append(callers, fmt.Sprintf("%s: %d %s", frame.File, frame.Line, frame.Function)) if !more { break } } ll := l.clone() ll.callers = callers return ll } func (l *Logger) WithTrace() *Logger { ginCtx, ok := l.ctx.(*gin.Context) if ok { return l.WithFields(Fields{ "trace_id": ginCtx.MustGet("X-Trace-Id"), "span_id": ginCtx.MustGet("X-Span-Id"), }) } return l } func (l *Logger) JSONFormat(level Level, message string) map[string]interface{} { data := make(Fields, len(l.fields)+4) data["level"] = level.String() data["time"] = time.Now().Local().UnixNano() data["message"] = message data["callers"] = l.callers if len(l.fields) > 0 { for k, v := range l.fields { if _, ok := data[k]; !ok { data[k] = v } } } return data } func (l *Logger) Output(level Level, message string) { body, _ := json.Marshal(l.JSONFormat(level, message)) content := string(body) switch level { case LevelDebug, LevelInfo, LevelWarn, LevelError: l.newLogger.Print(content) case LevelFatal: l.newLogger.Fatal(content) case LevelPanic: l.newLogger.Panic(content) } } func (l *Logger) log(level Level, ctx context.Context, v ...interface{}) { l.WithContext(ctx).WithTrace().Output(level, fmt.Sprint(v...)) } func (l *Logger) logf(level Level, ctx context.Context, format string, v ...interface{}) { l.WithContext(ctx).WithTrace().Output(level, fmt.Sprintf(format, v...)) } func (l *Logger) Debug(ctx context.Context, v ...interface{}) { l.log(LevelDebug, ctx, v) } func (l *Logger) Debugf(ctx context.Context, format string, v ...interface{}) { l.logf(LevelDebug, ctx, format, v) } func (l *Logger) Info(ctx context.Context, v ...interface{}) { l.log(LevelInfo, ctx, v) } func (l *Logger) Infof(ctx context.Context, format string, v ...interface{}) { l.logf(LevelInfo, ctx, format, v) } func (l *Logger) Warn(ctx context.Context, v ...interface{}) { l.log(LevelWarn, ctx, v) } func (l *Logger) Warnf(ctx context.Context, format string, v ...interface{}) { l.logf(LevelWarn, ctx, format, v) } func (l *Logger) Error(ctx context.Context, v ...interface{}) { l.log(LevelError, ctx, v) } func (l *Logger) Errorf(ctx context.Context, format string, v ...interface{}) { l.logf(LevelError, ctx, format, v) } func (l *Logger) Fatal(ctx context.Context, v ...interface{}) { l.log(LevelFatal, ctx, v) } func (l *Logger) Fatalf(ctx context.Context, format string, v ...interface{}) { l.logf(LevelFatal, ctx, format, v) } func (l *Logger) Panic(ctx context.Context, v ...interface{}) { l.log(LevelPanic, ctx, v) } func (l *Logger) Panicf(ctx context.Context, format string, v ...interface{}) { l.logf(LevelPanic, ctx, format, v) }
自定义了各种错误等级以及一个logger结构体,嵌套了go自带的log,该结构体实现各种错误等级的打印。
并且实现了多个With函数,这些函数可以当作中间件看待。其实扩展性还是不够强,应该可以自由选择要使用的With函数,而不是直接在log和logf中写死。
2.2、新增全局logger变量
修改之间的global文件下的setting文件,新增logger
Logger *logger.Logger
2.3、项目启动时初始化配置
在main文件中i添加setupLogger函数
func setupLogger() error { global.Logger = logger.NewLogger(&lumberjack.Logger{ Filename: global.AppSetting.LogSavePath + "/" + global.AppSetting.LogFileName + global.AppSetting.LogFileExt, MaxSize: 600, MaxAge: 10, LocalTime: true, }, "", log.LstdFlags).WithCaller(2) return nil }
并在init函数新增setupLogger函数,在启动时初始化Logger
err = setupLogger() if err != nil { log.Fatalf("init.setupLogger err: %v", err) }
3、添加数据库
我采用mysql作为数据库,使用gorm做orm
go get "http://github.com/jinzhu/gorm"
3.1、封装数据库基础数据
在internal目录的model目录下创建model文件,内容如下
const ( STATE_OPEN = 1 STATE_CLOSE = 0 ) type Model struct { Id uint32 `gorm:"primary_key" json:"id"` CreatedBy string `json:"created_by"` ModifiedBy string `json:"modified_by"` CreatedOn uint32 `json:"created_on"` ModifiedOn uint32 `json:"modified_on"` DeletedOn uint32 `json:"deleted_on"` IsDel uint8 `json:"is_del"` } func NewDBEngine(databaseSetting *setting.DataBaseSetting) (*gorm.DB, error) { s := "%s:%s@tcp(%s)/%s?charset=%s&parseTime=%t&loc=Local" db, err := gorm.Open(databaseSetting.DBType, fmt.Sprintf(s, databaseSetting.UserName, databaseSetting.Password, databaseSetting.Host, databaseSetting.DBName, databaseSetting.Charset, databaseSetting.ParseTime, )) if err != nil { return nil, err } if global.ServerSetting.RunMode == "debug" { db.LogMode(true) } db.SingularTable(true) db.Callback().Create().Replace("gorm:update_time_stamp", updateTimeStampForCreateCallback) db.Callback().Update().Replace("gorm:update_time_stamp", updateTimeStampForUpdateCallback) db.Callback().Delete().Replace("gorm:delete", deleteCallback) db.DB().SetMaxIdleConns(databaseSetting.MaxIdleConns) db.DB().SetMaxOpenConns(databaseSetting.MaxOpenConns) return db, nil } func updateTimeStampForCreateCallback(scope *gorm.Scope) { if !scope.HasError() { nowTime := time.Now().Unix() if createTimeField, ok := scope.FieldByName("CreatedOn"); ok { if createTimeField.IsBlank { _ = createTimeField.Set(nowTime) } } if modifyTimeField, ok := scope.FieldByName("ModifiedOn"); ok { if modifyTimeField.IsBlank { _ = modifyTimeField.Set(nowTime) } } } } func updateTimeStampForUpdateCallback(scope *gorm.Scope) { if _, ok := scope.Get("gorm:update_column"); !ok { _ = scope.SetColumn("ModifiedOn", time.Now().Unix()) } } func deleteCallback(scope *gorm.Scope) { if !scope.HasError() { var extraOption string if str, ok := scope.Get("gorm:delete_option"); ok { extraOption = fmt.Sprint(str) } deletedOnField, hasDeletedOnField := scope.FieldByName("DeletedOn") isDelField, hasIsDelField := scope.FieldByName("IsDel") if !scope.Search.Unscoped && hasDeletedOnField && hasIsDelField { now := time.Now().Unix() scope.Raw(fmt.Sprintf( "UPDATE %v SET %v=%v,%v=%v%v%v", scope.QuotedTableName(), scope.Quote(deletedOnField.DBName), scope.AddToVars(now), scope.Quote(isDelField.DBName), scope.AddToVars(1), addExtraSpaceIfExist(scope.CombinedConditionSql()), addExtraSpaceIfExist(extraOption), )).Exec() } else { scope.Raw(fmt.Sprintf( "DELETE FROM %v%v%v", scope.QuotedTableName(), addExtraSpaceIfExist(scope.CombinedConditionSql()), addExtraSpaceIfExist(extraOption), )).Exec() } } } func addExtraSpaceIfExist(str string) string { if str != "" { return " " + str } return "" }
创建了一个Model结构体,内部定义了数据库常用字段。
实现NewDBEngine实例化数据库引擎。
并实现了多个callback函数,在执行某些特定操作时新增或更新基础字段
3.3、初始化全局数据库引擎
在global目录下创建db文件,新增
var ( DBEngine *gorm.DB )
3.4、项目启动时初始化数据库
在main文件下新增setupDBEngine函数
func setupDBEngine() error { var err error global.DBEngine, err = model.NewDBEngine(global.DatabaseSetting) if err != nil { return err } return nil }
在init函数中调用
err = setupDBEngine() if err != nil { log.Fatalf("init.setupDBEngine err: %v", err) }
4、统一错误码
在pkg目录下创建errcode目录,存放错误码相关代码
4.1、自定义error结构体
在该目录下创建errcode文件,封装下错误,包括错误码,错误信息,详细信息。内容如下
type Error struct {
code int `json:"code"`
msg string `json:"msg"`
details []string `json:"details"`
}
var codes = map[int]string{}
// newError
func NewError(code int, msg string) *Error {
_, ok := codes[code]
if ok {
panic("")
}
codes[code] = msg
return &Error{code: code, msg: msg}
}
func (e *Error) Error() string {
return fmt.Sprintf("错误码:%d, 错误信息:%s", e.Code(), e.Msg())
}
func (e *Error) Code() int {
return e.code
}
func (e *Error) Msg() string {
return e.msg
}
func (e *Error) Msgf(args []interface{}) string {
return fmt.Sprintf(e.msg, args...)
}
func (e *Error) Details() []string {
return e.details
}
func (e *Error) WithDetails(details ...string) *Error {
newError := *e
newError.details = []string{}
for _, d := range details {
newError.details = append(newError.details, d)
}
return &newError
}
func (e *Error) StatusCode() int {
switch e.Code() {
case Success.Code():
return http.StatusOK
case ServerError.Code():
return http.StatusInternalServerError
case InvalidParams.Code():
return http.StatusBadRequest
case UnauthorizedAuthNotExist.Code():
fallthrough
case UnauthorizedTokenError.Code():
fallthrough
case UnauthorizedTokenGenerate.Code():
fallthrough
case UnauthorizedTokenTimeout.Code():
return http.StatusUnauthorized
case TooManyRequests.Code():
return http.StatusTooManyRequests
}
return http.StatusInternalServerError
}
4.2、添加常用错误码
在该目录下创建common_code文件,并添加常用错误码如下
var ( Success = NewError(0, "成功") ServerError = NewError(10000000, "服务内部错误") InvalidParams = NewError(10000001, "入参错误") NotFound = NewError(10000002, "找不到") UnauthorizedAuthNotExist = NewError(10000003, "鉴权失败,找不到对应的 AppKey 和 AppSecret") UnauthorizedTokenError = NewError(10000004, "鉴权失败,Token 错误") UnauthorizedTokenTimeout = NewError(10000005, "鉴权失败,Token 超时") UnauthorizedTokenGenerate = NewError(10000006, "鉴权失败,Token 生成失败") TooManyRequests = NewError(10000007, "请求过多") )
到此我们完成了项目目录设计,配置、日志、数据库、错误码封装常用的操作。后续还有访问控制、接口文档生成、限流等可以根据需要自己选择。
学习go编程之旅最重要的不是按书上内容把代码打一遍,而是要理解为什么要做这些操作。
思考为什么要这么设计,如果是自己会如何设计
上述模板项目地址:https://github.com/lin344902118/go_api_framework