Golang微服務(wù)框架居然可以開發(fā)單體應(yīng)用?—— Kratos單體架構(gòu)實(shí)踐
TL;DR
微服務(wù)框架也是可以用于開發(fā)單體架構(gòu)(monolith architecture)的應(yīng)用。并且,單體應(yīng)用也是最小的、最原始的、最初的項(xiàng)目狀態(tài),經(jīng)過漸進(jìn)式的開發(fā)演進(jìn),單體應(yīng)用能夠逐步的演變成微服務(wù)架構(gòu),并且不斷的細(xì)分服務(wù)粒度。微服務(wù)框架開發(fā)的單體架構(gòu)應(yīng)用,既然是一個(gè)最小化的實(shí)施,那么它只需要使用到微服務(wù)框架最小的技術(shù),也就意味著它只需要用到微服務(wù)框架最少的知識(shí)點(diǎn),拿它來學(xué)習(xí)微服務(wù)框架是極佳的。
本文將圍繞著一個(gè)我寫的demo項(xiàng)目:kratos-monolithic-demo開展,它既是一個(gè)微服務(wù)框架Kratos的最小化實(shí)踐,也是一個(gè)工程化實(shí)踐的完全體。從中你可以學(xué)習(xí)到:
- 構(gòu)建工具M(jìn)ake的使用;
- 依賴注入框架Wire的使用;
- Protobuf構(gòu)建工具Buf的使用;
- ORM框架Ent的使用;
- OpenAPI在項(xiàng)目開發(fā)中的應(yīng)用;
- 完整的CURD開發(fā)示例;
- 用戶登陸認(rèn)證。
為什么要學(xué)要用微服務(wù)框架?
我向身邊的人推廣微服務(wù)架構(gòu),但是經(jīng)常會(huì)得到否定的態(tài)度,譬如:
- 我沒有那么多在線人數(shù),那么大的項(xiàng)目規(guī)模,我不需要微服務(wù);
- 我用GIN就可以一把擼出來了,用什么微服務(wù)框架?
- 微服務(wù)框架太復(fù)雜了,學(xué)不來。
……
總結(jié)下來,無非就是:
- 微服務(wù)知識(shí)面太廣,上手太難,學(xué)習(xí)曲線太陡峭。
- 中小型項(xiàng)目,用不到微服務(wù)框架。
的確,微服務(wù)所需要的知識(shí)是挺多的:服務(wù)治理(服務(wù)注冊(cè)和發(fā)現(xiàn))、負(fù)載均衡、服務(wù)熔斷、服務(wù)降級(jí)、服務(wù)限流、服務(wù)容錯(cuò)、服務(wù)網(wǎng)關(guān)、分布式配置、鏈路追蹤、服務(wù)性能監(jiān)控、RPC服務(wù)調(diào)用……
這么多知識(shí)點(diǎn),上手的確是不容易,對(duì)于很多中小型企業(yè)來說,他們的項(xiàng)目規(guī)模小,大多的項(xiàng)目都是CURD項(xiàng)目,這種項(xiàng)目,開發(fā)者只需要知道怎么寫HTTP路由,怎么寫ORM,就行了,就可以上手做事情了。甚至于大部分代碼都可以通過代碼生成器來生成。要找到會(huì)這么多的人才,一個(gè)人員難以招聘,一個(gè)公司的資本也有限,需要控制成本,請(qǐng)不起。
那么,現(xiàn)在的情況看起來就很明顯了:中小型企業(yè),中小型項(xiàng)目,看起來確實(shí)是不需要微服務(wù)。
但,微服務(wù)框架也是用不到,不需要嗎?
答案是否定的。
在實(shí)際的項(xiàng)目開發(fā)中,我有使用微服務(wù)框架Kratos開發(fā)過好幾個(gè)單體架構(gòu)的應(yīng)用,并且上線運(yùn)營(yíng)。在最小的一個(gè)項(xiàng)目里面,我也就是用到了:REST服務(wù),ORM訪問數(shù)據(jù)庫。涉及的知識(shí)點(diǎn)并不多,因此開發(fā)起來,也并沒有復(fù)雜到哪里去。
那么,有人肯定會(huì)問我:那你用微服務(wù)框架的意義在哪里?
我的考量如下:
- 小項(xiàng)目不是我們的全部,我們也有中大型的項(xiàng)目,公司能夠統(tǒng)一用一套技術(shù)棧,總是要好過于用多個(gè)技術(shù)棧。
- Kratos工程化做得比較好,比較好規(guī)范公司的開發(fā)。
- Kratos基于Protobuf定義協(xié)議,gRPC進(jìn)行服務(wù)間通訊,在公司的強(qiáng)異構(gòu)開發(fā)場(chǎng)景下,具有很強(qiáng)的實(shí)用價(jià)值。
- Kratos基于插件機(jī)制開發(fā),極其容易對(duì)其進(jìn)行擴(kuò)展(看我的kratos-transport,我甚至插入了Gin、FastHttp、Hertz等Web框架)。
綜上,是我的理由。在做技術(shù)選型的時(shí)候,我是橫向?qū)Ρ攘耸忻嫔蠋缀跛械目蚣?,最終選擇了Kratos。
還有一點(diǎn)就是,微服務(wù)的開發(fā)過程,并不是一步到位的——微服務(wù)的開發(fā)是漸進(jìn)的,正所謂:一生二,二生三,三生萬物——從單體應(yīng)用開始逐步的拆分服務(wù)也并不是一件很稀奇的事情。
Demo代碼倉庫
代碼在前,適合那些不喜歡看啰嗦的同學(xué)。
對(duì)于那些想學(xué)習(xí)使用微服務(wù)框架的同學(xué),這一個(gè)微服務(wù)框架開發(fā)的單體項(xiàng)目,它本質(zhì)上是一個(gè)最小化的項(xiàng)目,故而,它也是極為適合拿來學(xué)習(xí)之用的項(xiàng)目。
對(duì)我而言,它是一個(gè)工程化實(shí)驗(yàn)的實(shí)驗(yàn)田,我主要拿它實(shí)驗(yàn)軟件工程的幾個(gè)基本形式:
- 標(biāo)準(zhǔn)化
- 模塊化
- 過程化
- 實(shí)用化和工具化。
項(xiàng)目結(jié)構(gòu)
本項(xiàng)目包含了前端和后端的代碼,前端是一個(gè)Vue3+TypeScript的Admin。但,前端不是本文的著重點(diǎn),本文著重講解后端。
前端項(xiàng)目在frontend文件夾中,后端項(xiàng)目在backend文件夾中,
后端項(xiàng)目結(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)用提供的輸入輸出點(diǎn)(創(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查詢的存放路徑
前置知識(shí)
安裝環(huán)境
安裝Make
Linux、Mac下面基本上都是預(yù)裝,就算不是預(yù)裝,要安裝也很簡(jiǎn)單,不再贅述。主要是Windows下面比較麻煩,我有一篇文章說這個(gè):怎么樣在Windows下使用Make編譯Golang程序。
protoc安裝
macOS安裝
brew install protobuf
Ubuntu安裝
sudo apt update; sudo apt upgrade
sudo apt install libprotobuf-dev protobuf-compiler
Windows安裝
在Windows下可以使用包管理器Choco和Scoop來安裝。
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
或者在后端項(xiàng)目根目錄backend下執(zhí)行:
make init
安裝IDE插件
在IDE里面(VSC和Goland),遠(yuǎn)程的proto源碼庫會(huì)被拉取到本地的緩存文件夾里面,而這IDE并不知道,故而無法解析到依賴到的proto文件,但是,Buf官方提供了插件,可以幫助IDE讀取并解析proto文件,并且自帶Lint。
- VSC的Buf插件: https://marketplace.visualstudio.com/items?itemName=bufbuild.vscode-buf
- Goland的Buf插件:https://plugins.jetbrains.com/plugin/19147-buf-for-protocol-buffers
Wire的使用
Wire是谷歌開源的一個(gè)依賴注入的框架。
依賴注入的作用是:
- 創(chuàng)建對(duì)象
- 知道哪些類需要那些對(duì)象
- 并提供所有這些對(duì)象
首先從注入源看起,在server、service、data這幾個(gè)包下面都存在一個(gè):
var ProviderSet = wire.NewSet(...)
NewSet方法里面都是對(duì)象的創(chuàng)建方法。
wire的代碼文件有兩個(gè):wire.go和wire_gen.go,存放在main.go同級(jí)文件夾下。
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))
}
這個(gè)文件不參與編譯,是提供給代碼生成器用的模板,它把ProviderSet中的依賴項(xiàng)引入進(jìn)來,由代碼生成器進(jìn)行組裝。
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
}
這個(gè)文件是由Wire的代碼生成器生成而成。從代碼可見,復(fù)雜的依賴調(diào)用關(guān)系被Wire輕松的理順了。
代碼生成
wire的代碼生成有兩種途徑,一個(gè)是安裝wire可執(zhí)行程序,一個(gè)是使用go run動(dòng)態(tài)編譯執(zhí)行。推薦動(dòng)態(tài)編譯執(zhí)行,為什么呢?這樣可以保證代碼生成器的版本和項(xiàng)目中wire的版本是一致的,如果版本不一致,可能會(huì)帶來一些問題。
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ì)上是一個(gè)調(diào)用protoc的工具,它可以把調(diào)用protoc的各種參數(shù)配置化,并且支持遠(yuǎn)程proto,遠(yuǎn)程插件。所以,Buf能夠把proto的編譯工程化。
它總共有3組配置文件:buf.work.yaml、buf.gen.yaml、buf.yaml。
另外,還有一個(gè)buf.lock文件,但是它不需要進(jìn)行人工配置,它是由buf mod update命令所生成。這跟前端的npm、yarn等的lock文件差不多,golang的go.sum也差不多。
它的配置文件不多,也不復(fù)雜,維護(hù)起來非常方便,支持遠(yuǎn)程proto插件,支持遠(yuǎn)程第三方proto。對(duì)構(gòu)建系統(tǒng)Bazel支持很好,對(duì)CI/CD系統(tǒng)也支持得很好。它還有很多優(yōu)秀的特性。
buf.work.yaml
它一般放在項(xiàng)目的根目錄下面,它代表的是一個(gè)工作區(qū),通常一個(gè)項(xiàng)目也就一個(gè)該配置文件。
該配置文件最重要的就是directories配置項(xiàng),列出了要包含在工作區(qū)中的模塊的目錄。目錄路徑必須相對(duì)于buf.work.yaml,像../external就是一個(gè)無效的配置。
version: v1
directories:
- api
buf.gen.yaml
它一般放在buf.work.yaml的同級(jí)目錄下面,它主要是定義一些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 # 使用相對(duì)路徑
# 使用go-grpc插件生成gRPC服務(wù)代碼
#- plugin: buf.build/grpc/go
- name: go-grpc
out: gen/api/go
opt:
- paths=source_relative # 使用相對(duì)路徑
# generate rest service code
- name: go-http
out: gen/api/go
opt:
- paths=source_relative # 使用相對(duì)路徑
# generate kratos errors code
- name: go-errors
out: gen/api/go
opt:
- paths=source_relative # 使用相對(duì)路徑
# generate message validator code
#- plugin: buf.build/bufbuild/validate-go
- name: validate
out: gen/api/go
opt:
- paths=source_relative # 使用相對(duì)路徑
- lang=go
buf.yaml
它放置的路徑,你可以視之為protoc的--proto-path參數(shù)指向的路徑,也就是proto文件里面import的相對(duì)路徑。
需要注意的是,buf.work.yaml的同級(jí)目錄必須要放一個(gè)該配置文件。
該配置文件的內(nèi)容通常來說都是下面這個(gè)配置,不需要做任何修改,需要修改的情況不多。
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代碼生成
我們可以使用以下命令來進(jìn)行代碼生成:
buf generate
或者
make api
Ent的使用
Ent是一個(gè)優(yōu)秀的ORM框架?;谀0暹M(jìn)行代碼生成,相比較利用反射等方式,在性能上的損耗更少。并且,模板的使用使得擴(kuò)展系統(tǒng)變得簡(jiǎn)單容易。
它不僅能夠很對(duì)傳統(tǒng)的關(guān)系數(shù)據(jù)庫(MySQL、PostgreSQL、SQLite)方便的進(jìn)行查詢,并且可以容易的進(jìn)行圖遍歷——常用的譬如像是:菜單樹、組織樹……這種數(shù)據(jù)查詢。
Schema
Schema相當(dāng)于數(shù)據(jù)庫的表。
《道德經(jīng)》說:
道生一,一生二,二生三,三生萬物。
Schema,就是數(shù)據(jù)庫開發(fā)的起始點(diǎn)。
只有定義了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)建一個(gè)Schema
創(chuàng)建Schema有兩個(gè)方法可以做到:
使用 ent init 創(chuàng)建
ent init User
將會(huì)在 {當(dāng)前目錄}/ent/schema/ 下生成一個(gè)user.go文件,如果沒有文件夾,則會(huì)創(chuàng)建一個(gè):
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)上有人好心的制作了一個(gè)在線工具,可以將SQL轉(zhuǎn)換成schema代碼,實(shí)際應(yīng)用中,這是非常方便的!
SQL轉(zhuǎn)Schema工具: https://printlove.cn/tools/sql2ent
比如,我們有一個(gè)創(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ù)用字段
在實(shí)際應(yīng)用中,我們經(jīng)常會(huì)碰到一些一模一樣的通用字段,比如:id、created_at、updated_at等等。
那么,我們就只能一直的復(fù)制粘貼?這會(huì)使得代碼既臃腫,又顯得很不優(yōu)雅。
entgo能夠讓我們復(fù)用這些字段嗎?
答案顯然是,沒問題。
Mixin,就是辦這個(gè)事兒的。
好,我們現(xiàn)在需要復(fù)用時(shí)間相關(guān)的字段:created_at和updated_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)樗砑右粋€(gè)Mixin方法:
func (User) Mixin() []ent.Mixin {
return []ent.Mixin{
mixin.TimeMixin{},
}
}
生成代碼再看,user表就擁有這2個(gè)字段了。
生成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ū)動(dòng)sql.DB連接
有以下兩種途徑可以達(dá)成:
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
}
在實(shí)際應(yīng)用中,使用自定義的方法會(huì)更好,有兩個(gè)原因:
- 可以定制數(shù)據(jù)庫連接,比如使用連接池;
- 如果查詢語句太過于復(fù)雜,可以直接使用驅(qū)動(dòng)寫SQL語句進(jìn)行查詢。
OpenAPI的使用
Kratos官方本來是有一個(gè)swagger-api的項(xiàng)目的(現(xiàn)在已經(jīng)被歸檔了),集成的是OpenAPI v2的Swagger UI。這個(gè)項(xiàng)目呢,不好使,我在應(yīng)用中,經(jīng)常會(huì)讀不出來OpenAPI的文檔。還有就是OpenAPI v2不如v3功能強(qiáng)大。
因?yàn)闆]有支持,而我又需要跟前端進(jìn)行溝通,所以我只好生成出OpenAPI文檔之后,自行導(dǎo)入到ApiFox里面去使用,ApiFox呢,挺好的,支持文件和在線兩種方式導(dǎo)入,文檔管理,接口測(cè)試的功能也都很強(qiáng)大。但是總是要去費(fèi)神導(dǎo)出文檔,這很讓人抗拒——在開發(fā)的初期,接口變動(dòng)是很高頻的行為——難道就不能夠全自動(dòng)嗎?程序只要一發(fā)布,接口就自動(dòng)的跟隨程序一起發(fā)布出去了。
對(duì),說的就是集成Swagger UI。
為了做到這件事,并且工程化,需要做這么幾件事情:
- 編寫
Buf配置進(jìn)行OpenAPI文檔的生成; - 把Buf生成OpenAPI文檔的命令寫進(jìn)
MakeFile里面; - 利用golang的
Embedding Files特性,把openapi.yaml嵌入到BFF服務(wù)程序里面; - 集成
Swagger UI到項(xiàng)目,并且讀取內(nèi)嵌的openapi.yaml文檔。
1. 編寫Buf配置進(jìn)行OpenAPI文檔的生成
細(xì)心的你肯定早就發(fā)現(xiàn)了在api/admin/service/v1下面有一個(gè)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文件傳遞名稱。默認(rèn)為:json
- depth=2 # 循環(huán)消息的遞歸深度,默認(rèn)為:2
- default_response=false # 添加默認(rèn)響應(yīng)消息。如果為“true”,則自動(dòng)為使用google.rpc.Status消息的操作添加默認(rèn)響應(yīng)。如果您使用envoy或grpc-gateway進(jìn)行轉(zhuǎn)碼,則非常有用,因?yàn)樗鼈兪褂么祟愋妥鳛槟J(rèn)錯(cuò)誤響應(yīng)。默認(rèn)為:true。
- enum_type=string # 枚舉類型的序列化的類型。使用"string"則進(jìn)行基于字符串的序列化。默認(rèn)為:integer。
- output_mode=merged # 輸出文件生成模式。默認(rèn)情況下,只有一個(gè)openapi.yaml文件會(huì)生成在輸出文件夾。使用“source_relative”則會(huì)為每一個(gè)'[inputfile].proto'文件單獨(dú)生成一個(gè)“[inputfile].openapi.yaml”文件。默認(rèn)為:merged。
- fq_schema_naming=false # Schema的命名是否加上包名,為true,則會(huì)加上包名,例如:system.service.v1.ListDictDetailResponse,否則為:ListDictDetailResponse。默認(rèn)為:false。
這個(gè)配置文件是為了生成OpenAPI v3文檔而編寫的。
我之前嘗試了把生成OpenAPI的配置放在根目錄下的buf.gen.yaml,但是這產(chǎn)生了一個(gè)問題,因?yàn)槲乙粋€(gè)項(xiàng)目里面會(huì)有多個(gè)BFF服務(wù)程序,我不可能一股腦全部輸出到一個(gè)openapi.yaml里面。雖然,代碼生成器也可以為每一個(gè)proto各自生成一個(gè)[inputfile].openapi.yaml,但是,這樣顯得太亂了,而且,我沒有辦法用。所以,沒轍,只能單獨(dú)對(duì)待了——每個(gè)BFF服務(wù)獨(dú)立生成一個(gè)文檔。
那么,怎么使用這個(gè)配置文件呢?還是使用buf generate命令,該命令還是需要在項(xiàng)目根目錄下執(zhí)行,但是得帶--template參數(shù)去引入buf.openapi.gen.yaml這個(gè)配置文件:
buf generate --path api/admin/service/v1 --template api/admin/service/v1/buf.openapi.gen.yaml
最終,在./app/admin/service/cmd/server/assets這個(gè)目錄下面,將會(huì)生成出來一個(gè)文件名為openapi.yaml的文件。
2. 把Buf生成OpenAPI文檔的命令寫進(jìn)MakeFile里面
這么長(zhǎng)的命令,顯然寫入到Makefile會(huì)更加好用。
那么,我們開始編寫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ì)上的差別:它其實(shí)是一個(gè)文檔,而非配置。
以前寫VC的時(shí)候,一些資源是可以內(nèi)嵌到EXE的二進(jìn)制程序里面去的。Go也可以做到,就是使用Embedding Files的特性。
文檔,跟隨二進(jìn)制程序走,在我看來,才是最優(yōu)解。下面我們就開始實(shí)現(xiàn)文檔的內(nèi)嵌。
現(xiàn)在,我們來到./app/admin/service/cmd/server/assets這個(gè)目錄下面,我們?cè)谶@個(gè)目錄下面創(chuàng)建一個(gè)名為assets.go的代碼文件:
package assets
import _ "embed"
//go:embed openapi.yaml
var OpenApiData []byte
利用go:embed注解引入openapi.yaml文檔,并且讀取成一個(gè)類型為[]byte名為OpenApiData的全局變量。
就這樣,我們就把openapi.yaml內(nèi)嵌進(jìn)程序了。
4. 集成Swagger UI到項(xiàng)目,并且讀取內(nèi)嵌的openapi.yaml文檔
最后,我們就可以著手集成Swagger UI了。
我為了集成Swagger UI,把Swagger UI封裝了一個(gè)軟件包,要使用它,我們需要安裝依賴庫:
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:
同時(shí),openapi.yaml文件也可以在線訪問到:
http://localhost:8080/docs/openapi.yaml
完整的CURD開發(fā)示例
Kratos的官方示例的結(jié)構(gòu)是:data、biz、service、server,我簡(jiǎn)化掉了,我把biz給摘除掉了。
我們以用戶UserService為例。
Data
所有對(duì)ORM的調(diào)用,對(duì)數(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ā)生錯(cuò)誤[%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
}
增刪改,這些都沒有什么特別的。
列表查詢,有點(diǎn)特別,需要特別的說明一下,我提取了一個(gè)通用的分頁請(qǐng)求:
| 字段名 | 類型 | 格式 | 字段描述 | 示例 | 備注 |
|---|---|---|---|---|---|
| page | number |
當(dāng)前頁碼 | 默認(rèn)為1,最小值為1。 |
||
| pageSize | number |
每頁的行數(shù) | 默認(rèn)為10,最小值為1。 |
||
| query | string |
json object 或 json object array
|
AND過濾條件 | json字符串: {"field1":"val1","field2":"val2"} 或者[{"field1":"val1"},{"field1":"val2"},{"field2":"val2"}]
|
map和array都支持,當(dāng)需要同字段名,不同值的情況下,請(qǐng)使用array。具體規(guī)則請(qǐng)見:過濾規(guī)則
|
| or | string |
json object 或 json object array
|
OR過濾條件 | 同 AND過濾條件 | |
| orderBy | string |
json string array |
排序條件 | json字符串:["-create_time", "type"]
|
json的string array,字段名前加-是為降序,不加為升序。具體規(guī)則請(qǐng)見:排序規(guī)則
|
| nopaging | boolean |
是否不分頁 | 此字段為true時(shí),page、pageSize字段的傳入將無效用。 |
||
| fieldMask | string |
json string array |
字段掩碼 | 此字段是SELECT條件,為空的時(shí)候是為*。 |
Service
這一層主要是處理REST的請(qǐng)求和返回信息。
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] 用戶認(rèn)證失敗[%s]", authInfo, err.Error())
return nil, adminV1.ErrorAccessForbidden("用戶認(rèn)證失敗")
}
if req.User == nil {
return nil, adminV1.ErrorBadRequest("錯(cuò)誤的參數(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] 用戶認(rèn)證失敗[%s]", authInfo, err.Error())
return nil, adminV1.ErrorAccessForbidden("用戶認(rèn)證失敗")
}
if req.User == nil {
return nil, adminV1.ErrorBadRequest("錯(cuò)誤的參數(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] 用戶認(rèn)證失敗[%s]", authInfo, err.Error())
return nil, adminV1.ErrorAccessForbidden("用戶認(rèn)證失敗")
}
req.OperatorId = authInfo.UserId
_, err = s.uc.Delete(ctx, req)
return &emptypb.Empty{}, err
}
Server
在這一層創(chuàng)建REST服務(wù)器,Service的服務(wù)也在這里注冊(cè)進(jìn)去。
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
}
用戶登陸認(rèn)證
登陸的協(xié)議使用OAuth 2.0的密碼授權(quán)(Password Grant)方式,協(xié)議proto定義如下:
syntax = "proto3";
package admin.service.v1;
// 用戶后臺(tái)登陸認(rèn)證服務(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: "*"
};
}
// 刷新認(rèn)證令牌
rpc RefreshToken (RefreshTokenRequest) returns (LoginResponse) {
option (google.api.http) = {
post: "/admin/v1/refresh_token"
body: "*"
};
}
}
// 用戶后臺(tái)登陸 - 請(qǐng)求
message LoginRequest {
string username = 1; // 用戶名,必選項(xiàng)。
string password = 2; // 用戶的密碼,必選項(xiàng)。
string grand_type = 3; // 授權(quán)類型,此處的值固定為"password",必選項(xiàng)。
optional string scope = 4; // 以空格分隔的范圍列表。如果未提供,scope則授權(quán)任何范圍,默認(rèn)為空列表。
}
// 用戶后臺(tái)登陸 - 回應(yīng)
message LoginResponse {
string access_token = 1; // 訪問令牌,必選項(xiàng)。
string refresh_token = 2; // 更新令牌,用來獲取下一次的訪問令牌,可選項(xiàng)。
string token_type = 3; // 令牌類型,該值大小寫不敏感,必選項(xiàng),可以是bearer類型或mac類型。
int64 expires_in = 4; // 過期時(shí)間,單位為秒。如果省略該參數(shù),必須其他方式設(shè)置過期時(shí)間。
}
// 用戶刷新令牌 - 請(qǐng)求
message RefreshTokenRequest {
string refresh_token = 1; // 更新令牌,用來獲取下一次的訪問令牌,必選項(xiàng)。
string grand_type = 2; // 授權(quán)類型,此處的值固定為"password",必選項(xiàng)。
optional string scope = 3; // 以空格分隔的范圍列表。如果未提供,scope則授權(quán)任何范圍,默認(rèn)為空列表。
}
使用標(biāo)準(zhǔn)化的OAuth 2.0協(xié)議,有一個(gè)好處就是,別的系統(tǒng)可以無縫對(duì)接用戶登陸認(rèn)證。
登陸的令牌,我們使用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令牌的生成和驗(yàn)證的具體算法,我都已經(jīng)封裝在了github.com/tx7do/kratos-authn軟件包里面。
JWT令牌的驗(yàn)證,以中間件的方式提供:
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令牌的驗(yàn)證,無法通過驗(yàn)證的請(qǐng)求,都將無法訪問該接口。
結(jié)語
當(dāng)你學(xué)習(xí)到了這些知識(shí)點(diǎn)之后,你會(huì)發(fā)現(xiàn)上手使用Kratos微服務(wù)框架所涉及的知識(shí)點(diǎn)也并不繁雜,學(xué)習(xí)的門檻還是很低的。基于本文中的demo項(xiàng)目,我相信你可以很快的上手寫項(xiàng)目了。