博客 / 詳情

返回

go-scaffold 一個基於 kratos 和 wire 依賴注入框架的腳手架

  • 介紹
  • 架構圖
  • 生命週期
  • 目錄結構
  • 如何運行

    • go build 或 go run
    • make
    • docker-compose
    • 熱重啓
    • 運行子命令或腳本
  • 依賴注入
  • 配置

    • 配置模型
    • 遠程配置
    • 監聽配置變更
  • 日誌
  • 錯誤處理

    • 轉換為 HTTP 狀態碼
    • 將 GRPC 錯誤轉換為 Error
  • 組件

    • Casbin
    • Client

      • gRPC 客户端
    • Discovery 服務發現與註冊
    • Ent
    • orm

      • 如何配置多數據庫
    • Redis 客户端
    • trace
    • uid
  • transport 層

    • HTTP

      • 響應
      • swagger 文檔生成
      • 如何訪問 swagger 文檔
  • service 層
  • 命令行功能模塊
  • cron 定時任務功能模塊
  • 如何部署

    • Dockerfile
    • docker-compose
    • kubernetes

地址:https://github.com/OldSmokeGu...

歡迎 Star,歡迎 PR,希望大家可以一起討論和指正!

介紹

go-scaffold 是一個基於 cobra 和 kratos 框架的腳手架,設計思想是基於 wire 實現模塊和功能的組件化

go-scaffold 開箱即用,使用簡單,可以快速搭建起一個微服務進行業務代碼的開發,支持功能:

  • 依賴注入
  • cobra 命令行
  • cron 定時任務
  • apollo 遠程配置中心和配置監聽
  • 日誌切割
  • 服務註冊和發現
  • jaeger 鏈路追蹤
  • Swagger 文檔生成
  • docker-composeKubernetes 部署

架構圖

architecture.png

生命週期

image.png

目錄結構

|-- bin # 二進制文件目錄
|-- cmd # 編譯入口
|   `-- app
|-- deploy # 環境和部署相關目錄
|   |-- docker-compose # docker-compose 容器編排目錄
|   `-- kubernetes # k8s 編排配置目錄
|-- docs # 文檔目錄
|-- etc # 配置文件目錄
|-- internal
|   `-- app
|       |-- command # 命令行功能模塊
|       |   |-- handler
|       |   `-- script # 臨時腳本
|       |-- component # 功能組件,如:db, redis 等
|       |-- config # 配置模型
|       |-- cron # 定時任務功能模塊
|       |   `-- job
|       |-- model # 數據庫模型
|       |-- pkg # 功能類庫
|       |-- repository # 數據處理層
|       |-- service # 業務邏輯層
|       |-- test
|       `-- transport
|           |-- grpc
|           |   |-- api # proto 文件目錄
|           |   |-- handler # 控制層
|           |   `-- middleware # 中間件
|           `-- http
|               |-- api # swagger 文檔
|               |-- handler # 控制層
|               |-- middleware # 中間件
|               `-- router # 路由
|-- logs # 日誌目錄
|-- pkg # 功能類庫
`-- proto # 第三方 proto 文件目錄

如何運行

首先將 etc/config.yaml.example 拷貝為 etc/config.yaml

go buildgo run

  1. go build 方式
$ go generate ./...
$ go build -o bin/app cmd/app/main.go cmd/app/wire_gen.go
$ ./bin/app
  1. go run 方式
$ go generate ./...
$ go run cmd/app/main.go cmd/app/wire_gen.go

make

# 下載依賴
$ make download
$ make build

# 或依據平台編譯
$ make linux-build
$ make windows-build
$ make mac-build

# 運行
$ ./bin/app

docker-compose

docker-compose 的啓動方式有兩種,一種是基於 air 鏡像,一種是基於 Dockerfile 來構建鏡像

注意:

  • 基於 air 鏡像的方式只適用於開發階段,請勿用於生產環境

    • Windows 系統環境下,熱更新可能不會生效,這是因為 fsnotify 無法收到 wsl 文件系統的變更通知
  • 基於 Dockerfile 的方式如果用於開發階段,修改的代碼將不會更新,除非在 docker-compose 啓動時指定 --build 參數,但是這將會導致每次啓動時都重新構建鏡像,可能需要等待很長時間
