原始文章: https://medium.com/@prithuadhikary/go-microservices-using-gin-and-gorm-72938b3b56b4 此处是译文,主要针对初学者,没有接触过go的同学,手把手的教学用go写微服务。
![]()
简洁的web框架:Gin #
Gin是一个多功能的web框架,使用go语言开发。框架对HTTP请求和响应做了良好的封装,提供简单可用的函数,可以将表单、Json以及各类参数转化为数据结构(struct),并且还提供了参数的校验功能(validation) 。这些函数可以轻松的将接口渲染成 JSON 或者 XML返回,或者是渲染为模板驱动的HTML响应。
Gin还提供了编写中间件的方案(middlewares),举例来说是一些涉及到切面的问题,比如使用JWT进行身份校验、population of the request context with parsed claims1、按照当今分布式trace工具的要求,在header中增加span等
本篇文章中,我们会讲到这些问题
- 使用Gin创建一个微服务
- 在微服务中,使用GORM(Golang Object Relational Mapping)针对Postgres数据库进行开发
User 微服务 #
接下来的演示案例,是编写一个用户登录服务,提供REST风格的接口。功能如下:
- 接口会接收一个完整的JSON结构,通过http的payload发送
- 并且进行校验(validate)
- 使用GORM结构转化,并且实现持久化。数据库则选用postgres。
- 进行异常处理(error handling)
- 进行服务的身份验证(authentication)。
初始化服务引入Gin和GORM #
一行代码足矣
mkdir user-service && \
cd user-service && \
go mod init github.com/prithuadhikary/user-service
命令会在 user-service 这个文件夹中,创建和初始化服务所需的代码,在这个文件夹下,需要有一个 go.mod 文件。
执行下面的命令,引入框架
go get -u github.com/gin-gonic/gin
go get -u gorm.io/gorm
为了连接数据库,需要postgres的连接器
go get -u gorm.io/driver/postgres
依赖已经都搞定了
初始化GORM #
在编写持久层之前,需要初始化GROM。包括下面的步骤
- 定义对象模型(domain classes),并且需要通过属性和tag让GROM知道数据库结构(schema)
- 连接数据库
- 自动同步结构以及对象(automigration)举例,让GORM创建需要的表、主外键关系等。
分层架构 #
传统的企业应用中,分层设计是一个通用的最佳实践,即,将系统逻辑打散,编排到三个维护性较好的 “层(The Layer)” 中,这三层分别是
- Controller,控制层,承担request(大部分是HTTP)数据的接收,解析请求体,参数,Header,Path等参数。创建service层的对象,调用实现功能,得到结果后,将结果写入到response中去。
- Service,服务层,主要封装业务逻辑,和Repository交互。
- Repository,持久层,关注细节,和domain对象打交道,执行数据库保存等。
接下来开始逐步构建,从最底部的Repository层开始。
定义User 领域(domain)—— User Struct Type #
Repository层一般操作 domain 的实体。这些实体其实是数据库的表结构在服务中的映射。
示例从简,创建一个 domain文件夹,编写user.go
package domain
import "gorm.io/gorm"
type User struct {
gorm.Model
ID uuid.UUID `gorm:"type:uuid;primarykey"`
Username string
Password string
Role string
}
上面的domain中,有4个属性,没有疑问,还有一个 gorm.Model。这里需要额外解释下:
由于go中没有继承(inheritance)机制,需要用组合(composition)实现多态,下面讲一下如何用组合实现类似继承的能力。 比如有两个类型如下:
type Vodka struct {
AlcoholPercent float32
}
func (vodka Vodka) Hangover() {
fmt.Println("Tasteless Hangover!")
}
type BloodyMary struct {
Vodka
Ingredients []string
}
接下来定一个BloodyMary的实例,
var myPeg BloodyMary
就可以调用Vodka的方法了
myPeg.AlcoholPercent
go语言中,这个功能叫类型嵌入(Type Embedding),其实,也可以这么调用
myPeg.Vodka.AlcoholPercent
相当于是将 Vodka 嵌入到了 BloodMary中👻。之所以可以直接调用,是因为go语言中,对于嵌入的类型,会将其属性和方法都提升(promoted)到主类型中。myPeg.Hangover()也可以直接调用。
但是,属性的提升仅限于不覆盖(shadowed)的场景。
如果主类型中也有同名的方法,则会覆盖嵌入类型中的。比如在BloodMary中也定定义了Hangover 方法如下:
func (bloodyMary BloodyMary) Hangover() {
fmt.Println("Hangover yet tasty!")
}
调用类型中的同名方法,会覆盖嵌入类型中的,优先级比较高。
var myPeg BloodyMary
myPeg.Hangover()
// output:Hangover yet tasty!
myPeg.Vodka.Hangover()
// output: Tasteless Hangover!
切回正题,现在,对于类中的 gorm.Model 应该就不会陌生了,这个Model中包含了一些通常的属性,如下所示
// Model a basic GoLang struct which includes the following fields: // ID, CreatedAt, UpdatedAt, DeletedAt
// It may be embedded into your model or you may build your own
// model without it
// type User struct {
// gorm.Model
// }
type Model struct {
ID uint `gorm:"primarykey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt DeletedAt `gorm:"index"`
}
在上面的模型中,已经有了ID。在User 结构中,你可能会注意到,又定义了ID属性😉。
package domain
import "gorm.io/gorm"
type User struct {
gorm.Model
ID uuid.UUID `gorm:"type:uuid;primarykey"`
Username string
Password string
Role string
}
这个定义,目标就是将 gorm.Model 中的数字类型 ID 进行覆盖,用UUID进行替代。数字用的主键,通常满足某种自增序列,容易被钻空子,比如你可以算出来下一个。UUID至少可以在一张表中保证唯一。
重要提示: 假如你的程序在全世界各处运行,但是,你依然在单表中,遇到了UUID碰撞冲突,那么只有两种可能
- 上帝让你下地狱
- Morpheus找到了你,准备让你离开 Matrix。你一定立刻马上选择红药片🤪
适才相戏耳。在HTTP场景中,最好使用UUID,因为随机性更好,安全性也更好。尤其是你准备把主键放到HTTPGet方法的Path上去的时候。 如果你用了数字主键,那么我拿出这个请求,阁下又该如何应对。
DELETE /credit-card/123
我相信自己说明白了,继续向下,来到初始化DB链接的环节。
连接数据库 #
这部分代码比较简单,也比较符合语言习惯。下面是主要函数,然后返回了一个用来和DB交互的指针gorm.DB
代码链接
func InitialiseDB(dbConfig *DbConfig) (*gorm.DB, error) {
dsn := fmt.Sprintf("host=%v user=%v password=%v dbname=%v port=%v sslmode=require TimeZone=Asia/Kolkata", dbConfig.Host, dbConfig.User, dbConfig.Password, dbConfig.DbName, dbConfig.Port)
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer
logger.Config{
SlowThreshold: time.Second, // Slow SQL threshold
LogLevel: logger.Info, // Log level
IgnoreRecordNotFoundError: true, // Ignore ErrRecordNotFound error for logger
Colorful: true, // Disable color
},
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: newLogger,
NamingStrategy: schema.NamingStrategy{
TablePrefix: dbConfig.Schema + ".",
SingularTable: false,
},
})
if err != nil {
return nil, err
}
return db, err
}
type DbConfig struct {
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
DbName string `mapstructure:"dbName"`
Host string `mapstructure:"host"`
Port string `mapstructure:"port"`
Schema string `mapstructure:"schema"`
}
优秀的代码本身就是最好的注释。但是我还是解释一下
- dsn 是链接数据库的描述信息串,包含数据的连接四要素
- 将dsn传递给gorm的driver,
postgres.Open(dsn),可以拿到 gorm的 Dialector(方言)指针 - 通过
*gorm.Config然后通过GORM提供的接口,可以拿到*gorm.DB,大功告成。
注意,mapstructure 这个tag,是因为我用了viper去加载了属性,这个阶段可以不用关注,以免带来不必要的干扰。
最后,开启了GORM的 log,可以打印日志,通过NamingStrategy 确认开启的表结构。
可以在main方法中初始化DB,如下
db, err := **InitialiseDB**(&DbConfig{
User: "postgres",
Password: "password",
DbName: "groot",
Host: "localhost",
Port: "5432",
Schema: "users",
})
if err != nil {
panic(err)
}err = **db.AutoMigrate(&domain.User{})**
if err != nil {
panic(err)
}
db.AutoMigrate(&domain.User{}) 这句会让GORM 分析当前表结构,并且判定是否符合当前表定义,不符合的话,会根据当前代码结构自动变更db结构,失败则会抛出异常,进而引发panic。
还有一点值得注意,这个migrate尽量不会引起数据的丢失,不会执行任何drop命令,当前表已经不用的列,也不会删除。
现在可以用gorm.DB指针了,就是代码中的 db变量。现在可以创建持久层了。
The UserRepository 持久层 #
首先需要定一个接口,对User进行持久化操作。在repository 文件夹下,创建一个go文件,user.repository.go
package repository
import "github.com/prithuadhikary/user-service/domain"
type UserRepository interface {
Save(user *domain.User)
}
简约而不简单。这是一个接口,只定义行为,Save是动作。其中首字母的大写,在go中代表是public的。大写开头的属性、方法,在package以外也可以访问到。
接口独木不成林,需要定义对应的实现。在文件中新增如下:
type userRepository struct {
db *gorm.DB
}
这个struct并没有实现接口,需要增加下面的定义才行
func (repository *userRepository) Save(user *domain.User) {
// Implement the logic to save here.
}
在 go 中,实现和接口只是口头协议(契约)的关系。接口的实现并不需要显示的用 implement 进行关联或者用语法定义。UserRepository 是接口,userRepository 实现,在实际的引用中,两个变量可以等同。如下
func NewUserRepository(db *gorm.DB) UserRepository {
var repository UserRepository
repository = &userRepository{
db: db,
}
return repository
}
这是我们定义的第一个 构造(constructor)方法。由于用了小写字母u定义了实现,不对外暴露,因此,为了在包外访问这个实现,因此需要构造方法允许外部访问。构造方法会返回userRepository的指针,让调用方作为 UserRepository访问。
更进一步以前,需要讨论一下方法集(method sets)。与指针不同,按照下面定义,可以获得不带指针的对象
repository = userRepository{
db: db,
}
但是这样是不能编译的,原因在于,结构并没有实现接口,而指针才能实现接口。
探讨Go语言中的接口设计Receivers And Interfaces Under The Hood — Skippable #
这部分可以跳过。不会影响对于持久层的理解。
回顾一下save方法
func (repository *userRepository) Save(user *domain.User) {
// Implement the logic to save here.
}
*userRepoitory 称为接收器(receiver)。接收器用来承担外部的调用。可以将其理解为是方法的第一个参数。receiver 可以有两种,类型本身和类型的引用(前者不带*)。下面的例子就是直接将类型本身作为receiver。
func (repository userRepository) Save(user *domain.User) {
// Implement the logic to save here.
}
至此,repository 在方法内部是可见的,相当于第一个入参。
传递type引用还是传递type本身,都可以。Save方法中,value receiver 是一个全新的副本,pointer receiver则是将自己完全传递。对于后者,任何改动都会在外部生效。对于前者,所有属性,包括指针都会赋值。
func printPointerAddress(aPtr *int32) {
println("Address of pointer aPtr", &aPtr)
}
And then in main,
a := int32(10)
ptr := &a
println("Addres of pointer ptr", &ptr)
printPointerAddress(ptr)
输出如下
Addres of pointer ptr 0xc000084db8
Address of pointer aPtr 0xc000084dd0
ptr 和 aPtr 。ptr是一个指针,同时也是一个变量。当通过printPointerAddress 传递指针的时候,这个指针会被复制为aPtr,注意,aPtr 也是一个指针,指向上一个变量ptr。aPtr则是我们获得的,一个指向原始内存地址的句柄(handler),对于这个handler可以进行写操作、取消引用的操作。
但是你需要知道,这绝不是真正的按引用传递。😁 Because even a pointer to pointer to pointer will also get copied. It is by the virtue of the nature of pointers, it is a pass by reference.
回到定义
func (repository userRepository) Save(user *domain.User) {
// Implement the logic to save here.
}
还有一个隐含的问题,这两个定义在代码上都是合法的,可以编译通过,但是含义不同。
var repository UserRepository
//This is implied.
repository = userRepository{
db: db,
}
//This is also valid.
repository = &userRepository{
db: db,
}
如何选取,一些经验可以听一下
- pointer reveiver:这个pointer只能赋值给接口的变量
- value reveiver:可以同时赋值给接口的变量、实现类的变量
可以简化记忆一下:接口变量存储的是reveiver,如果receiver是一个指针,那么接口存储的一定是指针receiver。如果receiver是一个value receiver的话,接口可以像是存储地址一样存储自身,这就是方法集(method sets)的含义。 如果一个变量的类型是interface,可以同时存储指针类型的赋值,或者值类型的赋值,区别如下:
- 指针类型赋值:只包含接口的方法
- 值类型赋值:包含接口和实现的方法。
方法集:在Go中,方法集定义了一个类型必须实现的所有方法,以满足某个接口的要求。
- 如果类型
T的方法使用的是值接收者,那么这些方法属于T和*T的方法集。 - 但如果方法使用的是指针接收者,那么这些方法只属于
*T的方法集,而不属于T
给定一个指针,可以明确知道指针指向的value,如果拿到的是一个值,其实不能明确的推断出来地址。举例,在go中,可以由用户主动的将基础类型,重定义为用户自定义类型。如下:
type Number interface {
Increment() Number
}
type number int64
func (num *number) Increment() Number {
*num++
return num
}
n := number(5)
// The above declaration does three things:
// 1. Allocate memory
// 2. Give it the name 'n'
// 3. Assign the value number(5) to it.
// 4. Since there exists a named memory location,
// we can take its address by &n (address of n).
nptr := &number(6)
// On the other hand, &number(6) isn't feasible
// even though number(6) could have caused an
// allocation,
// we haven't got a named handle to its location.
// Likewise, &6 is invalid.
在底层,Go语言的接口是通过一个由一个类似pair的数据结构实现的。
- itab:第一个字
itab存储了一个指向Itab结构体(源自C语言世界)的指针,该结构体包含了关于类型的元数据,接着是函数指针(如fun[0],fun[1]等),这些指针指向实现该接口类型的具体类型中的相应函数。 - data:第二个字
data存储了一个指向实现类型所持有的数据的指针。当我们将一个具体类型赋值给接口变量时,底层的数据结构,即data和itab被填充。而当我们通过接口变量(例如iface)调用一个方法时,Go会执行类似以下操作的步骤。
Depending upon the kind of receiver fun[0] has,
iface.itab->fun[0](iface.data,...additional parameters)
or
iface.itab->fun[0](*iface.data, ...additional parameters)
记住,fun[0] 的含义是 (*(fun+0))
问题在于,go语言有时候无法推断出来 指向 itab 结构的函数指针是什么,因为没有可以开始的地址。
举例来说,&number(5) 这个表达式,是一种类似Consts和Literal的东西。现代的C语言允许使用复合变量,因此可以定义类似这样子的实例,并且拿到地址。go也可以拿到类似这样字的结构体实例的地址,但是这一套东西,明显和常量和基础类型不一样
深入探讨到止为止,接下来继续看微服务的实现。
实现Save方法 #
很简单,gorm.DB 提供了一个方法Save(interface{}),保存数据,如下
func (repository userRepository) Save(user *domain.User) {
repository.db.Save(user)
}
但是有一个事情很重要,如果我们创建了一个 domain.User,ID字段(定义为16
)的属性就会拿到uuid.Nil(16
的空值)。User中的ID需要一个UUID的值,如果没有设置,需要初始化并且设进去。 GORM Hooks可以用来解决这个问题。GORM可以针对 domain 的对象定义一些hook方法,在某些CRUD操作前后执行。当前,需要在User的create方法之前引入一个hook方法,参考GORM的文档
// begin transaction
BeforeSave
BeforeCreate //********************************这里正合适
// save before associations
// insert into database
// save after associations
AfterCreate
AfterSave
// commit or rollback transaction
完整文档请移步 GORM Hooks 文档
UUID的话,需要安装 Google的 uuid包
go get -u github.com/google/uuid
然后定义一个BeforeCreate 方法,如下:
func (u *User) BeforeCreate(tx *gorm.DB) (err error) {
u.ID = uuid.New()
return
}
还缺一些东西,可以顺便把 Password 字段一起搞定。在DB中存储 password 字段,最好是需要hash(当然加盐hash最好)。在本例中,可以将一个hex使用 sha256 算法进行计算散列值(此处省略密盐)
func (user *User) BeforeCreate(tx *gorm.DB) error {
user.ID = uuid.New()
// Digest and store the hex representation.
digest := sha256.Sum256([]byte(user.Password))
user.Password = hex.EncodeToString(digest[:])
return nil
}
现在开始编写service层,主要的内容是将输入转化为 domain.User,然后调用持久层。
The User Service 服务层 #
现在开始定一些基础的struct,作为Signup方法的依赖。需要建立 model文件夹,然后创建signup-request.go的文件。
package model
type SignupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
PasswordConfirmation string `json:"passwordConfirmation"`
}
其中的jsontags定义了,针对json转化的字段名,Gin也提供了其他的tag,可以针对POST,GET请求以及进行字段的校验(validation)。也支持自定义 检查器(validators)这些会在控制层进行介绍。
接下来定义UserService接口,在service目录下,定义user.service.go文件,内容如下
package service
import "github.com/prithuadhikary/user-service/model"
type UserService interface {
Signup(request *model.SignupRequest) error
}
接下来进行实现,代码如下
type userService struct {
repository repository.UserRepository
}
func (service *userService) Signup(request *model.SignupRequest) error {
if request.Password != request.PasswordConfirmation {
return errors.New("password and confirm password must match")
}
exists := service.repository.ExistsByUsername(request.Username)
if exists {
return errors.New("email already exists")
}
service.repository.Save(&domain.User{
Username: request.Username,
Password: request.Password,
Role: "END_USER",
})
return nil
}
func NewUserService(repository repository.UserRepository) UserService {
return &userService{
repository: repository,
}
}
这段代码也不难理解,
- 比较密码是否相等
- 进一步比较邮箱是否注册过了
- 如果发生异常,则返回error
go语言中,error是一个包含 Error() string的空接口,定义在go的标准库中。一般在middleware使用一些自定义属性的error,会包含额外的属性。
有一个新方法如下:
service.repository.ExistsByUsername(request.Username)
差点忘记了,我放在这里
func (repository *userRepository) ExistsByUsername(username string) bool {
var count int64
repository.db.Model(&domain.User{}).Where("username = ?", username).Count(&count)
return count > 0
}
这里利用GORM的属性,执行了一个数据库的query,注意,query中,Count方法传入了一个int64的指针,然后方法内写入了value。
东扯西扯,写了太多东西了,快速写一下Controller的实现
最终 The User Controller 控制层 #
接口文件如下,在user-service/controller/user.controller.go 中。
package controller
import "github.com/gin-gonic/gin"
type UserController interface {
Signup(ctx *gin.Context)
}
Signup方法中,入参是gin.Context的指针,这个指针的定义如下
type HandlerFunc func(*Context)
``
HanderFunc -> Handler Function!定义了处理来自HTTP请求的方法。这个方法一般会在路由中定义,作为某个路径的callback方法。will get invoked upon receiving an HTTP request at that route.
看下这个方法的实现如下
type userController struct {
service service.UserService
}
func (controller userController) Signup(ctx *gin.Context) {
request := &model.SignupRequest{}
if err := ctx.ShouldBind(request); err != nil {
ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"message": "Validation failed",
})
return
}
err := controller.service.Signup(request)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusNotAcceptable, gin.H{
"message": err.Error(),
})
}
}
func NewUserController(engine *gin.Engine, userService service.UserService) {
controller := &userController{
service: userService,
}
api := engine.Group("api")
{
api.POST("users", controller.Signup)
}
}
方法中,使用了ctx.ShouldBind(request),将request的body绑定在这个对象上model.SignupRequest对象上。
ShouldBind方法:会根据请求中的Content-Type 字段选择合适的反序列化策略,将请求的body进行解析,根据 binding tags 进行参数的校验。 在本地案例中,ShouldBind 发现入参的Content-Type=application/json 的话,会将传入的JSON结构绑定到 model.SignupRequest 结构的那个指针中。 现在,可以增加一些 binding标签了,如下:
快乐的绑定环节来了 SignupRequest 结构
package model
type SignupRequest struct {
Username string `json:"username" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
PasswordConfirmation string `json:"passwordConfirmation" binding:"required"`
}
例子中,json的tag告诉gin,要从json的哪些字段中提取信息,binding的tags会指定了绑定过程中的一些约束,不满足约束,绑定会失败。回到Signup的方法实现中,绑定成功之后,会进入到service层调用的方法。如果绑定失败,则会返回异常。
NewUserController 方法也很直接
- 接收参数,给下游调用。
- 创建路由,创建一个
gin.RouterGroup将/api/users映射到userController.Sigup这个方法上。后者被称为 handler方法。注意,api是一个group,users是一个接口。
优化参数校验结果返回 #
ShouldBind方法返回了 validator.ValidationErrors 的标准实现。通过遍历这个结构,可以拿到包含具体验证失败字段以及异常。如果前端返回的不明不白,类似Validation failed 这样的响应,其实调用者不知道如何改进
看下来自util的一个函数 function:
package util
import (
// ... Skipping
)
type responseErr struct {
Field string `json:"field"`
Condition string `json:"condition"`
}
func RenderBindingErrors(ctx *gin.Context, validationError validator.ValidationErrors) {
var responseErrs []responseErr
for _, fieldError := range validationError {
field := fieldError.Field()
responseErrs = append(responseErrs, responseErr{
Field: strings.ToLower(field[:1]) + field[1:],
Condition: fieldError.ActualTag(),
})
}
ctx.AbortWithStatusJSON(http.StatusBadRequest, responseErrs)
}
这段代码返回了400异常,遍历了异常集合,然后将所有异常的属性都增加在一个结构体内,返回客户端
把这段逻辑增加到controller层中如下:
func (controller userController) Signup(ctx *gin.Context) {
request := &model.SignupRequest{}
if err := ctx.ShouldBind(request); err != nil && errors.As(err, &validator.ValidationErrors{}) {
util.RenderBindingErrors(ctx, err.(validator.ValidationErrors))
return
}
err := controller.service.Signup(request)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusNotAcceptable, gin.H{
"message": err.Error(),
})
}
}
errors.As 是一个内置方法,用来判定err的类型。如果 参数err 和 &validator.ValidationErrors{} 类型相同,会返回true。可以阅读下面的文档,了解更多error包下的内容: documentation.
代码启动 #
这是启动的主方法。
func main() {
db, err := InitialiseDB(&DbConfig{
User: "postgres",
Password: "password",
DbName: "groot",
Host: "localhost",
Port: "5432",
Schema: "users",
})
if err != nil {
panic(err)
}
err = db.AutoMigrate(&domain.User{})
if err != nil {
panic(err)
}
userRepository := repository.NewUserRepository(db)
userService := service.NewUserService(userRepository)
engine := gin.Default()
controller.NewUserController(engine, userService)
log.Fatal(engine.Run(":8080"))
}
从底向上,一步一步构建,逻辑如下:
- 初始化GORM,连接数据库gostgres,获得
grom.DB - 调用
AutoMigrate自动化适配表结构,升级数据库。 - 通过构造方法
NewUserRepository创建持久层,传入gorm.DB - 通过构造方法
NewUserService创建服务层,并且传入持久层对象repository。 - 使用默认配置创建
gin.Engine,并且通过控制层构造方法NewUserController创建控制层实例,将gin.Engine、userService传入 - 启动gin,监听8080端口
日志如下:
...
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)**[GIN-debug] POST /api/users --> github.com/prithuadhikary/user-service/controller.userController.Signup-fm (3 handlers)**
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
**[GIN-debug] Listening and serving HTTP on :8080**
截图:
调用curl,获得响应。
终于搞定,大工程,希望对你有帮助。
Github 库: https://github.com/prithuadhikary/user-service
byebye!
![]()
没想好这段怎么翻译。容我仔细想想。 ↩︎