Golang微服務(wù)框架居然可以開發(fā)單體應(yīng)用?—— Kratos單體架構(gòu)實踐

Golang微服務(wù)框架居然可以開發(fā)單體應(yīng)用湾戳?—— Kratos單體架構(gòu)實踐

TL;DR

微服務(wù)框架也是可以用于開發(fā)單體架構(gòu)(monolith architecture)的應(yīng)用弟劲。并且,單體應(yīng)用也是最小的镶摘、最原始的嗽桩、最初的項目狀態(tài),經(jīng)過漸進式的開發(fā)演進凄敢,單體應(yīng)用能夠逐步的演變成微服務(wù)架構(gòu)碌冶,并且不斷的細分服務(wù)粒度。微服務(wù)框架開發(fā)的單體架構(gòu)應(yīng)用涝缝,既然是一個最小化的實施扑庞,那么它只需要使用到微服務(wù)框架最小的技術(shù)譬重,也就意味著它只需要用到微服務(wù)框架最少的知識點,拿它來學(xué)習(xí)微服務(wù)框架是極佳的罐氨。

本文將圍繞著一個我寫的demo項目:kratos-monolithic-demo開展臀规,它既是一個微服務(wù)框架Kratos的最小化實踐,也是一個工程化實踐的完全體栅隐。從中你可以學(xué)習(xí)到:

  1. 構(gòu)建工具Make的使用塔嬉;
  2. 依賴注入框架Wire的使用;
  3. Protobuf構(gòu)建工具Buf的使用租悄;
  4. ORM框架Ent的使用谨究;
  5. OpenAPI在項目開發(fā)中的應(yīng)用;
  6. 完整的CURD開發(fā)示例泣棋;
  7. 用戶登陸認證胶哲。

為什么要學(xué)要用微服務(wù)框架?

我向身邊的人推廣微服務(wù)架構(gòu)潭辈,但是經(jīng)常會得到否定的態(tài)度鸯屿,譬如:

  1. 我沒有那么多在線人數(shù),那么大的項目規(guī)模萎胰,我不需要微服務(wù)碾盟;
  2. 我用GIN就可以一把擼出來了,用什么微服務(wù)框架技竟?
  3. 微服務(wù)框架太復(fù)雜了冰肴,學(xué)不來。
    ……

總結(jié)下來榔组,無非就是:

  1. 微服務(wù)知識面太廣熙尉,上手太難,學(xué)習(xí)曲線太陡峭搓扯。
  2. 中小型項目检痰,用不到微服務(wù)框架。

的確锨推,微服務(wù)所需要的知識是挺多的:服務(wù)治理(服務(wù)注冊和發(fā)現(xiàn))铅歼、負載均衡、服務(wù)熔斷换可、服務(wù)降級椎椰、服務(wù)限流、服務(wù)容錯沾鳄、服務(wù)網(wǎng)關(guān)慨飘、分布式配置、鏈路追蹤译荞、服務(wù)性能監(jiān)控瓤的、RPC服務(wù)調(diào)用……

這么多知識點休弃,上手的確是不容易,對于很多中小型企業(yè)來說圈膏,他們的項目規(guī)模小塔猾,大多的項目都是CURD項目,這種項目稽坤,開發(fā)者只需要知道怎么寫HTTP路由桥帆,怎么寫ORM,就行了慎皱,就可以上手做事情了。甚至于大部分代碼都可以通過代碼生成器來生成叶骨。要找到會這么多的人才茫多,一個人員難以招聘,一個公司的資本也有限忽刽,需要控制成本天揖,請不起。

那么跪帝,現(xiàn)在的情況看起來就很明顯了:中小型企業(yè)今膊,中小型項目,看起來確實是不需要微服務(wù)伞剑。

但斑唬,微服務(wù)框架也是用不到,不需要嗎黎泣?

答案是否定的恕刘。

在實際的項目開發(fā)中,我有使用微服務(wù)框架Kratos開發(fā)過好幾個單體架構(gòu)的應(yīng)用抒倚,并且上線運營褐着。在最小的一個項目里面,我也就是用到了:REST服務(wù)托呕,ORM訪問數(shù)據(jù)庫含蓉。涉及的知識點并不多,因此開發(fā)起來项郊,也并沒有復(fù)雜到哪里去馅扣。

那么,有人肯定會問我:那你用微服務(wù)框架的意義在哪里呆抑?

我的考量如下:

  1. 小項目不是我們的全部岂嗓,我們也有中大型的項目,公司能夠統(tǒng)一用一套技術(shù)棧鹊碍,總是要好過于用多個技術(shù)棧厌殉。
  2. Kratos工程化做得比較好食绿,比較好規(guī)范公司的開發(fā)。
  3. Kratos基于Protobuf定義協(xié)議公罕,gRPC進行服務(wù)間通訊器紧,在公司的強異構(gòu)開發(fā)場景下,具有很強的實用價值楼眷。
  4. Kratos基于插件機制開發(fā)铲汪,極其容易對其進行擴展(看我的kratos-transport,我甚至插入了Gin罐柳、FastHttp掌腰、Hertz等Web框架)。

綜上张吉,是我的理由齿梁。在做技術(shù)選型的時候,我是橫向?qū)Ρ攘耸忻嫔蠋缀跛械目蚣馨褂迹罱K選擇了Kratos勺择。

還有一點就是,微服務(wù)的開發(fā)過程伦忠,并不是一步到位的——微服務(wù)的開發(fā)是漸進的省核,正所謂:一生二,二生三昆码,三生萬物——從單體應(yīng)用開始逐步的拆分服務(wù)也并不是一件很稀奇的事情气忠。

Demo代碼倉庫

代碼在前,適合那些不喜歡看啰嗦的同學(xué)赋咽。

對于那些想學(xué)習(xí)使用微服務(wù)框架的同學(xué)笔刹,這一個微服務(wù)框架開發(fā)的單體項目,它本質(zhì)上是一個最小化的項目冬耿,故而舌菜,它也是極為適合拿來學(xué)習(xí)之用的項目。

對我而言亦镶,它是一個工程化實驗的實驗田日月,我主要拿它實驗軟件工程的幾個基本形式:

  1. 標(biāo)準(zhǔn)化
  2. 模塊化
  3. 過程化
  4. 實用化和工具化。

項目結(jié)構(gòu)

本項目包含了前端和后端的代碼缤骨,前端是一個Vue3+TypeScript的Admin爱咬。但,前端不是本文的著重點绊起,本文著重講解后端精拟。

前端項目在frontend文件夾中,后端項目在backend文件夾中,

后端項目結(jié)構(gòu):