# 基於 air 
$ docker-compose -f deploy/docker-compose/docker-compose-dev.yaml up

# 基於 Dockerfile
$ docker-compose -f deploy/docker-compose/docker-compose.yaml up

熱重啓

熱重啓功能基於 air

$ air

運行子命令或腳本

命令行程序功能基於 cobra

$ ./bin/app [標誌] <子命令> [標誌] [參數]

# 幫助信息

$ ./bin/app -h
$ ./bin/app <子命令> -h

依賴注入

依賴通過自動生成代碼的方式在編譯期完成注入

依賴結構:

dependency.png

配置

默認配置文件路徑為:etc/app/config.yaml

可以在運行程序時通過 --config-f 選項指定其它配置文件

配置模型

配置文件的內容在程序啓動時會被加載到配置模型中,相關目錄:internal/app/config

  • internal/app/config/declare.go:配置的結構體定義
  • internal/app/config/config.go:聲明 Provider 和監聽的配置 Key

如何獲取配置模型:

  • 注入配置模型類型:*config.Config
  • 注入 App 配置模型類型:*config.App
  • ...

例:

package trace

import "go-scaffold/internal/app/config"

type Handler struct {
    conf    *config.Config
    appConf *config.App
}

func NewHandler(
    conf *config.Config,
    appConf *config.App,
) *Handler {
    return &Handler{
        conf:    conf,
        appConf: appConf,
    }
}

遠程配置

在啓動程序時,可通過以下選項配置遠程配置中心

  • --config.apollo.enable: apollo 是否啓用
  • --config.apollo.endpoint: 連接地址
  • --config.apollo.appid: appID
  • --config.apollo.cluster: cluster
  • --config.apollo.namespace: 命名空間
  • --config.apollo.secret: secret

監聽配置變更

internal/app/config/config.go 文件的 watchKeys 變量中註冊需要監聽的配置鍵

註冊完成後,如果配置文件內容發生變更,無需重啓服務,更改內容會自動同步到配置實例中

例:

var watchKeys = []string{
   "services.self",
   "jwt.key",
}

日誌

日誌基於 zap,日誌的輪轉基於 file-rotatelogs

日誌內容默認輸出到 logs 目錄中,並且根據每天的日期進行分割

