使用 Golang 构建 RESTful API
注:本文最初发布于martinheinz.dev
在上一篇文章中,我们讨论了如何为Golang项目创建“终极”配置,现在是时候将其应用到实际项目中了——RESTful API。本文将涵盖数据库、单元测试、API 测试、示例应用程序以及实际项目所需的一切。那么,让我们开始吧。
由于本文是本系列上一篇文章《下一个 Golang 项目的终极设置》的后续,因此如果您还没有读过那篇文章,那么您可能需要先查看一下。
TL;DR:这是我的存储库(rest-api
分支) - https://github.com/MartinHeinz/go-project-blueprint/tree/rest-api
框架和库
首先,我们要用什么来构建它?
-
Gin - 我们将使用Gin HTTP Web 框架。它是一个基于 Gin 构建的高性能框架,
net/http
提供最基本的功能、库和必要的功能。它还拥有相当简洁和全面的 API。 -
GORM - 是一个基于 开发的Golang
database/sql
ORM 库。它包含了所有常用的功能,例如预加载、回调、事务等。它的学习曲线比较陡峭,文档也不太完善,所以如果你喜欢编写原始 SQL,那么你可以直接使用sqlx
。 -
Viper - Go配置库,可以处理各种格式、命令行标志、环境变量等。此库的设置和使用已在之前的文章中解释过,因此我在这里就不再详细介绍了。
项目和包结构
接下来,我们来看一下项目中的各个包。此外,main
还有以下包,每个包都负责单一功能。让我们从数据库开始,到查询,一直到 API 端点:
模型
Models 包(models
)有一个文件,该文件定义了反映数据库表结构的类型。在存储库中的示例中,有两种struct
类型 -Model
和User
:
type Model struct {
ID uint `gorm:"primary_key;column:id" json:"id"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deleted_at"`
}
type User struct {
Model
FirstName string `gorm:"column:first_name" json:"first_name"`
LastName string `gorm:"column:last_name" json:"last_name"`
Address string `gorm:"column:address" json:"address"`
Email string `gorm:"column:email" json:"email"`
}
Model
与上一节相同,但gorm.Model
添加了 JSON 标签,以便更轻松地生成包含其字段的 JSON 响应。其次,使用GORMUser
标签描述简单的应用程序用户,这些标签指定字段应与哪一列关联。还有许多其他标签可用于索引、类型或关联,您可以在此处了解更多。
数据访问对象
接下来是包,它是数据访问对象(DAO)daos
的缩写。DAO是一个负责访问数据的对象(惊喜吧……),本质上意味着它使用GORM或原始 SQL 进行 SQL 查询。在下面的示例中,我们有一个简单函数,它根据 ID 检索用户数据,并将其以前面提到的模型形式返回,如果有错误,则返回:User
func (dao *UserDAO) Get(id uint) (*models.User, error) {
var user models.User
err := config.Config.DB.Where("id = ?", id). // Do the query
First(&user). // Make it scalar
Error // retrieve error or null
return &user, err
}
您可以根据DAO访问的表或类似的业务逻辑或任何其他您想要的指标来分离 DAO,但不要将它们全部混在一起,否则会变得一团糟。
服务
当我们将数据完美地加载到模型中后,我们可以在提供数据之前执行额外的逻辑来处理数据,这就是服务发挥作用的地方。这些额外的逻辑可以是过滤、聚合、修改结构或验证数据。最重要的是,它允许我们将数据库查询与业务逻辑分离,这使得代码更加简洁、易于维护,最重要的是(对我来说)更容易测试(稍后会详细介绍)。那么,让我们看一下代码:
type userDAO interface {
Get(id uint) (*models.User, error)
}
type UserService struct {
dao userDAO
}
// NewUserService creates a new UserService with the given user DAO.
func NewUserService(dao userDAO) *UserService {
return &UserService{dao}
}
// Get just retrieves user using User DAO, here can be additional logic for processing data retrieved by DAOs
func (s *UserService) Get(id uint) (*models.User, error) {
return s.dao.Get(id) // No additional logic, just return the query result
}
在上面的代码中,我们首先定义一个接口,它将所有之前创建的DAO函数(在本例中,这些函数Get(id uint)
来自上一节)组合在一起。接下来,我们定义一个用户服务,其中包含我们的DAO以及一个使用作为参数提供的DAO创建该 DAO 的函数。最后,我们定义一个函数,它可以执行一些额外的逻辑并使用DAOUserService
。不过,为了简单起见,我们只使用DAO查询数据库以查找用户并返回结果。这里可以执行的逻辑示例包括模型验证或错误检查。
蜜蜂
最后,服务准备好为我们提供经过处理的有效数据后,我们就可以将其提供给用户了。那么,让我们看看代码:
func GetUser(c *gin.Context) {
s := services.NewUserService(daos.NewUserDAO()) // Create service
id, _ := strconv.ParseUint(c.Param("id"), 10, 32) // Parse ID from URL
if user, err := s.Get(uint(id)); err != nil { // Try to get user from database
c.AbortWithStatus(http.StatusNotFound) // Abort if not found
log.Println(err)
} else {
c.JSON(http.StatusOK, user) // Send back data
}
}
在此代码片段中,我们可以看到可用于服务 API 端点的函数。首先,我们创建服务,并使用前面提到的用户DAO进行调用。接下来,我们解析 ID(我们期望该 ID 出现在 URL 中,例如 - /users/{id}
),然后使用服务从数据库获取用户数据。最后,如果找到数据,我们将以 JSON 格式返回,并附带200
状态码。
将所有东西连接在一起
前面几节中展示的内容都很好,但现在我们实际上需要在中进行设置main
,以便Gin知道在哪里提供我们的 API:
r := gin.New()
r.Use(gin.Logger())
r.Use(gin.Recovery())
v1 := r.Group("/api/v1")
{
v1.GET("/users/:id", apis.GetUser)
}
r.Run(fmt.Sprintf(":%v", config.Config.ServerPort))
显然,我们首先需要创建Gin的实例,然后将中间件(例如 logger 或CORS)附加到它。最后部分 - 也是最重要的部分 - 我们创建一组端点,这些端点都将以上一节中的函数开头api/v1/
并注册GetUser
,以便在用户 ID 参数后进行专门服务/api/v1/users
,就这样,现在我们可以运行我们的应用程序了!
说到这儿,你可能会想:“为什么要创建那么多包、单独的文件、函数层等等?” —— 答案是:如果你的应用程序变得足够大,把所有东西都堆放在一起,维护起来会非常麻烦。而且,在我看来,更重要的是,这种分离是为了提高可测试性,因为单独测试每个层(数据库访问、数据操作和 API)比在一个地方测试要容易得多。所以,既然我们谈到了测试,我想是时候写一些……
注意:在上面的代码片段中,为了清楚起见,我省略了几行代码和注释,例如连接到数据库或加载配置,这些都可以在存储库中找到,其中包括一些用于解释的注释。
测试一切
现在,说到我最喜欢的部分——测试——让我们从test_data
包开始。这个包包含与测试数据库和测试数据相关的实用函数。我想在此包中重点介绍的一个函数是init
:
func init() {
err := config.LoadConfig("/config")
if err != nil {
panic(err)
}
config.Config.DB, config.Config.DBErr = gorm.Open("sqlite3", ":memory:")
config.Config.DB.Exec("PRAGMA foreign_keys = ON") // SQLite defaults to `foreign_keys = off'`
if config.Config.DBErr != nil {
panic(config.Config.DBErr)
}
config.Config.DB.AutoMigrate(&models.User{})
}
这个函数比较特殊,因为Go在导入包时会执行它。这里非常适合进行测试设置——更具体地说,我们首先加载配置,然后创建测试数据库,该数据库是一个内存 SQLite,我们也为其启用了外键。最后,我们使用GORM AutoMigrate
函数创建数据库表。
您可能会问“为什么使用 SQLite 内存数据库,不是更好吗?” - 是的,我自己在每个项目中都使用PostgreSQL,但是当涉及到测试时,您需要一致、快速(内存中)且独立于主机系统/数据库服务器的东西,而此设置提供了它。
我不会介绍这个包的其余功能,因为这篇文章已经很长了,而且它们在这里的代码中记录了。
除了初始化函数之外,我们还在此包中存储了一些数据,即db.sql
包含在运行测试之前填充我们的SQLite数据库的 SQL 插入的文件以及 JSON 测试用例,它们用作 API 端点的预期输出。
现在,我们已经完成了设置,让我们检查一下每个包中的测试:
func TestUserDAO_Get(t *testing.T) {
config.Config.DB = test_data.ResetDB()
dao := NewUserDAO()
user, err := dao.Get(1)
expected := map[string]string{"First Name": "John", "Last Name": "Doe", "Email": "john.doe@gmail.com"}
assert.Nil(t, err)
assert.Equal(t, expected["First Name"], user.FirstName)
assert.Equal(t, expected["Last Name"], user.LastName)
assert.Equal(t, expected["Email"], user.Email)
}
首先,daos
上面的测试非常简单,我们只是创建了DAO并调用了被测函数(Get
),然后根据在设置过程中插入到SQLite数据库中的预期值进行测试,其实没什么好说的了。让我们继续services
:
func TestUserService_Get(t *testing.T) {
s := NewUserService(newMockUserDAO())
user, err := s.Get(2)
if assert.Nil(t, err) && assert.NotNil(t, user) {
assert.Equal(t, "Ben", user.FirstName)
assert.Equal(t, "Doe", user.LastName)
}
user, err = s.Get(100)
assert.NotNil(t, err)
}
func (m *mockUserDAO) Get(id uint) (*models.User, error) {
for _, record := range m.records {
if record.ID == id {
return &record, nil
}
}
return nil, errors.New("not found")
}
func newMockUserDAO() userDAO {
return &mockUserDAO{
records: []models.User{
{Model: models.Model{ID: 1}, FirstName: "John", LastName: "Smith", Email: "john.smith@gmail.com", Address: "Dummy Value"},
{Model: models.Model{ID: 2}, FirstName: "Ben", LastName: "Doe", Email: "ben.doe@gmail.com", Address: "Dummy Value"},
},
}
}
type mockUserDAO struct {
records []models.User
}
这代码量有点大,我们来从下往上分析一下。首先我们需要的是模拟DAO(mockUserDAO
),这样我们就可以独立于真实DAO的实现。为了使这个模拟有用,我们需要用一些测试数据填充它,这就是在 中所做的newMockUserDAO
。接下来,我们还需要定义一个模拟版本Get
来模拟真实的 DAO —— 在这里,我们不需要查询数据库,而是只需查找虚假记录,如果找到提供的 ID 则返回一条记录。
现在,对于实际测试 - 我们创建NewUserService
,但不是传递真实的DAO ,而是使用具有可预测行为的模拟,因此我们能够将被测函数与底层DAO隔离。之后,测试非常简单 - 我们使用模拟Get
方法并测试插入到模拟中的预期值是否存在。
最后要测试的是 API,这些测试实际上都是一行代码,但我们需要做一些准备:
func newRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
config.Config.DB = test_data.ResetDB()
return router
}
func testAPI(router *gin.Engine, method string, urlToServe string, urlToHit string, function gin.HandlerFunc, body string) *httptest.ResponseRecorder {
router.Handle(method, urlToServe, function)
res := httptest.NewRecorder()
req, _ := http.NewRequest(method, urlToHit, bytes.NewBufferString(body))
router.ServeHTTP(res, req)
return res
}
func runAPITests(t *testing.T, tests []apiTestCase) {
for _, test := range tests {
router := newRouter()
res := testAPI(router, test.method, test.urlToServe, test.urlToHit, test.function, test.body)
assert.Equal(t, test.status, res.Code, test.tag)
if test.responseFilePath != "" {
response, _ := ioutil.ReadFile(test.responseFilePath)
assert.JSONEq(t, string(response), res.Body.String(), test.tag)
}
}
}
此处的三个函数模拟 HTTP 请求,用于测试目的。第一个函数在测试模式下创建Gin并重置数据库。第二个函数提供服务,随后访问特定的 API 端点。最后一个函数运行一系列测试用例,检查状态码是否相同,以及 JSON 输出是否相同(可选)。现在,让我们看一些示例测试用例:
func TestUser(t *testing.T) {
path := test_data.GetTestCaseFolder()
runAPITests(t, []apiTestCase{
{"t1 - get a User", "GET", "/users/:id", "/users/1", "", GetUser, http.StatusOK, path + "/user_t1.json"},
{"t2 - get a User not Present", "GET", "/users/:id", "/users/9999", "", GetUser, http.StatusNotFound, ""},
})
}
参数很多,但很简单,我们来逐一看一下:
"t1 - get a User"
- 测试用例的名称 - 编号,以便在调试时更容易找到"GET"
- HTTP 方法"/users/:id"
- 正在提供/测试的 URL"/users/1"
- 被访问的 URL - 包含填充的参数""
- 请求主体 - 在这种情况下为空GetUser
- 附加到端点的方法http.StatusOK
- 预期状态代码 - 此处200
path + "/user_t1.json"
- 预期 JSON 输出的路径 - 这些存储在test_data
前面提到的包中
结论
就是这样 - 用Golang创建 RESTful API 所需的一切。希望这些内容至少能在您构建下一个项目时有所帮助。您可以在这里找到所有源代码,如果您有任何建议或改进,请随时创建问题、拉取请求或直接 fork/star 代码库。如果您喜欢这篇文章,请关注下一篇,我将在其中演示如何将Swagger文档添加到此项目中。
文章来源:https://dev.to/martinheinz/building-restful-apis-in-golang-2e58