├─api  # proto協(xié)議存放的路徑
│  ├─admin # Admin服務(wù)蜂绎,定義了REST的接口栅表。
│  │  └─service
│  │      └─v1
│  ├─file # 文件服務(wù),定義了文件上下傳等师枣。
│  │  └─service
│  │      └─v1
│  ├─system # 系統(tǒng)服務(wù)怪瓶,定義了比如目錄、路由等践美。洗贰。。
│  │  └─service
│  │      └─v1
│  └─user # 用戶服務(wù)陨倡,定義了用戶敛滋、組織架構(gòu)、職位等兴革。
│      └─service
│          └─v1
├─app # 應(yīng)用程序所在的路徑
│  └─admin
│      └─service
│          ├─cmd
│          │  └─server # 應(yīng)用程序的入口
│          │      └─assets
│          ├─configs # 應(yīng)用的配置文件
│          └─internal
│              ├─data # 應(yīng)用的數(shù)據(jù)層矛缨,數(shù)據(jù)庫操作的邏輯代碼
│              │  └─ent # 使用的Facebook的ORM,entgo帖旨。
│              │      └─schema # 數(shù)據(jù)庫表結(jié)構(gòu)定義
│              ├─server # 應(yīng)用的傳輸層,應(yīng)用提供的輸入輸出點(創(chuàng)建REST灵妨、gRPC解阅、Kafka等……)
│              └─service # 應(yīng)用的服務(wù)層,REST泌霍、gRPC等的處理器代碼货抄。
├─gen # proto協(xié)議生成的go代碼存放路徑
│  └─api
│      └─go
│          ├─admin
│          │  └─service
│          │      └─v1
│          ├─file
│          │  └─service
│          │      └─v1
│          ├─system
│          │  └─service
│          │      └─v1
│          └─user
│              └─service
│                  └─v1
├─pkg # 公共代碼存放路徑
│  ├─errors
│  │  └─auth
│  ├─middleware
│  │  └─auth
│  ├─service
│  └─task
└─sql # 一些SQL查詢的存放路徑

前置知識

安裝環(huán)境

安裝Make

Linux、Mac下面基本上都是預(yù)裝朱转,就算不是預(yù)裝蟹地,要安裝也很簡單,不再贅述藤为。主要是Windows下面比較麻煩怪与,我有一篇文章說這個:怎么樣在Windows下使用Make編譯Golang程序

protoc安裝

macOS安裝

brew install protobuf

Ubuntu安裝

sudo apt update; sudo apt upgrade
sudo apt install libprotobuf-dev protobuf-compiler

Windows安裝

在Windows下可以使用包管理器ChocoScoop來安裝缅疟。

Choco
choco install protoc
Scoop
scoop bucket add extras
scoop install protobuf

golang install安裝的工具

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
go install github.com/go-kratos/kratos/cmd/protoc-gen-go-http/v2@latest
go install github.com/go-kratos/kratos/cmd/protoc-gen-go-errors/v2@latest
go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest
go install github.com/google/gnostic/cmd/protoc-gen-openapi@latest
go install github.com/envoyproxy/protoc-gen-validate@latest
go install github.com/bufbuild/buf/cmd/buf@latest
go install github.com/go-kratos/kratos/cmd/kratos/v2@latest

或者在后端項目根目錄backend下執(zhí)行:

make init

安裝IDE插件

在IDE里面(VSC和Goland)分别,遠程的proto源碼庫會被拉取到本地的緩存文件夾里面,而這IDE并不知道存淫,故而無法解析到依賴到的proto文件耘斩,但是,Buf官方提供了插件桅咆,可以幫助IDE讀取并解析proto文件括授,并且自帶Lint。

Wire的使用

Wire是谷歌開源的一個依賴注入的框架。

依賴注入的作用是:

  • 創(chuàng)建對象
  • 知道哪些類需要那些對象
  • 并提供所有這些對象

首先從注入源看起荚虚,在server薛夜、servicedata這幾個包下面都存在一個:

var ProviderSet = wire.NewSet(...)

NewSet方法里面都是對象的創(chuàng)建方法曲管。

wire的代碼文件有兩個:wire.gowire_gen.go却邓,存放在main.go同級文件夾下。

wire.go

//go:build wireinject
// +build wireinject

// The build tag makes sure the stub is not built in the final build.

package main

import (
    "github.com/google/wire"

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

    conf "github.com/tx7do/kratos-bootstrap/gen/api/go/conf/v1"

    "kratos-monolithic-demo/app/admin/service/internal/data"
    "kratos-monolithic-demo/app/admin/service/internal/server"
    "kratos-monolithic-demo/app/admin/service/internal/service"
)

// initApp init kratos application.
func initApp(log.Logger, registry.Registrar, *conf.Bootstrap) (*kratos.App, func(), error) {
    panic(wire.Build(server.ProviderSet, service.ProviderSet, data.ProviderSet, newApp))
}

這個文件不參與編譯院水,是提供給代碼生成器用的模板腊徙,它把ProviderSet中的依賴項引入進來,由代碼生成器進行組裝檬某。

wire_gen.go

// Code generated by Wire. DO NOT EDIT.

//go:generate go run github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject

package main

import (
    "github.com/go-kratos/kratos/v2"
    "github.com/go-kratos/kratos/v2/log"
    "github.com/go-kratos/kratos/v2/registry"
    "github.com/tx7do/kratos-bootstrap/gen/api/go/conf/v1"
    "kratos-monolithic-demo/app/admin/service/internal/data"
    "kratos-monolithic-demo/app/admin/service/internal/server"
    "kratos-monolithic-demo/app/admin/service/internal/service"
)

// Injectors from wire.go:

// initApp init kratos application.
func initApp(logger log.Logger, registrar registry.Registrar, bootstrap *v1.Bootstrap) (*kratos.App, func(), error) {
    authenticator := data.NewAuthenticator(bootstrap)
    engine := data.NewAuthorizer()
    entClient := data.NewEntClient(bootstrap, logger)
    client := data.NewRedisClient(bootstrap, logger)
    dataData, cleanup, err := data.NewData(entClient, client, authenticator, engine, logger)
    if err != nil {
        return nil, nil, err
    }
    userRepo := data.NewUserRepo(dataData, logger)
    userTokenRepo := data.NewUserTokenRepo(dataData, authenticator, logger)
    authenticationService := service.NewAuthenticationService(logger, userRepo, userTokenRepo)
    userService := service.NewUserService(logger, userRepo)
    dictRepo := data.NewDictRepo(dataData, logger)
    dictService := service.NewDictService(logger, dictRepo)
    dictDetailRepo := data.NewDictDetailRepo(dataData, logger)
    dictDetailService := service.NewDictDetailService(logger, dictDetailRepo)
    menuRepo := data.NewMenuRepo(dataData, logger)
    menuService := service.NewMenuService(menuRepo, logger)
    routerService := service.NewRouterService(logger, menuRepo)
    organizationRepo := data.NewOrganizationRepo(dataData, logger)
    organizationService := service.NewOrganizationService(organizationRepo, logger)
    roleRepo := data.NewRoleRepo(dataData, logger)
    roleService := service.NewRoleService(roleRepo, logger)
    positionRepo := data.NewPositionRepo(dataData, logger)
    positionService := service.NewPositionService(positionRepo, logger)
    httpServer := server.NewRESTServer(bootstrap, logger, authenticator, engine, authenticationService, userService, dictService, dictDetailService, menuService, routerService, organizationService, roleService, positionService)
    app := newApp(logger, registrar, httpServer)
    return app, func() {
        cleanup()
    }, nil
}

這個文件是由Wire的代碼生成器生成而成撬腾。從代碼可見,復(fù)雜的依賴調(diào)用關(guān)系被Wire輕松的理順了恢恼。

代碼生成