可在程序啓動時,通過以下選項改變日誌行為:

  • --log.path: 日誌輸出路徑
  • --log.level: 日誌等級(debuginfowarnerrorpanicfatal
  • --log.format: 日誌輸出格式(textjson
  • --log.caller-skip: 日誌 caller 跳過層數

如何獲取日誌實例:

  • 注入類型:log.Logger

例:

package greet

import "github.com/go-kratos/kratos/v2/log"

type Service struct {
    logger *log.Helper
}

func NewService(logger log.Logger) *Service {
    return &Service{
        logger: log.NewHelper(logger),
    }
}

錯誤處理

腳手架定義了統一的錯誤格式

type Error struct {
    // Code 狀態碼
    Code ErrorCode

    // Message 錯誤信息
    Message string

    // Metadata 元數據
    Metadata map[string]string
}

快捷函數:

// ServerError 服務器錯誤
func ServerError(options ...Option) *Error {
    return New(ServerErrorCode, ServerErrorCode.String(), options...)
}

// ClientError 客户端錯誤
func ClientError(options ...Option) *Error {
    return New(ClientErrorCode, ClientErrorCode.String(), options...)
}

// ValidateError 參數校驗錯誤
func ValidateError(options ...Option) *Error {
    return New(ValidateErrorCode, ValidateErrorCode.String(), options...)
}

// Unauthorized 未認證
func Unauthorized(options ...Option) *Error {
    return New(UnauthorizedCode, UnauthorizedCode.String(), options...)
}

// PermissionDenied 權限拒絕錯誤
func PermissionDenied(options ...Option) *Error {
    return New(PermissionDeniedCode, PermissionDeniedCode.String(), options...)
}

// ResourceNotFound 資源不存在
func ResourceNotFound(options ...Option) *Error {
    return New(ResourceNotFoundCode, ResourceNotFoundCode.String(), options...)
}

// TooManyRequest 請求太過頻繁
func TooManyRequest(options ...Option) *Error {
    return New(TooManyRequestCode, TooManyRequestCode.String(), options...)
}

轉換為 HTTP 狀態碼

Code 屬性實現了 HTTP 狀態碼的轉換

例:

func (s *Service) Hello(ctx context.Context, req HelloRequest) (*HelloResponse, error) {
    // ...
    
    // 返回 Error
    return nil, errors.ServerError()
    
    // ...
}
// ...

// 調用 service 方法
ret, err := h.service.Hello(ctx.Request.Context(), *req)
if err != nil {
    // response.Error 方法會自動將 Error 轉換為對應的 HTTP 狀態
    response.Error(ctx, err)
    return
}

// ...

GRPC 錯誤轉換為 Error

Error 實現了 GRPCStatus() 接口,通過 FromGRPCError 函數可將 GRPC 錯誤轉換為 Error

例:

// ...

client := greet.NewGreetClient(conn)
resp, err := client.Hello(reqCtx, &greet.HelloRequest{Name: "Example"})
if err != nil {
    // 將 GRPC 錯誤轉換為 Error
    e := errors.FromGRPCError(err)
    response.Error(ctx, fmt.Errorf("GRPC 調用錯誤:%s", e.Message))
    return
}

// ...

組件

Casbin

基於 casbin 進行封裝,現支持 filegorm 兩種類型的 adapter,如果同時配置,file 類型生效

如何獲取 Enforcer 實例:

  • 注入類型:*casbin.Enforcer

例:

package permission

import "github.com/casbin/casbin/v2"

type Service struct {
    enforcer *casbin.Enforcer
}

func NewService(enforcer *casbin.Enforcer) *Service {
    return &Service{
        enforcer: enforcer,
    }
}

如何進行配置:

casbin:
  model: # casbin 模型
    path: "assets/casbin/rbac_model.conf"
  adapter: # 適配器配置
    file:
      path: "assets/casbin/rbac_policy.csv"
    gorm:
      tableName: "casbin_rules" # 數據表名稱

如何自定義 casbin policy 的數據庫存儲模型:

internal/app/config/config.go 文件的 Loaded 函數中增加代碼

func Loaded(hLogger log.Logger, cfg config.Config, conf *Config) error {
    // ...
    
    if conf.Casbin != nil {
        if conf.Casbin.Adapter != nil {
            if conf.Casbin.Adapter.Gorm != nil {
                conf.Casbin.Adapter.Gorm.SetMigration(func(db *gorm.DB) error {
                    return (&model.CasbinRule{}).Migrate(db)
                })
            }
        }
    }

    // ...
}

Client

gRPC 客户端

基於 kratosgRPC 客户端進行封裝,根據傳入的地址自動判斷是走直連還是服務發現

如何獲取客户端實例:

  • 注入類型:*grpc.Client

例:

package trace

import "go-scaffold/internal/app/component/client/grpc"

type Handler struct {
    grpcClient *grpc.Client
}

func NewHandler(
    grpcClient *grpc.Client,
) *Handler {
    return &Handler{
        grpcClient: grpcClient,
    }
}

如何進行配置:

services:
  self: "127.0.0.1:9528"
  # self: "discovery:///go-scaffold" # 服務發現地址

Discovery 服務發現與註冊

基於 kratos 的服務註冊與發現進行封裝,現支持 etcdconsul,可根據配置進行切換,如果同時配置,etcd 生效

如何獲取 Discovery 實例:

  • 注入類型:discovery.Discovery

例:

package transport

import "go-scaffold/internal/app/component/discovery"

type Transport struct {
    // ...
}

func New(discovery discovery.Discovery) *Transport {
    // ...
}

如何進行配置:

discovery:
  etcd:
    endpoints:
      - "localhost:12379"
  # consul:
  #   addr: "localhost:8500"
  #   schema: "http"

Ent

ent 組件基於 ent

如何獲取 ent 客户端:

  • 注入類型:*ent.Client

例:

package user

import "go-scaffold/internal/app/component/ent/ent"

type Repository struct {
    ent *ent.Client
}

func NewRepository(ent *ent.Client) *Repository {
    return &Repository{
        ent: ent,
    }
}

orm

orm 組件基於 gorm

如何獲取 orm 實例:

  • 注入類型:*gorm.DB

例:

package user

import "gorm.io/gorm"

type Repository struct {
    db *gorm.DB
}

func NewRepository(db *gorm.DB) *Repository {
    return &Repository{
        db: db,
    }
}

如何配置多數據庫

參考:https://gorm.io/docs/dbresolv...

etc/config.yaml:

db:
  driver: "mysql"
  host: "127.0.0.1"
  port: 13306
  database: "go-scaffold"
  username: "root"
  password: "root"
  options:
    - "charset=utf8mb4"
    - "parseTime=True"
    - "loc=Local"
  maxIdleConn: 5
  maxOpenConn: 10
  connMaxIdleTime: 120
  connMaxLifeTime: 120
  logLevel: "info"
  # 多數據庫配置
  resolvers:
    - type: "replica" # source 或 replica
      host: "127.0.0.1"
      port: 13307
      database: "go-scaffold"
      username: "root"
      password: "root"
      options:
        - "charset=utf8mb4"
        - "parseTime=True"
        - "loc=Local"
    - type: "replica"
      host: "127.0.0.1"
      port: 13308
      database: "go-scaffold"
      username: "root"
      password: "root"
      options:
        - "charset=utf8mb4"
        - "parseTime=True"
        - "loc=Local"

internal/app/config/config.go:

func Loaded(hLogger log.Logger, cfg config.Config, conf *Config) error {
    // ...
    
    // 配置多數據庫
    if conf.DB != nil {
        if len(conf.DB.Resolvers) > 0 {
            var (
                sources  = make([]gorm.Dialector, 0, len(conf.DB.Resolvers))
                replicas = make([]gorm.Dialector, 0, len(conf.DB.Resolvers))
            )
    
            for _, resolver := range conf.DB.Resolvers {
                dial, err := orm.BuildDialector(conf.DB.Driver, resolver.DSN)
                if err != nil {
                    return err
                }
                switch resolver.Type {
                case orm.Source:
                    sources = append(sources, dial)
                case orm.Replica:
                    replicas = append(replicas, dial)
                default:
                    return fmt.Errorf("unsupported resolver type %s", resolver.Type)
                }
            }
    
            conf.DB.Plugins = func(db *gorm.DB) ([]gorm.Plugin, error) {
                return []gorm.Plugin{
                    dbresolver.Register(dbresolver.Config{
                        Sources:  sources,
                        Replicas: replicas,
                        Policy:   dbresolver.RandomPolicy{},
                    }),
                }, nil
            }
        }
    }
    
    // ...
}

Redis 客户端

Redis 客户端基於 go-redis

如何獲取 Redis 客户端:

  • 注入類型:*redis.Client

例:

package user

import "github.com/go-redis/redis/v8"

type Repository struct {
    rdb *redis.Client
}

func NewRepository(rdb *redis.Client) *Repository {
    return &Repository{
        rdb: rdb,
    }
}

trace

腳手架基於 opentelemetry-go 實現了 OpenTelemetry 規範的鏈路追蹤

transportHTTPgRPC 均已註冊鏈路追蹤的中間件

如何獲取 tracerProvidertracer

  • 注入類型:*redis.Client

例:

package trace

import "go-scaffold/internal/app/component/trace"

type Handler struct {
    trace *trace.Tracer
}

func NewHandler(
    trace *trace.Tracer,
) *Handler {
    return &Handler{
        trace: trace,
    }
}

uid

uid 組件是基於 snowflake 實現的 uid 生成器,可用於數據庫主鍵

如何獲取 uid 實例:

  • 注入類型:uid.Generator

例:

package user

import "go-scaffold/internal/app/component/uid"

type Repository struct {
    id uid.Generator
}

func NewRepository(id uid.Generator) *Repository {
    return &Repository{
        id: id,
    }
}

transport

HTTP

響應

internal/app/transport/http/pkg/response 包中,對 JSON 響應進行了封裝

成功響應示例:

func (h *Handler) Hello(ctx *gin.Context) {
    // ...

    response.Success(ctx, response.WithData(ret))
    return
}

錯誤響應示例:

func (h *Handler) Hello(ctx *gin.Context) {
    // ...
    
    ret, err := h.service.Hello(ctx.Request.Context(), *req)
        if err != nil {
        response.Error(ctx, err)
        return
    }
    
    // ...
}

swagger 文檔生成

swagger 文檔的生成基於 swag,統一生成到 internal/app/transport/http/api 目錄下,否則無法訪問

生成 swagger 文檔的方式有三種

  1. swag 命令方式
$ swag fmt -d internal/app -g app.go
$ swag init -d internal/app -g app.go -o internal/app/transport/http/api
  1. make 方式
$ make doc
  1. go generate 方式
$ go generate ./...

如何訪問 swagger 文檔

瀏覽器打開 <host>/api/docs

service

service 層處理業務邏輯 transport 層中的 HTTPgRPC,或命令行都只是其中一個入口

參數的校驗基於 ozzo-validation,統一放到 service

例:

type CreateRequest struct {
    Name  string `json:"name"`
    Age   int8   `json:"age"`
    Phone string `json:"phone"`
}

func (r CreateRequest) Validate() error {
    return validation.ValidateStruct(&r,
        validation.Field(&r.Name, validation.Required.Error("名稱不能為空")),
        validation.Field(&r.Phone, validation.By(validator.IsMobilePhone)),
    )
}

type CreateResponse struct {
    Id    uint64 `json:"id"`
    Name  string `json:"name"`
    Age   int8   `json:"age"`
    Phone string `json:"phone"`
}

func (s *Service) Create(ctx context.Context, req CreateRequest) (*CreateResponse, error) {
    // 參數校驗
    if err := req.Validate(); err != nil {
        return nil, errorsx.ValidateError(errorsx.WithMessage(err.Error()))
    }

    // ...
}

命令行功能模塊

命令行功能模塊基於 cobra

命令行功能被抽象為兩部分,一部分稱為“業務命令”(command),一部分稱為“腳本”(script

  • “業務命令”設計用於通過命令行的方式調用業務邏輯
  • “腳本”設計用於執行開發過程中的臨時腳本任務,例如:進行數據修復
  • “業務命令”被註冊為應用程序的 business 子命令,“腳本”被註冊為應用程序的 script 子命令

命令行目錄規範:

  • “業務命令”和“腳本”的註冊位於 internal/app/command/command.go 文件中
  • “業務命令”部分:

    • “業務命令”在 internal/app/command/handler 目錄中進行定義
    • 應按照不同的職責對包進行縱向拆分,例如:postusercomment 三個業務模塊,每一個模塊都獨立對外提供相應的功能
    • 每個業務模塊都是一個單獨的包,對應 business 命令的子命令,例如:./bin/app business post
    • 業務模塊中的每個方法都抽離為一個單獨的文件,對應業務模塊命令的子命令,例如:./bin/app business post add
  • “腳本”部分:

    • “腳本”在 internal/app/command/script 目錄中進行定義
    • 腳本文件的名稱為 S+10 位時間戳,説明腳本的創建時間
    • 文件中的結構體名稱為腳本文件名,並且實現 Script 接口
    • 結構體的註釋應該説明此腳本的用途

注意:

不要通過系統的定時任務來頻繁調用命令行功能的“業務命令”或“腳本”,因為每次執行都會初始化數據庫連接、日誌等資源,這可能會造成性能問題

如果需要頻繁調用某個業務邏輯,可以考慮是否應該使用 cron 功能模塊

cron 定時任務功能模塊

定時任務功能模塊基於 cron

  • 其可以提供最小時間單位為秒的定時任務
  • 可明確知道項目中有那些定時任務

定時任務規範:

  • 任務在 internal/app/cron/cron.go 文件中進行註冊
  • internal/app/cron/job 目錄中進行定義
  • 任務結構體的名稱為任務文件名,並且實現 cron.Job 接口
  • 結構體的註釋應該説明此任務的用途

如何部署

Dockerfile

Dockerfile 文件位於項目根目錄

docker-compose

docker-compose 編排文件位於 deploy/docker-compose 目錄中

部署前根據需要將 docker-compose.yaml.exampledocker-compose-dev.yaml.example 拷貝為 docker-compose.yaml,然後根據 docker-compose 運行

Kubernetes

Kubernetes 編排文件位於 deploy/kubernetes 目錄中

Kubernetes 的方式基於 helm,部署前需要將 values.yaml.example 拷貝為 values.yaml

然後執行:

$ kubectl apply -Rf deploy/kubernetes

# 或

$ helm install go-scaffold kubernetes/
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.