浅谈-api项目设计

从事api后端接口开发也有五六年时间了,都没有好好的整理下api项目架构模板以及如何从零开始设计。

抽空写个文章记录下,顺便检查下自己对这块的理解,如有不正确的地方,欢迎底下友好交流。

本文的目的是检查自己对架构设计的理解,思考架构设计的意义和常用的设计思想

按照软件工程流程。我们需要需求分析(之前的步骤忽略)、概要设计、详细设计、编码和测试。

本文主要谈概要设计这块。以最基础的博客网站为例,参考煎鱼大佬编写的go语言编程之旅。

go语言编程之旅源文地址:前言

一、项目结构目录设计

我使用的web框架是gin

go get  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  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 ""

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 ""

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

posted @ 2023-04-15 16:43  菲菲菲菲菲常新的新手  阅读(44)  评论(0编辑  收藏  举报