wire的代碼生成有兩種途徑民傻,一個是安裝wire可執(zhí)行程序漓踢,一個是使用go run動態(tài)編譯執(zhí)行漏隐。推薦動態(tài)編譯執(zhí)行喧半,為什么呢挺据?這樣可以保證代碼生成器的版本和項目中wire的版本是一致的,如果版本不一致脖隶,可能會帶來一些問題。

go run -mod=mod github.com/google/wire/cmd/wire ./cmd/server

我已經(jīng)把這條命令寫入了app.mk婉称,可以在app/admin/service路徑下執(zhí)行:

make wire

Buf的使用

buf.build是專門用于構(gòu)建protobuf API的工具酿矢。

Buf本質(zhì)上是一個調(diào)用protoc的工具瘫筐,它可以把調(diào)用protoc的各種參數(shù)配置化策肝,并且支持遠程proto之众,遠程插件棺禾。所以膘婶,Buf能夠把proto的編譯工程化。

它總共有3組配置文件:buf.work.yaml衅码、buf.gen.yaml逝段、buf.yaml奶躯。

另外嘹黔,還有一個buf.lock文件,但是它不需要進行人工配置乏悄,它是由buf mod update命令所生成恳不。這跟前端的npm烟勋、yarn等的lock文件差不多卵惦,golang的go.sum也差不多沮尿。

它的配置文件不多,也不復(fù)雜印衔,維護起來非常方便奸焙,支持遠程proto插件与帆,支持遠程第三方proto鲤桥。對構(gòu)建系統(tǒng)Bazel支持很好茶凳,對CI/CD系統(tǒng)也支持得很好贮喧。它還有很多優(yōu)秀的特性箱沦。

buf.work.yaml

它一般放在項目的根目錄下面谓形,它代表的是一個工作區(qū)寒跳,通常一個項目也就一個該配置文件童太。

該配置文件最重要的就是directories配置項书释,列出了要包含在工作區(qū)中的模塊的目錄爆惧。目錄路徑必須相對于buf.work.yaml检激,像../external就是一個無效的配置齿穗。

version: v1

directories:
  - api

buf.gen.yaml

它一般放在buf.work.yaml的同級目錄下面窃页,它主要是定義一些protoc生成的規(guī)則和插件配置脖卖。

# 配置protoc生成規(guī)則
version: v1

managed:
  enabled: true
  optimize_for: SPEED

  go_package_prefix:
    default: kratos-monolithic-demo/gen/api/go
    except:
      - 'buf.build/googleapis/googleapis'
      - 'buf.build/envoyproxy/protoc-gen-validate'
      - 'buf.build/kratos/apis'
      - 'buf.build/gnostic/gnostic'
      - 'buf.build/gogo/protobuf'
      - 'buf.build/tx7do/pagination'

plugins:
  # 使用go插件生成go代碼
  #- plugin: buf.build/protocolbuffers/go
  - name: go
    out: gen/api/go
    opt: paths=source_relative # 使用相對路徑

  # 使用go-grpc插件生成gRPC服務(wù)代碼
  #- plugin: buf.build/grpc/go
  - name: go-grpc
    out: gen/api/go
    opt:
      - paths=source_relative # 使用相對路徑

  # generate rest service code
  - name: go-http
    out: gen/api/go
    opt:
      - paths=source_relative # 使用相對路徑

  # generate kratos errors code
  - name: go-errors
    out: gen/api/go
    opt:
      - paths=source_relative # 使用相對路徑

  # generate message validator code
  #- plugin: buf.build/bufbuild/validate-go
  - name: validate
    out: gen/api/go
    opt:
      - paths=source_relative # 使用相對路徑
      - lang=go

buf.yaml

它放置的路徑,你可以視之為protoc--proto-path參數(shù)指向的路徑十籍,也就是proto文件里面import的相對路徑勾栗。

需要注意的是围俘,buf.work.yaml的同級目錄必須要放一個該配置文件。

該配置文件的內(nèi)容通常來說都是下面這個配置宿亡,不需要做任何修改她混,需要修改的情況不多。

version: v1

deps:
  - 'buf.build/googleapis/googleapis'
  - 'buf.build/envoyproxy/protoc-gen-validate'
  - 'buf.build/kratos/apis'
  - 'buf.build/gnostic/gnostic'
  - 'buf.build/gogo/protobuf'
  - 'buf.build/tx7do/pagination'

breaking:
  use:
    - FILE

lint:
  use:
    - DEFAULT

API代碼生成

我們可以使用以下命令來進行代碼生成:

buf generate

或者

make api

Ent的使用

Ent是一個優(yōu)秀的ORM框架「购觯基于模板進行代碼生成窘奏,相比較利用反射等方式着裹,在性能上的損耗更少摔竿。并且继低,模板的使用使得擴展系統(tǒng)變得簡單容易袁翁。

它不僅能夠很對傳統(tǒng)的關(guān)系數(shù)據(jù)庫(MySQL梦裂、PostgreSQL、SQLite)方便的進行查詢冗恨,并且可以容易的進行圖遍歷——常用的譬如像是:菜單樹掀抹、組織樹……這種數(shù)據(jù)查詢傲武。

Schema

Schema相當(dāng)于數(shù)據(jù)庫的表揪利。

《道德經(jīng)》說:

道生一,一生二甜刻,二生三得院,三生萬物矾柜。

Schema怪蔑,就是數(shù)據(jù)庫開發(fā)的起始點缆瓣。

只有定義了Schema弓坞,代碼生成器才能夠生成數(shù)據(jù)庫表的go數(shù)據(jù)結(jié)構(gòu)和相關(guān)操作的go代碼,有了這些生成后的代碼族吻,我們才能夠通過ORM來操作數(shù)據(jù)庫表。

ent還支持從Schema生成gRPC和GraphQL的接口定義巍举,可以說ent已經(jīng)打通了開發(fā)全流程——向后搞定了數(shù)據(jù)庫懊悯,向前搞定了API。

創(chuàng)建一個Schema

創(chuàng)建Schema有兩個方法可以做到:

使用 ent init 創(chuàng)建
ent init User

將會在 {當(dāng)前目錄}/ent/schema/ 下生成一個user.go文件欠窒,如果沒有文件夾岖妄,則會創(chuàng)建一個:

package schema

import "entgo.io/ent"

// User holds the schema definition for the User entity.
type User struct {
    ent.Schema
}

// Fields of the User.
func (User) Fields() []ent.Field {
    return nil
}

// Edges of the User.
func (User) Edges() []ent.Edge {
    return nil
}
SQL轉(zhuǎn)換Schema在線工具

網(wǎng)上有人好心的制作了一個在線工具,可以將SQL轉(zhuǎn)換成schema代碼福扬,實際應(yīng)用中,這是非常方便的汽烦!

SQL轉(zhuǎn)Schema工具: https://printlove.cn/tools/sql2ent

比如撇吞,我們有一個創(chuàng)建表的SQL語句:

