使用 Gin 在 Go 中实现 RESTful HTTP API
您好,欢迎回到后端大师班。
到目前为止,我们已经学习了很多关于用 Go 语言操作数据库的知识。现在是时候学习如何实现一些 RESTful HTTP API,以便前端客户端能够与我们的银行服务后端进行交互。
以下是:
- YouTube 上完整系列播放列表的链接
- 以及它的Github 仓库
Go Web 框架和 HTTP 路由器
虽然我们可以使用标准的 net/http 包来实现这些 API,但利用一些现有的 Web 框架会容易得多。
以下是一些最受欢迎的 golang web 框架,按 Github 星号排序:
它们提供了广泛的功能,例如路由、参数绑定、验证、中间件,其中一些甚至具有内置 ORM。
如果您更喜欢仅具有路由功能的轻量级包,那么这里有一些最流行的 golang HTTP 路由器:
在本教程中,我将使用最流行的框架:Gin
安装 Gin
我们打开浏览器搜索golang gin
,然后打开它的Github 页面。向下滚动一点,然后选择Installation
。
让我们复制这个 go get 命令,并在终端中运行它来安装包:
❯ go get -u github.com/gin-gonic/gin
在此之后,在go.mod
我们简单的银行项目的文件中,我们可以看到 gin 作为新的依赖项与它使用的一些其他包一起添加。
定义服务器结构
现在我要创建一个名为 的新文件夹api
。然后在里面创建一个新文件server.go
。这就是我们实现 HTTP API 服务器的地方。
首先,我们定义一个新的Server
结构体。该服务器将负责处理我们银行服务的所有 HTTP 请求。它包含两个字段:
- 第一个是
db.Store
我们在之前的课程中实现的。它将允许我们在处理来自客户端的 API 请求时与数据库进行交互。 - 第二个字段是 类型的路由器
gin.Engine
。这个路由器会帮助我们将每个 API 请求发送到正确的处理程序进行处理。
type Server struct {
store *db.Store
router *gin.Engine
}
现在让我们添加一个函数NewServer
,它接受 adb.Store
作为输入,并返回 a Server
。此函数将创建一个新Server
实例,并为该服务器上的服务设置所有 HTTP API 路由。
Server
首先,我们用输入创建一个新的对象store
。然后通过调用创建一个新的路由器。稍后gin.Default()
我们将向其添加路由。完成此步骤后,我们将对象赋值给服务器并返回服务器。router
router
server.router
func NewServer(store *db.Store) *Server {
server := &Server{store: store}
router := gin.Default()
// TODO: add routes to router
server.router = router
return server
}
现在让我们添加第一个 API 路由来创建新帐户。它将使用POST
方法,因此我们调用router.POST
。
我们必须为路由传入一个路径(/accounts
在本例中是路径),然后传入一个或多个处理函数。如果传入多个函数,则最后一个函数应该是真正的处理函数,所有其他函数都应该是中间件。
func NewServer(store *db.Store) *Server {
server := &Server{store: store}
router := gin.Default()
router.POST("/accounts", server.createAccount)
server.router = router
return server
}
目前我们没有任何中间件,所以我只传入一个 handler: 。这是我们需要实现的结构server.createAccount
体方法。之所以需要将其作为结构体方法,是因为我们必须访问该对象才能将新账户保存到数据库。Server
Server
store
实现创建帐户 API
我将在文件夹内的server.createAccount
一个新文件中实现方法。这里我们声明一个带有服务器指针接收器的函数。它的名称是,并且应该以一个对象作为输入。account.go
api
createAccount
gin.Context
func (server *Server) createAccount(ctx *gin.Context) {
...
}
为什么会有这个函数签名呢?我们来看看router.POST
Gin的这个函数:
这里我们可以看到,它HandlerFunc
被声明为一个带有输入的函数Context
。基本上,在使用 Gin 时,我们在处理程序中执行的所有操作都会涉及到这个上下文对象。它提供了许多便捷的方法来读取输入参数并输出响应。
好了,现在让我们声明一个新的结构体来存储创建账户的请求。它将包含几个字段,类似于我们在上一节课中数据库中使用的createAccountParams
字段:account.sql.go
type CreateAccountParams struct {
Owner string `json:"owner"`
Balance int64 `json:"balance"`
Currency string `json:"currency"`
}
我要复制这些字段并粘贴到我们的createAccountRequest
结构体中。新账户创建时,其初始余额应始终为 0,因此我们可以删除余额字段。我们只允许客户端指定账户所有者的姓名和货币。我们将从 HTTP 请求的主体中获取这些输入参数,该主体是一个 JSON 对象,因此我将保留 JSON 标签。
type createAccountRequest struct {
Owner string `json:"owner"`
Currency string `json:"currency"`
}
func (server *Server) createAccount(ctx *gin.Context) {
...
}
现在,每当我们从客户端获取输入数据时,验证它们总是一个好主意,因为谁知道呢,客户端可能会发送一些我们不想存储在数据库中的无效数据。
幸运的是,Gin 内部使用了一个验证器包,可以在后台自动执行数据验证。例如,我们可以使用一个binding
标签告诉 Gin 该字段是required
。之后,我们调用该ShouldBindJSON
函数从 HTTP 请求正文中解析输入数据,Gin 将验证输出对象,以确保它满足我们在绑定标签中指定的条件。
我将为required
owner 和 currency 字段添加一个绑定标签。此外,假设我们的银行目前仅支持两种货币:USD
和EUR
。那么我们如何让 Gin 帮我们检查呢?好吧,我们可以使用oneof条件来实现这一点:
type createAccountRequest struct {
Owner string `json:"owner" binding:"required"`
Currency string `json:"currency" binding:"required,oneof=USD EUR"`
}
我们使用逗号分隔多个条件,使用空格分隔oneof
条件的可能值。
好了,现在createAccount
我们在函数中声明一个req
类型的新变量createAccountRequest
。然后我们调用ctx.ShouldBindJSON()
函数,并传入这个req
对象。这个函数将返回一个错误。
如果错误不是nil
,则表示客户端提供了无效数据。因此,我们应该向客户端发送 400 Bad Request 响应。为此,我们只需调用ctx.JSON()
函数发送 JSON 响应即可。
第一个参数是 HTTP 状态码,在本例中应该是http.StatusBadRequest
。第二个参数是我们要发送给客户端的 JSON 对象。这里我们只想发送错误,因此需要一个函数将此错误转换为键值对象,以便 Gin 可以在返回客户端之前将其序列化为 JSON。
func (server *Server) createAccount(ctx *gin.Context) {
var req createAccountRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, errorResponse(err))
return
}
...
}
我们稍后会errorResponse()
在代码中大量使用这个函数,它也可以用于其他处理程序,而不仅仅是帐户处理程序,所以我将在server.go
文件中实现它。
此函数将错误作为输入,并返回一个gin.H
对象,该对象实际上只是 的快捷方式map[string]interface{}
。因此,我们可以在其中存储任何所需的键值数据。
现在我们只返回一个键为 error 的 gin.H,它的值是错误消息。稍后我们可能会检查错误类型,并根据需要将其转换为更合适的格式。
func errorResponse(err error) gin.H {
return gin.H{"error": err.Error()}
}
现在让我们回到createAccount
处理程序。如果输入数据有效,就不会出现错误。所以我们继续将新账户插入数据库。
首先,我们声明一个 CreateAccountParams 对象,其中Owner
是req.Owner
,Currency
是req.Currency
,Balance
是0
。然后我们调用server.store.CreateAccount()
,传入输入上下文和参数 。此函数将返回创建的帐户和一个错误。
func (server *Server) createAccount(ctx *gin.Context) {
var req createAccountRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, errorResponse(err))
return
}
arg := db.CreateAccountParams{
Owner: req.Owner,
Currency: req.Currency,
Balance: 0,
}
account, err := server.store.CreateAccount(ctx, arg)
if err != nil {
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
ctx.JSON(http.StatusOK, account)
}
如果错误不是nil
,那么在尝试插入数据库时一定存在一些内部问题。因此,我们将向500 Internal Server Error
客户端返回状态码。我们还重用了该errorResponse()
函数将错误发送给客户端,然后立即返回。
如果没有错误发生,则帐户创建成功。我们只需200 OK
向客户端发送一个状态码和创建的帐户对象即可。就这样!createAccount
处理程序完成了。
启动 HTTP 服务器
接下来,我们需要添加一些代码来运行 HTTP 服务器。我将Start()
在Server
结构体中添加一个新函数。该函数将接受一个错误address
作为输入并返回一个错误。它的作用是在输入上运行 HTTP 服务器,address
以开始监听 API 请求。
func (server *Server) Start(address string) error {
return server.router.Run(address)
}
Gin 已经在路由器中提供了一个函数来执行此操作,因此我们需要做的就是调用server.router.Run()
,并传入服务器地址。
请注意,该server.router
字段是私有的,因此无法从包外部访问api
。这也是我们创建这个公共函数的原因之一Start()
。目前,它只有一个命令,但以后我们可能还想在这个函数中添加一些优雅的关闭逻辑。
好的,现在让我们在main.go
这个仓库根目录下的文件中为我们的服务器创建一个入口点。包名称应该是main
,并且应该有一个main()
函数。
为了创建一个Server
,我们需要先连接到数据库并创建一个。这与我们之前在文件中编写的代码Store
非常相似。main_test.go
我要复制这些常量,dbDriver
并dbSource
粘贴到文件顶部main.go
。然后复制建立数据库连接的代码块,并将其粘贴到主函数中。
有了这个连接,我们可以创建一个新的store
using函数。然后我们通过调用并传入 来db.NewStore()
创建一个新的服务器。api.NewServer()
store
const (
dbDriver = "postgres"
dbSource = "postgresql://root:secret@localhost:5432/simple_bank?sslmode=disable"
)
func main() {
conn, err := sql.Open(dbDriver, dbSource)
if err != nil {
log.Fatal("cannot connect to db:", err)
}
store := db.NewStore(conn)
server := api.NewServer(store)
...
}
要启动服务器,我们只需调用server.Start()
并传入服务器地址即可。目前,我将其声明为常量:localhost,端口 8080。将来,我们将重构代码,以便从环境变量或设置文件加载所有这些配置。如果在启动服务器时出现错误,我们只需写入致命日志,提示“无法启动服务器”。
最后但非常重要的一点是,我们必须为驱动程序添加一个空白的导入lib/pq
。如果没有这个,我们的代码将无法与数据库通信。
package main
import (
"database/sql"
"log"
_ "github.com/lib/pq"
"github.com/techschool/simplebank/api"
db "github.com/techschool/simplebank/db/sqlc"
)
const (
dbDriver = "postgres"
dbSource = "postgresql://root:secret@localhost:5432/simple_bank?sslmode=disable"
serverAddress = "0.0.0.0:8080"
)
func main() {
conn, err := sql.Open(dbDriver, dbSource)
if err != nil {
log.Fatal("cannot connect to db:", err)
}
store := db.NewStore(conn)
server := api.NewServer(store)
err = server.Start(serverAddress)
if err != nil {
log.Fatal("cannot start server:", err)
}
}
好了,现在我们服务器的主入口已经完成了。让我们添加一个新的 make 命令来Makefile
运行它。
我要调用它make server
。它应该执行这个go run main.go
命令。让我们将服务器添加到伪造列表中。
...
server:
go run main.go
.PHONY: postgres createdb dropdb migrateup migratedown sqlc test server
然后打开终端并运行:
make server
瞧,服务器已启动并运行。它正在监听并处理 8080 端口上的 HTTP 请求。
使用 Postman 测试创建帐户 API
现在我将使用 Postman 来测试创建帐户 API。
我们来添加一个新的请求,选择POST
方法,填写URL,即http://localhost:8080/accounts。
参数应该通过 JSON 格式发送,因此我们选择选项Body
卡,选择Raw
,然后选择JSON
格式。我们需要添加两个输入字段:所有者姓名(此处我将使用我的名字)和货币,例如美元。
{
"owner": "Quang Pham",
"currency": "USD"
}
确定,然后单击“发送”。
耶,成功了。我们得到了200 OK
状态码,以及创建的账户对象。它包含ID = 1
、balance = 0
,以及正确的所有者姓名和货币。
现在让我们尝试发送一些无效数据来看看会发生什么。我将把两个字段都设置为空字符串,然后点击“发送”。
{
"owner": "",
"currency": ""
}
这次,我们得到了400 Bad Request
,以及一个错误,提示字段为必填项。这个错误信息看起来很难阅读,因为它把两个字段的验证错误合并在一起了。这是我们将来可能想要改进的地方。
接下来我将尝试使用无效的货币代码,例如xyz
。
{
"owner": "Quang Pham",
"currency": "xyz"
}
这次,我们也得到了400 Bad Request
状态码,但错误信息有所不同。它表示标签验证失败oneof
,这正是我们想要的,因为在代码中,我们只允许货币有 2 个可能的值:USD
和EUR
。
Gin 只用几行代码就帮我们处理了所有输入绑定和验证,真是太棒了。它还能打印出清晰易读的请求日志。
实现获取账户 API
好的,接下来我们将添加一个 API 来通过 ID 获取特定帐户。它与创建帐户 API 非常相似,因此我将复制以下路由语句:
func NewServer(store *db.Store) *Server {
...
router.POST("/accounts", server.createAccount)
router.GET("/accounts/:id", server.getAccount)
...
}
这里POST
我们将使用GET
方法而不是 。此路径应包含id
我们要获取的账户的/accounts/:id
。请注意, 前面有一个冒号id
。这就是我们告诉 Gin 它id
是一个 URI 参数的方式。
然后,我们必须getAccount
在该Server
结构体上实现一个新的处理程序。让我们转到account.go
文件来执行此操作。与之前类似,我们声明一个名为 的结构体getAccountRequest
来存储输入参数。它将包含一个ID
类型为 的字段int64
。
现在,由于ID
是一个 URI 参数,我们无法像以前一样从请求正文中获取它。相反,我们使用uri
标签来告诉 Gin URI 参数的名称:
type getAccountRequest struct {
ID int64 `uri:"id" binding:"required,min=1"`
}
我们添加一个绑定条件,即这ID
是必填字段。此外,我们不希望客户端发送无效的 ID,例如负数。为了告知 Gin 这一点,我们可以使用最小值条件。在本例中,我们设置min = 1
,因为它是账户 ID 的最小可能值。
好的,现在在server.getAccount
处理程序中,我们将执行与之前类似的操作。首先,我们声明一个req
类型的新变量。然后在这里,我们应该调用 ,getAccountRequest
而不是。ShouldBindJSON
ShouldBindUri
如果发生错误,我们只返回一个400 Bad Request
状态码。否则,我们调用来获取等于 的server.store.GetAccount()
帐户。此函数将返回一个和一个错误。ID
req.ID
account
func (server *Server) getAccount(ctx *gin.Context) {
var req getAccountRequest
if err := ctx.ShouldBindUri(&req); err != nil {
ctx.JSON(http.StatusBadRequest, errorResponse(err))
return
}
account, err := server.store.GetAccount(ctx, req.ID)
if err != nil {
if err == sql.ErrNoRows {
ctx.JSON(http.StatusNotFound, errorResponse(err))
return
}
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
ctx.JSON(http.StatusOK, account)
}
如果错误不是nil
,则有两种可能的情况。
- 第一种情况是从数据库查询数据时出现一些内部错误。在这种情况下,我们只需
500 Internal Server Error
向客户端返回状态码。 - 第二种情况是,当输入的特定 ID 对应的账户不存在时。在这种情况下,我们收到的错误应该是
sql.ErrNoRows
。所以我们只需在这里检查一下,如果确实存在,就直接404 Not Found
向客户端发送一个状态码,然后返回。
如果一切顺利,没有错误,我们只需200 OK
向客户端返回状态码和账户信息即可。就这样!我们的 getAccount API 完成了。
使用 Postman 测试获取帐户 API
我们重启服务器,打开Postman进行测试。
让我们添加一个方法为 GET 的新请求,URL 为http://localhost:8080/accounts/1。我们/1
在末尾添加了一个,因为我们想要获取带有 的账户ID = 1
。现在点击发送:
请求成功,我们收到了200 OK
状态码以及找到的账户。这正是我们之前创建的账户。
现在让我们尝试获取一个不存在的账户。我将把 ID 改为 100:http://localhost:8080/accounts/100,然后再次点击“发送”。
这次我们得到了404 Not Found
状态代码和错误:sql no rows in result set
。正如我们所料。
让我们再试一次使用负面 ID:http://localhost:8080/accounts/-1
现在我们得到了一个400 Bad Request
状态代码,其中包含有关验证失败的错误消息。
好的,我们的 getAccount API 运行良好。
实现列出帐户 API
下一步,我将向您展示如何实现带有分页的列表帐户 API。
随着时间的推移,我们数据库中存储的账户数量可能会增长到非常大的规模。因此,我们不应该在一次 API 调用中查询并返回所有账户。分页的理念是将记录分成多个小页面,以便客户端每次 API 请求只能检索一页。
此 API 略有不同,因为我们不会从请求正文或 URI 获取输入参数,而是从查询字符串中获取。以下是请求示例:
我们有一个page_id
参数,它是我们想要获取的页面的索引号,从第 1 页开始。还有一个page_size
参数,它是 1 页中可以返回的最大记录数。
如您所见,page_id
和page_size
被添加到请求 URL 的问号后面:http://localhost:8080/accounts?page_id=1&page_size=5。这就是为什么它们被称为查询参数,而不是像获取帐户请求中的帐户 ID 那样的 URI 参数。
好的,现在让我们回到代码。我将使用相同的GET
方法添加一条新路由。但这次,路径应该是/accounts
only,因为我们要从查询中获取参数。处理程序的名称应该是listAccount
。
func NewServer(store *db.Store) *Server {
server := &Server{store: store}
router := gin.Default()
router.POST("/accounts", server.createAccount)
router.GET("/accounts/:id", server.getAccount)
router.GET("/accounts", server.listAccount)
server.router = router
return server
}
好的,让我们打开account.go
文件来实现这个server.listAccount
函数。它和server.getAccount
处理程序非常相似,所以我要复制它。然后将结构体名称更改为listAccountRequest
。
这个结构体应该存储两个参数,PageID
和PageSize
。请注意,我们不是从 uri 获取这些参数,而是从查询字符串获取,因此我们不能使用uri
标签。我们应该使用form
tag。
type listAccountRequest struct {
PageID int32 `form:"page_id" binding:"required,min=1"`
PageSize int32 `form:"page_size" binding:"required,min=5,max=10"`
}
两个参数都是必需的,最小值PageID
应为 1。对于PageSize
,假设我们不希望它太大或太小,因此我将其最小约束设置为 5 条记录,最大约束设置为 10 条记录。
好的,现在server.listAccount
处理函数应该像这样实现:
func (server *Server) listAccount(ctx *gin.Context) {
var req listAccountRequest
if err := ctx.ShouldBindQuery(&req); err != nil {
ctx.JSON(http.StatusBadRequest, errorResponse(err))
return
}
arg := db.ListAccountsParams{
Limit: req.PageSize,
Offset: (req.PageID - 1) * req.PageSize,
}
accounts, err := server.store.ListAccounts(ctx, arg)
if err != nil {
ctx.JSON(http.StatusInternalServerError, errorResponse(err))
return
}
ctx.JSON(http.StatusOK, accounts)
}
变量req
的类型应该是listAccountRequest
。然后我们使用另一个绑定函数:ShouldBindQuery
告诉 Gin 从查询字符串中获取数据。
如果发生错误,我们只返回一个400 Bad Request
状态。否则,我们调用 来server.store.ListAccounts()
从数据库中查询一页帐户记录。此函数需要一个ListAccountsParams
作为输入,我们需要为两个字段提供值:Limit
和Offset
。
Limit
就是req.PageSize
。虽然Offset
是数据库应该跳过的记录数,但我们必须使用以下公式根据页面 ID 和页面大小来计算它:(req.PageID - 1) * req.PageSize
该ListAccounts
函数返回一个列表accounts
和一个错误。如果发生错误,我们只需返回500 Internal Server Error
客户端即可。否则,我们将发送一个200 OK
状态码以及输出账户列表。
就这样,ListAccount API 就完成了。
使用 Postman 测试列表帐户 API
让我们重新启动服务器,然后打开 Postman 来测试这个请求。
成功了,但列表中只有一个账户。这是因为我们的数据库目前很空。我们只创建了一个账户。让我们运行一下之前课程中编写的数据库测试,获取更多随机数据。
❯ make test
好的,现在我们的数据库中应该有很多账户了。让我们重新发送这个 API 请求。
瞧,现在返回的列表恰好包含 5 个帐户,正如预期的那样。ID 为 5 的帐户没有出现在列表中,因为我认为它在测试中被删除了。我们在这里看到的是 ID 为 6 的帐户。
我们尝试获取第二页。
太棒了,现在我们得到了接下来的 5 个帐户,ID 从 7 到 11。所以它运行得很好。
我将再尝试一次获取不存在的页面,比如说第 100 页。
好的,现在我们得到了一个null
响应主体。虽然从技术上来说这没错,但我认为在这种情况下服务器返回一个空列表会更好。那就这样吧!
返回空列表而不是 null
这是sqlc为我们生成的account.sql.go
文件:
func (q *Queries) ListAccounts(ctx context.Context, arg ListAccountsParams) ([]Account, error) {
rows, err := q.db.QueryContext(ctx, listAccounts, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Account
for rows.Next() {
var i Account
if err := rows.Scan(
&i.ID,
&i.Owner,
&i.Balance,
&i.Currency,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
我们可以看到,Account
items 变量声明后没有被初始化:var items []Account
。这就是为什么如果没有添加记录,它将保持为空。
幸运的是,在最新发布的 sqlc(版本 1.5.0)中,我们有一个新的设置,它将指示 sqlc 创建一个空切片而不是空值。
该设置称为emit_empty_slices
,其默认值为false
。如果我们将此值设置为true
,则多查询返回的结果将为空切片。
好的,现在让我们将这个新设置添加到我们的sqlc.yaml
文件中:
version: "1"
packages:
- name: "db"
path: "./db/sqlc"
queries: "./db/query/"
schema: "./db/migration/"
engine: "postgresql"
emit_json_tags: true
emit_prepared_queries: false
emit_interface: false
emit_exact_table_names: false
emit_empty_slices: true
保存并打开终端将 sqlc 升级到最新版本。如果你使用的是 Mac 系统并使用Homebrew,只需运行:
❯ brew upgrade sqlc
您可以通过运行以下命令检查当前版本:
❯ sqlc version
v1.5.0
对我来说,它已经是最新版本:1.5.0
,所以现在我要重新生成代码:
❯ make sqlc
回到 Visual Studio Code。现在在account.sql.go
文件中,我们可以看到 items 变量现在被初始化为一个空切片:
func (q *Queries) ListAccounts(ctx context.Context, arg ListAccountsParams) ([]Account, error) {
...
items := []Account{}
...
}
太棒了!让我们重启服务器并在 Postman 上测试一下。现在,当我发送这个请求时,我们得到了一个空列表,正如预期的那样。
所以它有效!
现在我要尝试一些无效的参数。例如,让我们将其更改page_size
为20
,它大于的最大约束10
。
这次我们得到了400 Bad Request
状态代码,以及一个错误,表明标签验证page_size
失败max
。
让我们再试一次page_id = 0
。
现在我们仍然能获取400 Bad Request
状态,但错误是由于page_id
标签验证失败required
。这里发生的情况是,在验证器包中,任何零值都会被识别为缺失值。在这种情况下,这是可以接受的,因为我们无论如何都不想得到零值页面。
但是,如果你的 API 有一个零值参数,那么你需要注意这一点。我建议你阅读validator 包的文档来了解更多信息。
好了,今天我们已经学习了使用 Gin 在 Go 中轻松实现 RESTful HTTP API。您可以基于本教程尝试自行实现更多路由来更新或删除账户。我把这留作练习。
非常感谢你阅读这篇文章。祝你编程愉快!下节课再见!
如果您喜欢这篇文章,请订阅我们的 Youtube 频道并在 Twitter 上关注我们,以便将来获取更多教程。
如果你想加入我目前在 Voodoo 的优秀团队,请查看我们的职位空缺。你可以远程办公,也可以在巴黎/阿姆斯特丹/伦敦/柏林/巴塞罗那现场办公,但需获得签证担保。
文章来源:https://dev.to/techschoolguru/implement-restful-http-api-in-go-using-gin-4ap1