CREATE TABLE `user`  (
`id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`email` varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL,
`type` varchar(20) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_unicode_ci ROW_FORMAT = DYNAMIC;

轉(zhuǎn)換之后,生成如下的Schema代碼:

package schema

import (
    "entgo.io/ent"
    "entgo.io/ent/dialect"
    "entgo.io/ent/schema/field"
)

// User holds the schema definition for the User entity.
type User struct {
    ent.Schema
}

// Fields of the User.
func (User) Fields() []ent.Field {

    return []ent.Field{
        field.Int32("id").SchemaType(map[string]string{
            dialect.MySQL: "int(10)UNSIGNED", // Override MySQL.
        }).NonNegative().Unique(),

        field.String("email").SchemaType(map[string]string{
            dialect.MySQL: "varchar(50)", // Override MySQL.
        }),

        field.String("type").SchemaType(map[string]string{
            dialect.MySQL: "varchar(20)", // Override MySQL.
        }),

        field.Time("created_at").SchemaType(map[string]string{
            dialect.MySQL: "timestamp", // Override MySQL.
        }).Optional(),

        field.Time("updated_at").SchemaType(map[string]string{
            dialect.MySQL: "timestamp", // Override MySQL.
        }).Optional(),
    }

}

// Edges of the User.
func (User) Edges() []ent.Edge {
    return nil
}

Mixin復(fù)用字段

在實際應(yīng)用中,我們經(jīng)常會碰到一些一模一樣的通用字段人乓,比如:idcreated_at戳护、updated_at等等腌且。

那么,我們就只能一直的復(fù)制粘貼精续?這會使得代碼既臃腫重付,又顯得很不優(yōu)雅弓颈。

entgo能夠讓我們復(fù)用這些字段嗎?

答案顯然是,沒問題计福。

Mixin象颖,就是辦這個事兒的。

好陶冷,我們現(xiàn)在需要復(fù)用時間相關(guān)的字段:created_atupdated_at,那么我們可以:

package mixin

import (
    "time"

    "entgo.io/ent"
    "entgo.io/ent/schema/field"
    "entgo.io/ent/schema/mixin"
)

type TimeMixin struct {
    mixin.Schema
}

func (TimeMixin) Fields() []ent.Field {
    return []ent.Field{
        field.Time("created_at").
            Immutable().
            Default(time.Now),

        field.Time("updated_at").
            Default(time.Now).
            UpdateDefault(time.Now),
    }
}

然后沾谜,我們就可以在Schema當(dāng)中應(yīng)用了,比如User媳否,我們?yōu)樗砑右粋€Mixin方法:

func (User) Mixin() []ent.Mixin {
    return []ent.Mixin{
        mixin.TimeMixin{},
    }
}

生成代碼再看力图,user表就擁有這2個字段了靡努。

生成Ent代碼

internal/data/ent目錄下執(zhí)行:

go run -mod=mod entgo.io/ent/cmd/ent generate \
        --feature privacy \
        --feature sql/modifier \
        --feature entql \
        --feature sql/upsert \
        ./internal/data/ent/schema

或者:

ent generate \
        --feature privacy \
        --feature sql/modifier \
        --feature entql \
        --feature sql/upsert \
        ./internal/data/ent/schema

或者直接在app/admin/service路徑下用Make命令:

make ent

連接數(shù)據(jù)庫

SQLite3

import (
    _ "github.com/mattn/go-sqlite3"
)

client, err := ent.Open("sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
if err != nil {
    log.Fatalf("failed opening connection to sqlite: %v", err)
}
defer client.Close()

MySQL/MariaDB

  • TiDB 高度兼容MySQL 5.7 協(xié)議
  • ClickHouse 支持MySQL wire通訊協(xié)議
import (
    _ "github.com/go-sql-driver/mysql"
)

client, err := ent.Open("mysql", "<user>:<pass>@tcp(<host>:<port>)/<database>?parseTime=True")
if err != nil {
    log.Fatalf("failed opening connection to mysql: %v", err)
}
defer client.Close()

PostgreSQL

  • CockroachDB 兼容PostgreSQL協(xié)議
import (
    _ "github.com/lib/pq"
)

client, err := ent.Open("postgresql", "host=<host> port=<port> user=<user> dbname=<database> password=<pass>")
if err != nil {
    log.Fatalf("failed opening connection to postgres: %v", err)
}
defer client.Close()

Gremlin

import (
    "<project>/ent"
)

client, err := ent.Open("gremlin", "http://localhost:8182")
if err != nil {
    log.Fatalf("failed opening connection to gremlin: %v", err)
}
defer client.Close()

自定義驅(qū)動sql.DB連接

有以下兩種途徑可以達成:

package main

import (
    "time"

    "<your_project>/ent"
    "entgo.io/ent/dialect/sql"
)

func Open() (*ent.Client, error) {
    drv, err := sql.Open("mysql", "<mysql-dsn>")
    if err != nil {
        return nil, err
    }
    // Get the underlying sql.DB object of the driver.
    db := drv.DB()
    db.SetMaxIdleConns(10)
    db.SetMaxOpenConns(100)
    db.SetConnMaxLifetime(time.Hour)
    return ent.NewClient(ent.Driver(drv)), nil
}

第二種是:

package main

import (
    "database/sql"
    "time"

    "<your_project>/ent"
    entsql "entgo.io/ent/dialect/sql"
)

func Open() (*ent.Client, error) {
    db, err := sql.Open("mysql", "<mysql-dsn>")
    if err != nil {
        return nil, err
    }
    db.SetMaxIdleConns(10)
    db.SetMaxOpenConns(100)
    db.SetConnMaxLifetime(time.Hour)
    // Create an ent.Driver from `db`.
    drv := entsql.OpenDB("mysql", db)
    return ent.NewClient(ent.Driver(drv)), nil
}

在實際應(yīng)用中,使用自定義的方法會更好蜓陌,有兩個原因:

  1. 可以定制數(shù)據(jù)庫連接烛芬,比如使用連接池仆潮;
  2. 如果查詢語句太過于復(fù)雜,可以直接使用驅(qū)動寫SQL語句進行查詢蚌讼。

OpenAPI的使用

Kratos官方本來是有一個swagger-api的項目的(現(xiàn)在已經(jīng)被歸檔了)西采,集成的是OpenAPI v2的Swagger UI武通。這個項目呢境析,不好使,我在應(yīng)用中括勺,經(jīng)常會讀不出來OpenAPI的文檔拾氓。還有就是OpenAPI v2不如v3功能強大续滋。

因為沒有支持,而我又需要跟前端進行溝通,所以我只好生成出OpenAPI文檔之后崭庸,自行導(dǎo)入到ApiFox里面去使用执赡,ApiFox呢,挺好的绊率,支持文件和在線兩種方式導(dǎo)入佣盒,文檔管理,接口測試的功能也都很強大盯仪。但是總是要去費神導(dǎo)出文檔全景,這很讓人抗拒——在開發(fā)的初期爸黄,接口變動是很高頻的行為——難道就不能夠全自動嗎揭鳞?程序只要一發(fā)布称开,接口就自動的跟隨程序一起發(fā)布出去了鳖轰。

對蕴侣,說的就是集成Swagger UI睛蛛。

為了做到這件事,并且工程化荸频,需要做這么幾件事情:

  1. 編寫Buf配置進行OpenAPI文檔的生成旭从;
  2. 把Buf生成OpenAPI文檔的命令寫進MakeFile里面和悦;
  3. 利用golang的Embedding Files特性鸽素,把openapi.yaml嵌入到BFF服務(wù)程序里面馍忽;
  4. 集成Swagger UI到項目燕差,并且讀取內(nèi)嵌的openapi.yaml文檔徒探。

1. 編寫Buf配置進行OpenAPI文檔的生成

細心的你肯定早就發(fā)現(xiàn)了在api/admin/service/v1下面有一個buf.openapi.gen.yaml的配置文件测暗,這是什么配置文件呢碗啄?我現(xiàn)在把該配置文件放出來:

# 配置protoc生成規(guī)則
version: v1

managed:
  enabled: true
  optimize_for: SPEED

  go_package_prefix:
    default: kratos-monolithic-demo/gen/api/go
    except:
      - 'buf.build/googleapis/googleapis'
      - 'buf.build/envoyproxy/protoc-gen-validate'
      - 'buf.build/kratos/apis'
      - 'buf.build/gnostic/gnostic'
      - 'buf.build/gogo/protobuf'
      - 'buf.build/tx7do/pagination'

plugins:
  # generate openapi v2 json doc
#  - name: openapiv2
#    out: ./app/admin/service/cmd/server/assets
#    opt:
#      - json_names_for_fields=true
#      - logtostderr=true

  # generate openapi v3 yaml doc
  - name: openapi
    out: ./app/admin/service/cmd/server/assets
    opt:
      - naming=json # 命名約定挫掏。使用"proto"則直接從proto文件傳遞名稱尉共。默認為:json
      - depth=2 # 循環(huán)消息的遞歸深度袄友,默認為:2
      - default_response=false # 添加默認響應(yīng)消息剧蚣。如果為“true”,則自動為使用google.rpc.Status消息的操作添加默認響應(yīng)饶碘。如果您使用envoy或grpc-gateway進行轉(zhuǎn)碼馒吴,則非常有用饮戳,因為它們使用此類型作為默認錯誤響應(yīng)扯罐。默認為:true歹河。
      - enum_type=string # 枚舉類型的序列化的類型启泣。使用"string"則進行基于字符串的序列化。默認為:integer矾麻。
      - output_mode=merged # 輸出文件生成模式。默認情況下弄喘,只有一個openapi.yaml文件會生成在輸出文件夾蘑志。使用“source_relative”則會為每一個'[inputfile].proto'文件單獨生成一個“[inputfile].openapi.yaml”文件急但。默認為:merged波桩。
      - fq_schema_naming=false # Schema的命名是否加上包名镐躲,為true萤皂,則會加上包名裆熙,例如:system.service.v1.ListDictDetailResponse弛车,否則為:ListDictDetailResponse纷跛。默認為:false。

這個配置文件是為了生成OpenAPI v3文檔而編寫的唬血。

我之前嘗試了把生成OpenAPI的配置放在根目錄下的buf.gen.yaml,但是這產(chǎn)生了一個問題谢肾,因為我一個項目里面會有多個BFF服務(wù)程序芦疏,我不可能一股腦全部輸出到一個openapi.yaml里面酸茴。雖然薪捍,代碼生成器也可以為每一個proto各自生成一個[inputfile].openapi.yaml酪穿,但是昆稿,這樣顯得太亂了溉潭,而且,我沒有辦法用赞别。所以仿滔,沒轍崎页,只能單獨對待了——每個BFF服務(wù)獨立生成一個文檔飒焦。

那么牺荠,怎么使用這個配置文件呢休雌?還是使用buf generate命令杈曲,該命令還是需要在項目根目錄下執(zhí)行鱼蝉,但是得帶--template參數(shù)去引入buf.openapi.gen.yaml這個配置文件:

buf generate --path api/admin/service/v1 --template api/admin/service/v1/buf.openapi.gen.yaml

最終,在./app/admin/service/cmd/server/assets這個目錄下面羔挡,將會生成出來一個文件名為openapi.yaml的文件绞灼。

2. 把Buf生成OpenAPI文檔的命令寫進MakeFile里面

這么長的命令低矮,顯然寫入到Makefile會更加好用军掂。

那么蝗锥,我們開始編寫Makefile:

# generate protobuf api go code
api:
    buf generate

# generate OpenAPI v3 docs.
openapi:
    buf generate --path api/admin/service/v1 --template api/admin/service/v1/buf.openapi.gen.yaml
    buf generate --path api/front/service/v1 --template api/front/service/v1/buf.openapi.gen.yaml

# run application
run: api openapi
    @go run ./cmd/server -conf ./configs

這樣我們只需要在backend根目錄下執(zhí)行Make命令汇竭,就完成OpenAPI的生成了:

make openapi

3. 利用golang的Embedding Files特性细燎,把openapi.yaml嵌入到BFF服務(wù)程序里面

OpenAPI文檔是要使用Swagger UI讀取找颓,提供給前端的击狮,那么彪蓬,openapi.yaml肯定是要跟著程序走的档冬。我一開始想過放在configs里面酷誓,雖然也是yaml文件盐数,但是玫氢,它還是跟配置文件有本質(zhì)上的差別:它其實是一個文檔漾峡,而非配置生逸。

以前寫VC的時候槽袄,一些資源是可以內(nèi)嵌到EXE的二進制程序里面去的掰伸。Go也可以做到合搅,就是使用Embedding Files的特性灾部。

文檔赌髓,跟隨二進制程序走锁蠕,在我看來荣倾,才是最優(yōu)解舌仍。下面我們就開始實現(xiàn)文檔的內(nèi)嵌铸豁。

現(xiàn)在节芥,我們來到./app/admin/service/cmd/server/assets這個目錄下面藏古,我們在這個目錄下面創(chuàng)建一個名為assets.go的代碼文件:

package assets

import _ "embed"

//go:embed openapi.yaml
var OpenApiData []byte

利用go:embed注解引入openapi.yaml文檔隙姿,并且讀取成一個類型為[]byte名為OpenApiData的全局變量。

就這樣靡馁,我們就把openapi.yaml內(nèi)嵌進程序了臭墨。

4. 集成Swagger UI到項目胧弛,并且讀取內(nèi)嵌的openapi.yaml文檔

最后,我們就可以著手集成Swagger UI了红竭。

我為了集成Swagger UI茵宪,把Swagger UI封裝了一個軟件包眉厨,要使用它憾股,我們需要安裝依賴庫:

go get -u github.com/tx7do/kratos-swagger-ui

在創(chuàng)建REST服務(wù)器的地方調(diào)用程序包里面的方法:

package server

import (
    rest "github.com/go-kratos/kratos/v2/transport/http"
    swaggerUI "github.com/tx7do/kratos-swagger-ui"

    "kratos-monolithic-demo/app/admin/service/cmd/server/assets"
)

func NewRESTServer() *rest.Server {
    srv := CreateRestServer()

    swaggerUI.RegisterSwaggerUIServerWithOption(
        srv,
        swaggerUI.WithTitle("Admin Service"),
        swaggerUI.WithMemoryData(assets.OpenApiData, "yaml"),
    )
}

到現(xiàn)在,我們就大功告成了斩熊!

假如BFF服務(wù)的端口是8080粉渠,那么我們可以訪問下面的鏈接來訪問Swagger UI:

http://localhost:8080/docs/

同時霸株,openapi.yaml文件也可以在線訪問到:

http://localhost:8080/docs/openapi.yaml

完整的CURD開發(fā)示例

Kratos的官方示例的結(jié)構(gòu)是:databiz尤溜、service宫莱、server授霸,我簡化掉了绝葡,我把biz給摘除掉了敷硅。

我們以用戶UserService為例绞蹦。

Data

所有對ORM的調(diào)用幽七,對數(shù)據(jù)庫的操作都在這一層做澡屡。

package data

import (
    "context"
    "time"

    "entgo.io/ent/dialect/sql"
    "github.com/go-kratos/kratos/v2/log"
    "github.com/tx7do/go-utils/crypto"
    entgo "github.com/tx7do/go-utils/entgo/query"
    util "github.com/tx7do/go-utils/time"
    "github.com/tx7do/go-utils/trans"

    "kratos-monolithic-demo/app/admin/service/internal/data/ent"
    "kratos-monolithic-demo/app/admin/service/internal/data/ent/user"

    pagination "github.com/tx7do/kratos-bootstrap/gen/api/go/pagination/v1"
    v1 "kratos-monolithic-demo/gen/api/go/user/service/v1"
)

type UserRepo struct {
    data *Data
    log  *log.Helper
}

func NewUserRepo(data *Data, logger log.Logger) *UserRepo {
    l := log.NewHelper(log.With(logger, "module", "user/repo/admin-service"))
    return &UserRepo{
        data: data,
        log:  l,
    }
}

func (r *UserRepo) convertEntToProto(in *ent.User) *v1.User {
    if in == nil {
        return nil
    }

    var authority *v1.UserAuthority
    if in.Authority != nil {
        authority = (*v1.UserAuthority)(trans.Int32(v1.UserAuthority_value[string(*in.Authority)]))
    }

    return &v1.User{
        Id:            in.ID,
        RoleId:        in.RoleID,
        WorkId:        in.WorkID,
        OrgId:         in.OrgID,
        PositionId:    in.PositionID,
        CreatorId:     in.CreateBy,
        UserName:      in.Username,
        NickName:      in.NickName,
        RealName:      in.RealName,
        Email:         in.Email,
        Avatar:        in.Avatar,
        Phone:         in.Phone,
        Gender:        (*string)(in.Gender),
        Address:       in.Address,
        Description:   in.Description,
        Authority:     authority,
        LastLoginTime: in.LastLoginTime,
        LastLoginIp:   in.LastLoginIP,
        Status:        (*string)(in.Status),
        CreateTime:    util.TimeToTimeString(in.CreateTime),
        UpdateTime:    util.TimeToTimeString(in.UpdateTime),
        DeleteTime:    util.TimeToTimeString(in.DeleteTime),
    }
}

func (r *UserRepo) Count(ctx context.Context, whereCond []func(s *sql.Selector)) (int, error) {
    builder := r.data.db.Client().User.Query()
    if len(whereCond) != 0 {
        builder.Modify(whereCond...)
    }

    count, err := builder.Count(ctx)
    if err != nil {
        r.log.Errorf("query count failed: %s", err.Error())
    }

    return count, err
}

func (r *UserRepo) List(ctx context.Context, req *pagination.PagingRequest) (*v1.ListUserResponse, error) {
    builder := r.data.db.Client().User.Query()

    err, whereSelectors, querySelectors := entgo.BuildQuerySelector(r.data.db.Driver().Dialect(),
        req.GetQuery(), req.GetOrQuery(),
        req.GetPage(), req.GetPageSize(), req.GetNoPaging(),
        req.GetOrderBy(), user.FieldCreateTime)
    if err != nil {
        r.log.Errorf("解析條件發(fā)生錯誤[%s]", err.Error())
        return nil, err
    }

    if querySelectors != nil {
        builder.Modify(querySelectors...)
    }

    if req.GetFieldMask() != nil && len(req.GetFieldMask().GetPaths()) > 0 {
        builder.Select(req.GetFieldMask().GetPaths()...)
    }

    results, err := builder.All(ctx)
    if err != nil {
        r.log.Errorf("query list failed: %s", err.Error())
        return nil, err
    }

    items := make([]*v1.User, 0, len(results))
    for _, res := range results {
        item := r.convertEntToProto(res)
        items = append(items, item)
    }

    count, err := r.Count(ctx, whereSelectors)
    if err != nil {
        return nil, err
    }

    return &v1.ListUserResponse{
        Total: int32(count),
        Items: items,
    }, nil
}

func (r *UserRepo) Get(ctx context.Context, req *v1.GetUserRequest) (*v1.User, error) {
    ret, err := r.data.db.Client().User.Get(ctx, req.GetId())
    if err != nil && !ent.IsNotFound(err) {
        r.log.Errorf("query one data failed: %s", err.Error())
        return nil, err
    }

    u := r.convertEntToProto(ret)
    return u, err
}

func (r *UserRepo) Create(ctx context.Context, req *v1.CreateUserRequest) (*v1.User, error) {
    ph, err := crypto.HashPassword(req.GetPassword())
    if err != nil {
        return nil, err
    }

    builder := r.data.db.Client().User.Create().
        SetNillableUsername(req.User.UserName).
        SetNillableNickName(req.User.NickName).
        SetNillableEmail(req.User.Email).
        SetNillableRealName(req.User.RealName).
        SetNillablePhone(req.User.Phone).
        SetNillableOrgID(req.User.OrgId).
        SetNillableRoleID(req.User.RoleId).
        SetNillableWorkID(req.User.WorkId).
        SetNillablePositionID(req.User.PositionId).
        SetNillableAvatar(req.User.Avatar).
        SetNillableStatus((*user.Status)(req.User.Status)).
        SetNillableGender((*user.Gender)(req.User.Gender)).
        SetCreateBy(req.GetOperatorId()).
        SetPassword(ph).
        SetCreateTime(time.Now())

    if req.User.Authority != nil {
        builder.SetAuthority((user.Authority)(req.User.Authority.String()))
    }

    ret, err := builder.Save(ctx)
    if err != nil {
        r.log.Errorf("insert one data failed: %s", err.Error())
        return nil, err
    }

    u := r.convertEntToProto(ret)
    return u, err
}

func (r *UserRepo) Update(ctx context.Context, req *v1.UpdateUserRequest) (*v1.User, error) {
    cryptoPassword, err := crypto.HashPassword(req.GetPassword())
    if err != nil {
        return nil, err
    }

    builder := r.data.db.Client().User.UpdateOneID(req.Id).
        SetNillableNickName(req.User.NickName).
        SetNillableEmail(req.User.Email).
        SetNillableRealName(req.User.RealName).
        SetNillablePhone(req.User.Phone).
        SetNillableOrgID(req.User.OrgId).
        SetNillableRoleID(req.User.RoleId).
        SetNillableWorkID(req.User.WorkId).
        SetNillablePositionID(req.User.PositionId).
        SetNillableAvatar(req.User.Avatar).
        SetNillableStatus((*user.Status)(req.User.Status)).
        SetNillableGender((*user.Gender)(req.User.Gender)).
        SetPassword(cryptoPassword).
        SetUpdateTime(time.Now())

    if req.User.Authority != nil {
        builder.SetAuthority((user.Authority)(req.User.Authority.String()))
    }

    ret, err := builder.Save(ctx)
    if err != nil {
        r.log.Errorf("update one data failed: %s", err.Error())
        return nil, err
    }

    u := r.convertEntToProto(ret)
    return u, err
}

func (r *UserRepo) Delete(ctx context.Context, req *v1.DeleteUserRequest) (bool, error) {
    err := r.data.db.Client().User.
        DeleteOneID(req.GetId()).
        Exec(ctx)
    if err != nil {
        r.log.Errorf("delete one data failed: %s", err.Error())
    }

    return err == nil, err
}

增刪改伊约,這些都沒有什么特別的屡律。

列表查詢疹尾,有點特別纳本,需要特別的說明一下窍蓝,我提取了一個通用的分頁請求:

字段名 類型 格式 字段描述 示例 備注
page number 當(dāng)前頁碼 默認為1,最小值為1繁成。
pageSize number 每頁的行數(shù) 默認為10吓笙,最小值為1
query string json objectjson object array AND過濾條件 json字符串: {"field1":"val1","field2":"val2"} 或者[{"field1":"val1"},{"field1":"val2"},{"field2":"val2"}] maparray都支持巾腕,當(dāng)需要同字段名,不同值的情況下尊搬,請使用array叁鉴。具體規(guī)則請見:過濾規(guī)則
or string json objectjson object array OR過濾條件 同 AND過濾條件
orderBy string json string array 排序條件 json字符串:["-create_time", "type"] json的string array,字段名前加-是為降序佛寿,不加為升序幌墓。具體規(guī)則請見:排序規(guī)則
nopaging boolean 是否不分頁 此字段為true時,page冀泻、pageSize字段的傳入將無效用常侣。
fieldMask string json string array 字段掩碼 此字段是SELECT條件,為空的時候是為*弹渔。

Service

這一層主要是處理REST的請求和返回信息胳施。

package service

import (
    "context"

    "github.com/go-kratos/kratos/v2/log"
    "github.com/tx7do/go-utils/trans"
    "google.golang.org/protobuf/types/known/emptypb"

    "kratos-monolithic-demo/app/admin/service/internal/data"

    adminV1 "kratos-monolithic-demo/gen/api/go/admin/service/v1"
    userV1 "kratos-monolithic-demo/gen/api/go/user/service/v1"

    pagination "github.com/tx7do/kratos-bootstrap/gen/api/go/pagination/v1"

    "kratos-monolithic-demo/pkg/middleware/auth"
)

type UserService struct {
    adminV1.UserServiceHTTPServer

    uc  *data.UserRepo
    log *log.Helper
}

func NewUserService(logger log.Logger, uc *data.UserRepo) *UserService {
    l := log.NewHelper(log.With(logger, "module", "user/service/admin-service"))
    return &UserService{
        log: l,
        uc:  uc,
    }
}

func (s *UserService) ListUser(ctx context.Context, req *pagination.PagingRequest) (*userV1.ListUserResponse, error) {
    return s.uc.List(ctx, req)
}

func (s *UserService) GetUser(ctx context.Context, req *userV1.GetUserRequest) (*userV1.User, error) {
    return s.uc.Get(ctx, req)
}

func (s *UserService) CreateUser(ctx context.Context, req *userV1.CreateUserRequest) (*userV1.User, error) {
    authInfo, err := auth.FromContext(ctx)
    if err != nil {
        s.log.Errorf("[%d] 用戶認證失敗[%s]", authInfo, err.Error())
        return nil, adminV1.ErrorAccessForbidden("用戶認證失敗")
    }

    if req.User == nil {
        return nil, adminV1.ErrorBadRequest("錯誤的參數(shù)")
    }

    req.OperatorId = authInfo.UserId
    req.User.CreatorId = trans.Uint32(authInfo.UserId)
    if req.User.Authority == nil {
        req.User.Authority = userV1.UserAuthority_CUSTOMER_USER.Enum()
    }

    ret, err := s.uc.Create(ctx, req)
    return ret, err
}

func (s *UserService) UpdateUser(ctx context.Context, req *userV1.UpdateUserRequest) (*userV1.User, error) {
    authInfo, err := auth.FromContext(ctx)
    if err != nil {
        s.log.Errorf("[%d] 用戶認證失敗[%s]", authInfo, err.Error())
        return nil, adminV1.ErrorAccessForbidden("用戶認證失敗")
    }

    if req.User == nil {
        return nil, adminV1.ErrorBadRequest("錯誤的參數(shù)")
    }

    req.OperatorId = authInfo.UserId

    ret, err := s.uc.Update(ctx, req)
    return ret, err
}

func (s *UserService) DeleteUser(ctx context.Context, req *userV1.DeleteUserRequest) (*emptypb.Empty, error) {
    authInfo, err := auth.FromContext(ctx)
    if err != nil {
        s.log.Errorf("[%d] 用戶認證失敗[%s]", authInfo, err.Error())
        return nil, adminV1.ErrorAccessForbidden("用戶認證失敗")
    }

    req.OperatorId = authInfo.UserId

    _, err = s.uc.Delete(ctx, req)

    return &emptypb.Empty{}, err
}

Server

在這一層創(chuàng)建REST服務(wù)器,Service的服務(wù)也在這里注冊進去肢专。

package server

import (
    "context"

    "github.com/go-kratos/kratos/v2/log"
    "github.com/go-kratos/kratos/v2/middleware"
    "github.com/go-kratos/kratos/v2/middleware/logging"
    "github.com/go-kratos/kratos/v2/middleware/selector"
    "github.com/go-kratos/kratos/v2/transport/http"

    bootstrap "github.com/tx7do/kratos-bootstrap"
    conf "github.com/tx7do/kratos-bootstrap/gen/api/go/conf/v1"

    "kratos-monolithic-demo/app/admin/service/cmd/server/assets"
    "kratos-monolithic-demo/app/admin/service/internal/service"

    adminV1 "kratos-monolithic-demo/gen/api/go/admin/service/v1"
    "kratos-monolithic-demo/pkg/middleware/auth"
)

// NewRESTServer new an HTTP server.
func NewRESTServer(
    cfg *conf.Bootstrap, logger log.Logger,
    userSvc *service.UserService,
) *http.Server {
    srv := bootstrap.CreateRestServer(cfg)
    adminV1.RegisterUserServiceHTTPServer(srv, userSvc)
    return srv
}

用戶登陸認證

登陸的協(xié)議使用OAuth 2.0的密碼授權(quán)(Password Grant)方式舞肆,協(xié)議proto定義如下:

syntax = "proto3";

package admin.service.v1;

// 用戶后臺登陸認證服務(wù)
service AuthenticationService {
  // 登陸
  rpc Login (LoginRequest) returns (LoginResponse) {
    option (google.api.http) = {
      post: "/admin/v1/login"
      body: "*"
    };
  }

  // 登出
  rpc Logout (LogoutRequest) returns (google.protobuf.Empty) {
    option (google.api.http) = {
      post: "/admin/v1/logout"
      body: "*"
    };
  }

  // 刷新認證令牌
  rpc RefreshToken (RefreshTokenRequest) returns (LoginResponse) {
    option (google.api.http) = {
      post: "/admin/v1/refresh_token"
      body: "*"
    };
  }
}

// 用戶后臺登陸 - 請求
message LoginRequest {
  string username = 1; // 用戶名,必選項博杖。
  string password = 2; // 用戶的密碼椿胯,必選項。
  string grand_type = 3; // 授權(quán)類型欧募,此處的值固定為"password"压状,必選項。
  optional string scope = 4; // 以空格分隔的范圍列表跟继。如果未提供种冬,scope則授權(quán)任何范圍,默認為空列表舔糖。
}

// 用戶后臺登陸 - 回應(yīng)
message LoginResponse {
  string access_token = 1; // 訪問令牌娱两,必選項。
  string refresh_token = 2; // 更新令牌金吗,用來獲取下一次的訪問令牌十兢,可選項趣竣。
  string token_type = 3; // 令牌類型,該值大小寫不敏感旱物,必選項遥缕,可以是bearer類型或mac類型。
  int64 expires_in = 4; // 過期時間宵呛,單位為秒单匣。如果省略該參數(shù),必須其他方式設(shè)置過期時間宝穗。
}

// 用戶刷新令牌 - 請求
message RefreshTokenRequest {
  string refresh_token = 1; // 更新令牌户秤,用來獲取下一次的訪問令牌,必選項逮矛。
  string grand_type = 2; // 授權(quán)類型鸡号,此處的值固定為"password",必選項须鼎。
  optional string scope = 3; // 以空格分隔的范圍列表鲸伴。如果未提供,scope則授權(quán)任何范圍莉兰,默認為空列表挑围。
}

使用標(biāo)準(zhǔn)化的OAuth 2.0協(xié)議,有一個好處就是糖荒,別的系統(tǒng)可以無縫對接用戶登陸認證杉辙。

登陸的令牌,我們使用JWT算法生成捶朵。刷新的令牌蜘矢,使用UUIDv4算法生成,生成的代碼如下:

import (
    authnEngine "github.com/tx7do/kratos-authn/engine"
)

type UserTokenRepo struct {
    data          *Data
    log           *log.Helper
    authenticator authnEngine.Authenticator
}

// createAccessJwtToken 生成JWT訪問令牌
func (r *UserTokenRepo) createAccessJwtToken(_ string, userId uint32) string {
    principal := authn.AuthClaims{
        Subject: strconv.FormatUint(uint64(userId), 10),
        Scopes:  make(authn.ScopeSet),
    }

    signedToken, err := r.authenticator.CreateIdentity(principal)
    if err != nil {
        return ""
    }

    return signedToken
}

// createRefreshToken 生成刷新令牌
func (r *UserTokenRepo) createRefreshToken() string {
    strUUID, _ := uuid.NewV4()
    return strUUID.String()
}

JWT令牌的生成和驗證的具體算法综看,我都已經(jīng)封裝在了github.com/tx7do/kratos-authn軟件包里面品腹。

JWT令牌的驗證,以中間件的方式提供:

import (
    "context"

    "github.com/go-kratos/kratos/v2/log"
    "github.com/go-kratos/kratos/v2/middleware"
    "github.com/go-kratos/kratos/v2/middleware/logging"
    "github.com/go-kratos/kratos/v2/middleware/selector"
    "github.com/go-kratos/kratos/v2/transport/http"

    authnEngine "github.com/tx7do/kratos-authn/engine"
    authn "github.com/tx7do/kratos-authn/middleware"
)

// NewWhiteListMatcher 創(chuàng)建jwt白名單
func newRestWhiteListMatcher() selector.MatchFunc {
    whiteList := make(map[string]bool)
    whiteList[adminV1.OperationAuthenticationServiceLogin] = true
    return func(ctx context.Context, operation string) bool {
        if _, ok := whiteList[operation]; ok {
            return false
        }
        return true
    }
}

// NewRESTServer new an HTTP server.
func NewRESTServer(
    cfg *conf.Bootstrap, logger log.Logger,
    authenticator authnEngine.Authenticator,
) *http.Server {
    srv := bootstrap.CreateRestServer(cfg, selector.Server(authn.Server(authenticator)).Match(newRestWhiteListMatcher()).Build())
    return srv
}

現(xiàn)在红碑,只要不是在白名單里面的接口舞吭,都將接受JWT令牌的驗證,無法通過驗證的請求析珊,都將無法訪問該接口羡鸥。

結(jié)語

當(dāng)你學(xué)習(xí)到了這些知識點之后,你會發(fā)現(xiàn)上手使用Kratos微服務(wù)框架所涉及的知識點也并不繁雜忠寻,學(xué)習(xí)的門檻還是很低的惧浴。基于本文中的demo項目奕剃,我相信你可以很快的上手寫項目了衷旅。

參考資料

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末茄袖,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子九串,更是在濱河造成了極大的恐慌绞佩,老刑警劉巖寺鸥,帶你破解...
    沈念sama閱讀 218,941評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件猪钮,死亡現(xiàn)場離奇詭異,居然都是意外死亡胆建,警方通過查閱死者的電腦和手機烤低,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,397評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來笆载,“玉大人扑馁,你說我怎么就攤上這事×棺ぃ” “怎么了腻要?”我有些...
    開封第一講書人閱讀 165,345評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長涝登。 經(jīng)常有香客問我雄家,道長,這世上最難降的妖魔是什么胀滚? 我笑而不...
    開封第一講書人閱讀 58,851評論 1 295
  • 正文 為了忘掉前任趟济,我火速辦了婚禮,結(jié)果婚禮上咽笼,老公的妹妹穿的比我還像新娘顷编。我一直安慰自己,他們只是感情好剑刑,可當(dāng)我...
    茶點故事閱讀 67,868評論 6 392
  • 文/花漫 我一把揭開白布媳纬。 她就那樣靜靜地躺著,像睡著了一般施掏。 火紅的嫁衣襯著肌膚如雪钮惠。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,688評論 1 305
  • 那天其监,我揣著相機與錄音萌腿,去河邊找鬼。 笑死抖苦,一個胖子當(dāng)著我的面吹牛毁菱,可吹牛的內(nèi)容都是我干的米死。 我是一名探鬼主播,決...
    沈念sama閱讀 40,414評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼贮庞,長吁一口氣:“原來是場噩夢啊……” “哼峦筒!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起窗慎,我...
    開封第一講書人閱讀 39,319評論 0 276
  • 序言:老撾萬榮一對情侶失蹤物喷,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后遮斥,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體峦失,經(jīng)...
    沈念sama閱讀 45,775評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,945評論 3 336
  • 正文 我和宋清朗相戀三年术吗,在試婚紗的時候發(fā)現(xiàn)自己被綠了尉辑。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,096評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡较屿,死狀恐怖隧魄,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情隘蝎,我是刑警寧澤购啄,帶...
    沈念sama閱讀 35,789評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站嘱么,受9級特大地震影響狮含,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜拱撵,卻給世界環(huán)境...
    茶點故事閱讀 41,437評論 3 331
  • 文/蒙蒙 一辉川、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧拴测,春花似錦乓旗、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至务荆,卻和暖如春妆距,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背函匕。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評論 1 271
  • 我被黑心中介騙來泰國打工娱据, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人盅惜。 一個月前我還...
    沈念sama閱讀 48,308評論 3 372
  • 正文 我出身青樓中剩,卻偏偏與公主長得像忌穿,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子结啼,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,037評論 2 355

推薦閱讀更多精彩內(nèi)容