📖 在 Go 上构建 RESTful API:隔离的 Docker 容器中的 Fiber、PostgreSQL、JWT 和 Swagger 文档 📖 教程:在 Go 上构建 RESTful API

2025-05-25

📖 在 Go 上构建 RESTful API:在隔离的 Docker 容器中使用 Fiber、PostgreSQL、JWT 和 Swagger 文档

📖 教程:用 Go 语言构建 RESTful API

介绍

大家好!😉 欢迎来到这个非常棒的教程。我尽量根据实际应用,一步一步地提供尽可能简单的说明,以便您能够立即运用这些知识。

我故意不想把本教程分成几个不连贯的部分,这样你就不会失去思路和重点。毕竟,我写这个教程只是为了分享我的经验,并向你展示使用 Fiber 框架在 Golang 中进行后端开发是多么简单!

本教程的最后,您将找到一个自查知识块,以及应用程序进一步开发的计划。因此,我建议您将本教程的链接保存到书签中,并在社交网络上分享。

❤️ 喜欢,🦄 独角兽,🔖 收藏,我们走吧!

📝 目录

我们想要构建什么?

让我们为一个在线图书馆应用程序创建一个 REST API,用于创建新图书、查看图书以及更新和删除图书信息。但有些方法需要我们通过提供有效的 JWT 访问令牌进行授权。我会像往常一样,将所有图书信息存储在我心爱的 PostgreSQL 数据库中。

我认为,此功能足以帮助您理解使用Fiber Web 框架在 Go 中创建 REST API是多么容易。

↑ 目录

API 方法

民众:

  • GET : /api/v1/books,获取所有书籍;
  • GET/api/v1/book/{id}通过给定的ID获取书籍;
  • GET:,/api/v1/token/new创建一个新的访问令牌(用于演示);

私人(JWT 保护):

  • POST/api/v1/book,创建一本新书;
  • PATCH/api/v1/book,更新现有的书籍;
  • DELETE/api/v1/book,删除现有的书籍;

↑ 目录

高级用户的完整应用程序代码

如果您觉得自己足够强大,可以自己弄清楚代码,那么该应用程序的完整草稿已发布在我的 GitHub 存储库中:

GitHub 徽标 koddr /教程-go-fiber-rest-api

📖 在 Go 上构建 RESTful API:在隔离的 Docker 容器中构建 Fiber、PostgreSQL、JWT 和 Swagger 文档。

📖 教程:用 Go 语言构建 RESTful API

隔离的 Docker 容器中的 Fiber、PostgreSQL、JWT 和 Swagger 文档。

👉 全文于2021 年 3 月 22 日发布在 Dev.to 上:https ://dev.to/koddr/build-a-restful-api-on-go-fiber-postgresql-jwt-and-swagger-docs-in-isolated-docker-containers-475j

纤维覆盖层

快速启动

  1. 重命名.env.example.env填充您的环境值。
  2. 安装Docker迁移工具以应用迁移。
  3. 通过以下命令运行项目:
make docker.run

# Process:
#   - Generate API docs by Swagger
#   - Create a new Docker network for containers
#   - Build and run Docker containers (Fiber, PostgreSQL)
#   - Apply database migrations (using github.com/golang-migrate/migrate)
Enter fullscreen mode Exit fullscreen mode
  1. 转到您的 API 文档页面:127.0.0.1:5000/ swagger/index.html

截屏

聚苯乙烯

如果你想在本博客上看到更多类似的文章,请在下方留言并订阅我。谢谢!😘

当然,您也可以通过LiberaPay捐款来支持我。每笔捐款都将用于撰写新文章和开发非营利性开源项目……

↑ 目录

我的 Go 项目架构方法

在过去的两年里,我尝试了许多 Go 应用程序的结构,但最终确定了我的结构,现在我将尝试向您解释。

↑ 目录

仅包含业务逻辑的文件夹

./app文件夹不关心您正在使用什么数据库驱动程序您选择哪种缓存解决方案或任何第三方的东西。

  • ./app/controllers功能控制器文件夹(用于路线);
  • ./app/models用于描述商业模式和方法的文件夹;
  • ./app/queries用于描述模型查询的文件夹;

↑ 目录

包含 API 文档的文件夹

./docs文件夹包含 Swagger 自动生成的 API 文档的配置文件。

↑ 目录

具有项目特定功能的文件夹

./pkg文件夹包含仅为您的业务用例定制的所有项目特定代码,例如配置中间件路由实用程序

  • ./pkg/configs配置功能文件夹;
  • ./pkg/middleware添加中间件的文件夹;
  • ./pkg/routes用于描述项目路线的文件夹;
  • ./pkg/utils包含实用功能(服务器启动器、生成器等)的文件夹;

↑ 目录

包含平台级逻辑的文件夹

./platform文件夹包含构建实际项目的所有平台级逻辑,例如设置数据库缓存服务器实例以及存储迁移

  • ./platform/database具有数据库设置功能的文件夹;
  • ./platform/migrations包含迁移文件的文件夹;

↑ 目录

项目配置

这个项目的配置乍一看可能很复杂。别担心,我会尽可能简单易懂地描述每一点。

↑ 目录

Makefile

我强烈建议使用Makefile来加快项目管理速度!但在本文中,我想展示整个过程。因此,我将直接编写所有命令,无需任何魔法make

👋 如果您已经知道,这里是完整项目的链接Makefile

↑ 目录

ENV 文件中的 Fiber 配置

我知道有些人喜欢使用 YML 文件来配置他们的 Go 应用程序,但我习惯于使用经典.env配置,并且没有看到 YML 的太多好处(尽管我过去写过一篇关于 Go 中这种应用程序配置的文章)。

该项目的配置文件如下:



# ./.env

# Server settings:
SERVER_URL="0.0.0.0:5000"
SERVER_READ_TIMEOUT=60

# JWT settings:
JWT_SECRET_KEY="secret"
JWT_SECRET_KEY_EXPIRE_MINUTES_COUNT=15

# Database settings:
DB_SERVER_URL="host=localhost port=5432 user=postgres password=password dbname=postgres sslmode=disable"
DB_MAX_CONNECTIONS=100
DB_MAX_IDLE_CONNECTIONS=10
DB_MAX_LIFETIME_CONNECTIONS=2


Enter fullscreen mode Exit fullscreen mode

↑ 目录

Docker 网络

为您的操作系统安装并运行Docker服务。顺便说一下,在本教程中,我使用的是最新版本(目前v20.10.2

好的,让我们创建一个新的 Docker 网络,称为dev-network



docker network create -d bridge dev-network


Enter fullscreen mode Exit fullscreen mode

我们将来在隔离的容器中运行数据库和 Fiber 实例时会用到它。如果不这样做,两个容器将无法相互通信。

☝️ 更多信息请访问:https://docs.docker.com/network/

↑ 目录

PostgreSQL 和初始迁移

那么,让我们用数据库启动容器:



docker run --rm -d \
    --name dev-postgres \
    --network dev-network \
    -e POSTGRES_USER=postgres \
    -e POSTGRES_PASSWORD=password \
    -e POSTGRES_DB=postgres \
    -v ${HOME}/dev-postgres/data/:/var/lib/postgresql/data \
    -p 5432:5432 \
    postgres


Enter fullscreen mode Exit fullscreen mode

检查容器是否正在运行。例如,通过ctop控制台实用程序:

ctop

太棒了!现在我们可以开始迁移原始结构了。up迁移文件如下000001_create_init_tables.up.sql



-- ./platform/migrations/000001_create_init_tables.up.sql

-- Add UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- Set timezone
-- For more information, please visit:
-- https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
SET TIMEZONE="Europe/Moscow";

-- Create books table
CREATE TABLE books (
    id UUID DEFAULT uuid_generate_v4 () PRIMARY KEY,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW (),
    updated_at TIMESTAMP NULL,
    title VARCHAR (255) NOT NULL,
    author VARCHAR (255) NOT NULL,
    book_status INT NOT NULL,
    book_attrs JSONB NOT NULL
);

-- Add indexes
CREATE INDEX active_books ON books (title) WHERE book_status = 1;


Enter fullscreen mode Exit fullscreen mode

☝️ 为了方便使用额外的书籍属性,我使用JSONBtype 作为book_attrs字段。更多信息,请访问 PostgreSQL文档

对于这次迁移000001_create_init_tables.down.sqldown



-- ./platform/migrations/000001_create_init_tables.down.sql

-- Delete tables
DROP TABLE IF EXISTS books;


Enter fullscreen mode Exit fullscreen mode

好的!我们可以开始迁移了。

👍 我建议使用golang-migrate/migrate工具,通过一个控制台命令轻松完成数据库迁移。



migrate \
    -path $(PWD)/platform/migrations \
    -database "postgres://postgres:password@localhost/postgres?sslmode=disable" \
    up


Enter fullscreen mode Exit fullscreen mode

↑ 目录

Fiber 应用程序的 Dockerfile

Dockerfile在项目根文件夹中创建:



# ./Dockerfile

FROM golang:1.16-alpine AS builder

# Move to working directory (/build).
WORKDIR /build

# Copy and download dependency using go mod.
COPY go.mod go.sum ./
RUN go mod download

# Copy the code into the container.
COPY . .

# Set necessary environment variables needed for our image 
# and build the API server.
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
RUN go build -ldflags="-s -w" -o apiserver .

FROM scratch

# Copy binary and config files from /build 
# to root folder of scratch container.
COPY --from=builder ["/build/apiserver", "/build/.env", "/"]

# Export necessary port.
EXPOSE 5000

# Command to run when starting the container.
ENTRYPOINT ["/apiserver"]


Enter fullscreen mode Exit fullscreen mode

是的,我使用的是两阶段容器构建和 Golang 1.16.x。应用程序将使用 和 构建CGO_ENABLED=0-ldflags="-s -w"减少最终二进制文件的大小。除此之外,这是Dockerfile任何 Go 项目最常见的构建方式,您可以在任何地方使用。

构建 Fiber Docker 镜像的命令:



docker build -t fiber .


Enter fullscreen mode Exit fullscreen mode

☝️ 不要忘记将.dockerignore文件以及所有文件和文件夹添加到项目的根文件夹中,创建容器时应该忽略这一点。以下是本教程中使用的示例。

从图像创建并启动容器的命令:



docker run --rm -d \
    --name dev-fiber \
    --network dev-network \
    -p 5000:5000 \
    fiber


Enter fullscreen mode Exit fullscreen mode

↑ 目录

昂首阔步

从标题就能猜到,我们不会太担心 API 方法的文档编写。因为有Swagger这样的好工具可以帮我们搞定一切!

↑ 目录

实践部分

实践部分

好了,我们已经准备好所有必要的配置文件和工作环境,并且知道要创建什么了。现在是时候打开我们最喜欢的 IDE 并开始编写代码了。

👋 请注意,因为我将直接在代码的注释中解释一些要点,而不是在文章中。

↑ 目录

创建模型

在实现模型之前,我总是会创建一个包含 SQL 结构的迁移文件(参见第三章)。这样可以更轻松地一次性呈现所有必要的模型字段。



// ./app/models/book_model.go

package models

import (
    "database/sql/driver"
    "encoding/json"
    "errors"
    "time"

    "github.com/google/uuid"
)

// Book struct to describe book object.
type Book struct {
    ID         uuid.UUID `db:"id" json:"id" validate:"required,uuid"`
    CreatedAt  time.Time `db:"created_at" json:"created_at"`
    UpdatedAt  time.Time `db:"updated_at" json:"updated_at"`
    UserID     uuid.UUID `db:"user_id" json:"user_id" validate:"required,uuid"`
    Title      string    `db:"title" json:"title" validate:"required,lte=255"`
    Author     string    `db:"author" json:"author" validate:"required,lte=255"`
    BookStatus int       `db:"book_status" json:"book_status" validate:"required,len=1"`
    BookAttrs  BookAttrs `db:"book_attrs" json:"book_attrs" validate:"required,dive"`
}

// BookAttrs struct to describe book attributes.
type BookAttrs struct {
    Picture     string `json:"picture"`
    Description string `json:"description"`
    Rating      int    `json:"rating" validate:"min=1,max=10"`
}

// ...


Enter fullscreen mode Exit fullscreen mode

👍 我建议使用google/uuid包来创建唯一 ID,因为这是一种更通用的方法,可以保护你的应用程序免受常见的数字暴力攻击。尤其是在你的 REST API 包含未经授权且请求量受限的公共方法时

但这还不是全部。你需要编写两个特殊方法:

  1. Value(),用于返回结构的 JSON 编码表示;
  2. Scan(),用于将 JSON 编码的值解码为结构字段;

它们可能看起来像这样:



// ...

// Value make the BookAttrs struct implement the driver.Valuer interface.
// This method simply returns the JSON-encoded representation of the struct.
func (b BookAttrs) Value() (driver.Value, error) {
    return json.Marshal(b)
}

// Scan make the BookAttrs struct implement the sql.Scanner interface.
// This method simply decodes a JSON-encoded value into the struct fields.
func (b *BookAttrs) Scan(value interface{}) error {
    j, ok := value.([]byte)
    if !ok {
        return errors.New("type assertion to []byte failed")
    }

    return json.Unmarshal(j, &b)
}


Enter fullscreen mode Exit fullscreen mode

↑ 目录

为模型字段创建验证器

好的,让我们定义在将输入传递给控制器​​业务逻辑之前需要检查的字段:

  • ID字段,用于检查有效的UUID

这些字段是最值得关注的,因为在某些情况下,它们会由用户主动提供。正因如此,我们不仅要验证它们,还要考虑它们required

这就是我实现验证器的方式:



// ./app/utils/validator.go

package utils

import (
    "github.com/go-playground/validator/v10"
    "github.com/google/uuid"
)

// NewValidator func for create a new validator for model fields.
func NewValidator() *validator.Validate {
    // Create a new validator for a Book model.
    validate := validator.New()

    // Custom validation for uuid.UUID fields.
    _ = validate.RegisterValidation("uuid", func(fl validator.FieldLevel) bool {
        field := fl.Field().String()
        if _, err := uuid.Parse(field); err != nil {
            return true
        }
        return false
    })

    return validate
}

// ValidatorErrors func for show validation errors for each invalid fields.
func ValidatorErrors(err error) map[string]string {
    // Define fields map.
    fields := map[string]string{}

    // Make error message for each invalid field.
    for _, err := range err.(validator.ValidationErrors) {
        fields[err.Field()] = err.Error()
    }

    return fields
}


Enter fullscreen mode Exit fullscreen mode

👌 我使用go-playground/validator v10来发布此功能。

↑ 目录

创建查询和控制器

数据库查询

为了不损失性能,我喜欢使用SQL 查询,不加任何语法糖、类似gorm或类似的包。这样可以更好地理解应用程序的工作原理,有助于将来在优化数据库查询时避免犯愚蠢的错误!



// ./app/queries/book_query.go

package queries

import (
    "github.com/google/uuid"
    "github.com/jmoiron/sqlx"
    "github.com/koddr/tutorial-go-fiber-rest-api/app/models"
)

// BookQueries struct for queries from Book model.
type BookQueries struct {
    *sqlx.DB
}

// GetBooks method for getting all books.
func (q *BookQueries) GetBooks() ([]models.Book, error) {
    // Define books variable.
    books := []models.Book{}

    // Define query string.
    query := `SELECT * FROM books`

    // Send query to database.
    err := q.Get(&books, query)
    if err != nil {
        // Return empty object and error.
        return books, err
    }

    // Return query result.
    return books, nil
}

// GetBook method for getting one book by given ID.
func (q *BookQueries) GetBook(id uuid.UUID) (models.Book, error) {
    // Define book variable.
    book := models.Book{}

    // Define query string.
    query := `SELECT * FROM books WHERE id = $1`

    // Send query to database.
    err := q.Get(&book, query, id)
    if err != nil {
        // Return empty object and error.
        return book, err
    }

    // Return query result.
    return book, nil
}

// CreateBook method for creating book by given Book object.
func (q *BookQueries) CreateBook(b *models.Book) error {
    // Define query string.
    query := `INSERT INTO books VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`

    // Send query to database.
    _, err := q.Exec(query, b.ID, b.CreatedAt, b.UpdatedAt, b.UserID, b.Title, b.Author, b.BookStatus, b.BookAttrs)
    if err != nil {
        // Return only error.
        return err
    }

    // This query returns nothing.
    return nil
}

// UpdateBook method for updating book by given Book object.
func (q *BookQueries) UpdateBook(id uuid.UUID, b *models.Book) error {
    // Define query string.
    query := `UPDATE books SET updated_at = $2, title = $3, author = $4, book_status = $5, book_attrs = $6 WHERE id = $1`

    // Send query to database.
    _, err := q.Exec(query, id, b.UpdatedAt, b.Title, b.Author, b.BookStatus, b.BookAttrs)
    if err != nil {
        // Return only error.
        return err
    }

    // This query returns nothing.
    return nil
}

// DeleteBook method for delete book by given ID.
func (q *BookQueries) DeleteBook(id uuid.UUID) error {
    // Define query string.
    query := `DELETE FROM books WHERE id = $1`

    // Send query to database.
    _, err := q.Exec(query, id)
    if err != nil {
        // Return only error.
        return err
    }

    // This query returns nothing.
    return nil
}


Enter fullscreen mode Exit fullscreen mode

创建模型控制器

方法原理GET

  • 向 API 端点发出请求;
  • 建立与数据库的连接(否则出现错误);
  • 进行查询以从表中获取记录books(或出现错误);
  • 200返回已创建的书籍的状态和 JSON;


// ./app/controllers/book_controller.go

package controllers

import (
    "time"

    "github.com/gofiber/fiber/v2"
    "github.com/google/uuid"
    "github.com/koddr/tutorial-go-fiber-rest-api/app/models"
    "github.com/koddr/tutorial-go-fiber-rest-api/pkg/utils"
    "github.com/koddr/tutorial-go-fiber-rest-api/platform/database"
)

// GetBooks func gets all exists books.
// @Description Get all exists books.
// @Summary get all exists books
// @Tags Books
// @Accept json
// @Produce json
// @Success 200 {array} models.Book
// @Router /v1/books [get]
func GetBooks(c *fiber.Ctx) error {
    // Create database connection.
    db, err := database.OpenDBConnection()
    if err != nil {
        // Return status 500 and database connection error.
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": true,
            "msg":   err.Error(),
        })
    }

    // Get all books.
    books, err := db.GetBooks()
    if err != nil {
        // Return, if books not found.
        return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
            "error": true,
            "msg":   "books were not found",
            "count": 0,
            "books": nil,
        })
    }

    // Return status 200 OK.
    return c.JSON(fiber.Map{
        "error": false,
        "msg":   nil,
        "count": len(books),
        "books": books,
    })
}

// GetBook func gets book by given ID or 404 error.
// @Description Get book by given ID.
// @Summary get book by given ID
// @Tags Book
// @Accept json
// @Produce json
// @Param id path string true "Book ID"
// @Success 200 {object} models.Book
// @Router /v1/book/{id} [get]
func GetBook(c *fiber.Ctx) error {
    // Catch book ID from URL.
    id, err := uuid.Parse(c.Params("id"))
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": true,
            "msg":   err.Error(),
        })
    }

    // Create database connection.
    db, err := database.OpenDBConnection()
    if err != nil {
        // Return status 500 and database connection error.
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": true,
            "msg":   err.Error(),
        })
    }

    // Get book by ID.
    book, err := db.GetBook(id)
    if err != nil {
        // Return, if book not found.
        return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
            "error": true,
            "msg":   "book with the given ID is not found",
            "book":  nil,
        })
    }

    // Return status 200 OK.
    return c.JSON(fiber.Map{
        "error": false,
        "msg":   nil,
        "book":  book,
    })
}

// ...


Enter fullscreen mode Exit fullscreen mode

方法原理POST

  • 向 API 端点发出请求;
  • 检查请求是否Header具有有效的 JWT;
  • 检查 JWT 的到期日期是否大于现在(或出现错误);
  • 解析请求主体并将字段绑定到 Book 结构(或错误);
  • 建立与数据库的连接(否则出现错误);
  • 使用来自 Body 的新内容(或错误)验证结构字段;
  • 进行查询以在表中创建新记录books(或出现错误);
  • 返回一本新书的状态200和 JSON;


// ...

// CreateBook func for creates a new book.
// @Description Create a new book.
// @Summary create a new book
// @Tags Book
// @Accept json
// @Produce json
// @Param title body string true "Title"
// @Param author body string true "Author"
// @Param book_attrs body models.BookAttrs true "Book attributes"
// @Success 200 {object} models.Book
// @Security ApiKeyAuth
// @Router /v1/book [post]
func CreateBook(c *fiber.Ctx) error {
    // Get now time.
    now := time.Now().Unix()

    // Get claims from JWT.
    claims, err := utils.ExtractTokenMetadata(c)
    if err != nil {
        // Return status 500 and JWT parse error.
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": true,
            "msg":   err.Error(),
        })
    }

    // Set expiration time from JWT data of current book.
    expires := claims.Expires

    // Checking, if now time greather than expiration from JWT.
    if now > expires {
        // Return status 401 and unauthorized error message.
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": true,
            "msg":   "unauthorized, check expiration time of your token",
        })
    }

    // Create new Book struct
    book := &models.Book{}

    // Check, if received JSON data is valid.
    if err := c.BodyParser(book); err != nil {
        // Return status 400 and error message.
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": true,
            "msg":   err.Error(),
        })
    }

    // Create database connection.
    db, err := database.OpenDBConnection()
    if err != nil {
        // Return status 500 and database connection error.
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": true,
            "msg":   err.Error(),
        })
    }

    // Create a new validator for a Book model.
    validate := utils.NewValidator()

    // Set initialized default data for book:
    book.ID = uuid.New()
    book.CreatedAt = time.Now()
    book.BookStatus = 1 // 0 == draft, 1 == active

    // Validate book fields.
    if err := validate.Struct(book); err != nil {
        // Return, if some fields are not valid.
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": true,
            "msg":   utils.ValidatorErrors(err),
        })
    }

    // Delete book by given ID.
    if err := db.CreateBook(book); err != nil {
        // Return status 500 and error message.
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": true,
            "msg":   err.Error(),
        })
    }

    // Return status 200 OK.
    return c.JSON(fiber.Map{
        "error": false,
        "msg":   nil,
        "book":  book,
    })
}

// ...


Enter fullscreen mode Exit fullscreen mode

方法原理PUT

  • 向 API 端点发出请求;
  • 检查请求是否Header具有有效的 JWT;
  • 检查 JWT 的到期日期是否大于现在(或出现错误);
  • 解析请求主体并将字段绑定到 Book 结构(或错误);
  • 建立与数据库的连接(否则出现错误);
  • 使用来自 Body 的新内容(或错误)验证结构字段;
  • 检查具有此 ID 的书籍是否存在(或出现错误);
  • 进行查询以更新表中的此记录books(或出现错误);
  • 201返回无内容的状态;


// ...

// UpdateBook func for updates book by given ID.
// @Description Update book.
// @Summary update book
// @Tags Book
// @Accept json
// @Produce json
// @Param id body string true "Book ID"
// @Param title body string true "Title"
// @Param author body string true "Author"
// @Param book_status body integer true "Book status"
// @Param book_attrs body models.BookAttrs true "Book attributes"
// @Success 201 {string} status "ok"
// @Security ApiKeyAuth
// @Router /v1/book [put]
func UpdateBook(c *fiber.Ctx) error {
    // Get now time.
    now := time.Now().Unix()

    // Get claims from JWT.
    claims, err := utils.ExtractTokenMetadata(c)
    if err != nil {
        // Return status 500 and JWT parse error.
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": true,
            "msg":   err.Error(),
        })
    }

    // Set expiration time from JWT data of current book.
    expires := claims.Expires

    // Checking, if now time greather than expiration from JWT.
    if now > expires {
        // Return status 401 and unauthorized error message.
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": true,
            "msg":   "unauthorized, check expiration time of your token",
        })
    }

    // Create new Book struct
    book := &models.Book{}

    // Check, if received JSON data is valid.
    if err := c.BodyParser(book); err != nil {
        // Return status 400 and error message.
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": true,
            "msg":   err.Error(),
        })
    }

    // Create database connection.
    db, err := database.OpenDBConnection()
    if err != nil {
        // Return status 500 and database connection error.
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": true,
            "msg":   err.Error(),
        })
    }

    // Checking, if book with given ID is exists.
    foundedBook, err := db.GetBook(book.ID)
    if err != nil {
        // Return status 404 and book not found error.
        return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
            "error": true,
            "msg":   "book with this ID not found",
        })
    }

    // Set initialized default data for book:
    book.UpdatedAt = time.Now()

    // Create a new validator for a Book model.
    validate := utils.NewValidator()

    // Validate book fields.
    if err := validate.Struct(book); err != nil {
        // Return, if some fields are not valid.
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": true,
            "msg":   utils.ValidatorErrors(err),
        })
    }

    // Update book by given ID.
    if err := db.UpdateBook(foundedBook.ID, book); err != nil {
        // Return status 500 and error message.
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": true,
            "msg":   err.Error(),
        })
    }

    // Return status 201.
    return c.SendStatus(fiber.StatusCreated)
}

// ...


Enter fullscreen mode Exit fullscreen mode

方法原理DELETE

  • 向 API 端点发出请求;
  • 检查请求是否Header具有有效的 JWT;
  • 检查 JWT 的到期日期是否大于现在(或出现错误);
  • 解析请求主体并将字段绑定到 Book 结构(或错误);
  • 建立与数据库的连接(否则出现错误);
  • 使用来自 Body 的新内容(或错误)验证结构字段;
  • 检查具有此 ID 的书籍是否存在(或出现错误);
  • 进行查询以从表中删除该记录books(或出现错误);
  • 204返回无内容的状态;


// ...

// DeleteBook func for deletes book by given ID.
// @Description Delete book by given ID.
// @Summary delete book by given ID
// @Tags Book
// @Accept json
// @Produce json
// @Param id body string true "Book ID"
// @Success 204 {string} status "ok"
// @Security ApiKeyAuth
// @Router /v1/book [delete]
func DeleteBook(c *fiber.Ctx) error {
    // Get now time.
    now := time.Now().Unix()

    // Get claims from JWT.
    claims, err := utils.ExtractTokenMetadata(c)
    if err != nil {
        // Return status 500 and JWT parse error.
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": true,
            "msg":   err.Error(),
        })
    }

    // Set expiration time from JWT data of current book.
    expires := claims.Expires

    // Checking, if now time greather than expiration from JWT.
    if now > expires {
        // Return status 401 and unauthorized error message.
        return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
            "error": true,
            "msg":   "unauthorized, check expiration time of your token",
        })
    }

    // Create new Book struct
    book := &models.Book{}

    // Check, if received JSON data is valid.
    if err := c.BodyParser(book); err != nil {
        // Return status 400 and error message.
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": true,
            "msg":   err.Error(),
        })
    }

    // Create a new validator for a Book model.
    validate := utils.NewValidator()

    // Validate only one book field ID.
    if err := validate.StructPartial(book, "id"); err != nil {
        // Return, if some fields are not valid.
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": true,
            "msg":   utils.ValidatorErrors(err),
        })
    }

    // Create database connection.
    db, err := database.OpenDBConnection()
    if err != nil {
        // Return status 500 and database connection error.
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": true,
            "msg":   err.Error(),
        })
    }

    // Checking, if book with given ID is exists.
    foundedBook, err := db.GetBook(book.ID)
    if err != nil {
        // Return status 404 and book not found error.
        return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
            "error": true,
            "msg":   "book with this ID not found",
        })
    }

    // Delete book by given ID.
    if err := db.DeleteBook(foundedBook.ID); err != nil {
        // Return status 500 and error message.
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": true,
            "msg":   err.Error(),
        })
    }

    // Return status 204 no content.
    return c.SendStatus(fiber.StatusNoContent)
}


Enter fullscreen mode Exit fullscreen mode

获取新访问令牌(JWT)的方法

  • 向 API 端点发出请求;
  • 200使用新的访问令牌返回状态和 JSON;


// ./app/controllers/token_controller.go

package controllers

import (
    "github.com/gofiber/fiber/v2"
    "github.com/koddr/tutorial-go-fiber-rest-api/pkg/utils"
)

// GetNewAccessToken method for create a new access token.
// @Description Create a new access token.
// @Summary create a new access token
// @Tags Token
// @Accept json
// @Produce json
// @Success 200 {string} status "ok"
// @Router /v1/token/new [get]
func GetNewAccessToken(c *fiber.Ctx) error {
    // Generate a new Access token.
    token, err := utils.GenerateNewAccessToken()
    if err != nil {
        // Return status 500 and token generation error.
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": true,
            "msg":   err.Error(),
        })
    }

    return c.JSON(fiber.Map{
        "error":        false,
        "msg":          nil,
        "access_token": token,
    })
}


Enter fullscreen mode Exit fullscreen mode

↑ 目录

主要功能

这是我们整个应用程序中最重要的功能。它从文件加载配置.env,定义 Swagger 设置,创建一个新的 Fiber 实例,连接必要的端点组并启动 API 服务器。



// ./main.go

package main

import (
    "github.com/gofiber/fiber/v2"
    "github.com/koddr/tutorial-go-fiber-rest-api/pkg/configs"
    "github.com/koddr/tutorial-go-fiber-rest-api/pkg/middleware"
    "github.com/koddr/tutorial-go-fiber-rest-api/pkg/routes"
    "github.com/koddr/tutorial-go-fiber-rest-api/pkg/utils"

    _ "github.com/joho/godotenv/autoload"                // load .env file automatically
    _ "github.com/koddr/tutorial-go-fiber-rest-api/docs" // load API Docs files (Swagger)
)

// @title API
// @version 1.0
// @description This is an auto-generated API Docs.
// @termsOfService http://swagger.io/terms/
// @contact.name API Support
// @contact.email your@mail.com
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name Authorization
// @BasePath /api
func main() {
    // Define Fiber config.
    config := configs.FiberConfig()

    // Define a new Fiber app with config.
    app := fiber.New(config)

    // Middlewares.
    middleware.FiberMiddleware(app) // Register Fiber's middleware for app.

    // Routes.
    routes.SwaggerRoute(app)  // Register a route for API Docs (Swagger).
    routes.PublicRoutes(app)  // Register a public routes for app.
    routes.PrivateRoutes(app) // Register a private routes for app.
    routes.NotFoundRoute(app) // Register route for 404 Error.

    // Start server (with graceful shutdown).
    utils.StartServerWithGracefulShutdown(app)
}


Enter fullscreen mode Exit fullscreen mode

↑ 目录

中间件功能

因为在这个应用程序中我想展示如何使用 JWT 来授权一些查询,所以我们需要编写额外的中间件来验证它:



// ./pkg/middleware/jwt_middleware.go

package middleware

import (
    "os"

    "github.com/gofiber/fiber/v2"

    jwtMiddleware "github.com/gofiber/jwt/v2"
)

// JWTProtected func for specify routes group with JWT authentication.
// See: https://github.com/gofiber/jwt
func JWTProtected() func(*fiber.Ctx) error {
    // Create config for JWT authentication middleware.
    config := jwtMiddleware.Config{
        SigningKey:   []byte(os.Getenv("JWT_SECRET_KEY")),
        ContextKey:   "jwt", // used in private routes
        ErrorHandler: jwtError,
    }

    return jwtMiddleware.New(config)
}

func jwtError(c *fiber.Ctx, err error) error {
    // Return status 401 and failed authentication error.
    if err.Error() == "Missing or malformed JWT" {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": true,
            "msg":   err.Error(),
        })
    }

    // Return status 401 and failed authentication error.
    return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
        "error": true,
        "msg":   err.Error(),
    })
}


Enter fullscreen mode Exit fullscreen mode

↑ 目录

API 端点的路由

  • 对于公共方法:


// ./pkg/routes/private_routes.go

package routes

import (
    "github.com/gofiber/fiber/v2"
    "github.com/koddr/tutorial-go-fiber-rest-api/app/controllers"
)

// PublicRoutes func for describe group of public routes.
func PublicRoutes(a *fiber.App) {
    // Create routes group.
    route := a.Group("/api/v1")

    // Routes for GET method:
    route.Get("/books", controllers.GetBooks)              // get list of all books
    route.Get("/book/:id", controllers.GetBook)            // get one book by ID
    route.Get("/token/new", controllers.GetNewAccessToken) // create a new access tokens
}


Enter fullscreen mode Exit fullscreen mode
  • 对于私有(JWT 保护)方法:


// ./pkg/routes/private_routes.go

package routes

import (
    "github.com/gofiber/fiber/v2"
    "github.com/koddr/tutorial-go-fiber-rest-api/app/controllers"
    "github.com/koddr/tutorial-go-fiber-rest-api/pkg/middleware"
)

// PrivateRoutes func for describe group of private routes.
func PrivateRoutes(a *fiber.App) {
    // Create routes group.
    route := a.Group("/api/v1")

    // Routes for POST method:
    route.Post("/book", middleware.JWTProtected(), controllers.CreateBook) // create a new book

    // Routes for PUT method:
    route.Put("/book", middleware.JWTProtected(), controllers.UpdateBook) // update one book by ID

    // Routes for DELETE method:
    route.Delete("/book", middleware.JWTProtected(), controllers.DeleteBook) // delete one book by ID
}


Enter fullscreen mode Exit fullscreen mode
  • 对于 Swagger:


// ./pkg/routes/swagger_route.go


package routes

import (
    "github.com/gofiber/fiber/v2"

    swagger "github.com/arsmn/fiber-swagger/v2"
)

// SwaggerRoute func for describe group of API Docs routes.
func SwaggerRoute(a *fiber.App) {
    // Create routes group.
    route := a.Group("/swagger")

    // Routes for GET method:
    route.Get("*", swagger.Handler) // get one user by ID
}


Enter fullscreen mode Exit fullscreen mode
  • Not found(404)路线:


// ./pkg/routes/not_found_route.go

package routes

import "github.com/gofiber/fiber/v2"

// NotFoundRoute func for describe 404 Error route.
func NotFoundRoute(a *fiber.App) {
    // Register new special route.
    a.Use(
        // Anonimus function.
        func(c *fiber.Ctx) error {
            // Return HTTP 404 status and JSON response.
            return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
                "error": true,
                "msg":   "sorry, endpoint is not found",
            })
        },
    )
}


Enter fullscreen mode Exit fullscreen mode

↑ 目录

数据库连接

数据库连接是这个应用程序中最重要的部分(说实话,其他部分也一样)。我喜欢把这个过程分成两部分

  • 连接方法:


// ./platform/database/open_db_connection.go

package database

import "github.com/koddr/tutorial-go-fiber-rest-api/app/queries"

// Queries struct for collect all app queries.
type Queries struct {
    *queries.BookQueries // load queries from Book model
}

// OpenDBConnection func for opening database connection.
func OpenDBConnection() (*Queries, error) {
    // Define a new PostgreSQL connection.
    db, err := PostgreSQLConnection()
    if err != nil {
        return nil, err
    }

    return &Queries{
        // Set queries from models:
        BookQueries: &queries.BookQueries{DB: db}, // from Book model
    }, nil
}


Enter fullscreen mode Exit fullscreen mode
  • 所选数据库的具体连接设置:


// ./platform/database/postgres.go

package database

import (
    "fmt"
    "os"
    "strconv"
    "time"

    "github.com/jmoiron/sqlx"

    _ "github.com/jackc/pgx/v4/stdlib" // load pgx driver for PostgreSQL
)

// PostgreSQLConnection func for connection to PostgreSQL database.
func PostgreSQLConnection() (*sqlx.DB, error) {
    // Define database connection settings.
    maxConn, _ := strconv.Atoi(os.Getenv("DB_MAX_CONNECTIONS"))
    maxIdleConn, _ := strconv.Atoi(os.Getenv("DB_MAX_IDLE_CONNECTIONS"))
    maxLifetimeConn, _ := strconv.Atoi(os.Getenv("DB_MAX_LIFETIME_CONNECTIONS"))

    // Define database connection for PostgreSQL.
    db, err := sqlx.Connect("pgx", os.Getenv("DB_SERVER_URL"))
    if err != nil {
        return nil, fmt.Errorf("error, not connected to database, %w", err)
    }

    // Set database connection settings.
    db.SetMaxOpenConns(maxConn)                           // the default is 0 (unlimited)
    db.SetMaxIdleConns(maxIdleConn)                       // defaultMaxIdleConns = 2
    db.SetConnMaxLifetime(time.Duration(maxLifetimeConn)) // 0, connections are reused forever

    // Try to ping database.
    if err := db.Ping(); err != nil {
        defer db.Close() // close database connection
        return nil, fmt.Errorf("error, not sent ping to database, %w", err)
    }

    return db, nil
}


Enter fullscreen mode Exit fullscreen mode

☝️ 这种方法有助于在需要时更轻松地连接其他数据库,并始终在应用程序中保持清晰的数据存储层次结构。

↑ 目录

实用工具

  • 启动 API 服务器(正常关闭或简单开发):


// ./pkg/utils/start_server.go

package utils

import (
    "log"
    "os"
    "os/signal"

    "github.com/gofiber/fiber/v2"
)

// StartServerWithGracefulShutdown function for starting server with a graceful shutdown.
func StartServerWithGracefulShutdown(a *fiber.App) {
    // Create channel for idle connections.
    idleConnsClosed := make(chan struct{})

    go func() {
        sigint := make(chan os.Signal, 1)
        signal.Notify(sigint, os.Interrupt) // Catch OS signals.
        <-sigint

        // Received an interrupt signal, shutdown.
        if err := a.Shutdown(); err != nil {
            // Error from closing listeners, or context timeout:
            log.Printf("Oops... Server is not shutting down! Reason: %v", err)
        }

        close(idleConnsClosed)
    }()

    // Run server.
    if err := a.Listen(os.Getenv("SERVER_URL")); err != nil {
        log.Printf("Oops... Server is not running! Reason: %v", err)
    }

    <-idleConnsClosed
}

// StartServer func for starting a simple server.
func StartServer(a *fiber.App) {
    // Run server.
    if err := a.Listen(os.Getenv("SERVER_URL")); err != nil {
        log.Printf("Oops... Server is not running! Reason: %v", err)
    }
}


Enter fullscreen mode Exit fullscreen mode
  • 生成有效的 JWT:


// ./pkg/utils/jwt_generator.go

package utils

import (
    "os"
    "strconv"
    "time"

    "github.com/golang-jwt/jwt"
)

// GenerateNewAccessToken func for generate a new Access token.
func GenerateNewAccessToken() (string, error) {
    // Set secret key from .env file.
    secret := os.Getenv("JWT_SECRET_KEY")

    // Set expires minutes count for secret key from .env file.
    minutesCount, _ := strconv.Atoi(os.Getenv("JWT_SECRET_KEY_EXPIRE_MINUTES_COUNT"))

    // Create a new claims.
    claims := jwt.MapClaims{}

    // Set public claims:
    claims["exp"] = time.Now().Add(time.Minute * time.Duration(minutesCount)).Unix()

    // Create a new JWT access token with claims.
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

    // Generate token.
    t, err := token.SignedString([]byte(secret))
    if err != nil {
        // Return error, it JWT token generation failed.
        return "", err
    }

    return t, nil
}


Enter fullscreen mode Exit fullscreen mode
  • 解析并验证 JWT:


// ./pkg/utils/jwt_parser.go

package utils

import (
    "os"
    "strings"

    "github.com/golang-jwt/jwt"
    "github.com/gofiber/fiber/v2"
)

// TokenMetadata struct to describe metadata in JWT.
type TokenMetadata struct {
    Expires int64
}

// ExtractTokenMetadata func to extract metadata from JWT.
func ExtractTokenMetadata(c *fiber.Ctx) (*TokenMetadata, error) {
    token, err := verifyToken(c)
    if err != nil {
        return nil, err
    }

    // Setting and checking token and credentials.
    claims, ok := token.Claims.(jwt.MapClaims)
    if ok && token.Valid {
        // Expires time.
        expires := int64(claims["exp"].(float64))

        return &TokenMetadata{
            Expires: expires,
        }, nil
    }

    return nil, err
}

func extractToken(c *fiber.Ctx) string {
    bearToken := c.Get("Authorization")

    // Normally Authorization HTTP header.
    onlyToken := strings.Split(bearToken, " ")
    if len(onlyToken) == 2 {
        return onlyToken[1]
    }

    return ""
}

func verifyToken(c *fiber.Ctx) (*jwt.Token, error) {
    tokenString := extractToken(c)

    token, err := jwt.Parse(tokenString, jwtKeyFunc)
    if err != nil {
        return nil, err
    }

    return token, nil
}

func jwtKeyFunc(token *jwt.Token) (interface{}, error) {
    return []byte(os.Getenv("JWT_SECRET_KEY")), nil
}


Enter fullscreen mode Exit fullscreen mode

↑ 目录

测试

测试应用程序

那么,我们即将进入最重要的阶段!让我们通过测试来检查我们的 Fiber 应用。我将通过测试私有路由(JWT 保护)来向您展示其原理。

☝️ 与往常一样,我将使用 Fiber 的内置Test()方法和一个很棒的包stretchr/testify来测试 Golang 应用程序。

另外,我喜欢将测试配置放在单独的文件中,因为我不想将生产配置和测试配置混在一起。因此,我使用了名为 的文件.env.test,并将其添加到项目的根目录。

注意代码中定义路由的部分。我们调用的是应用程序的真实路由,因此在运行测试之前,需要启动数据库(例如,为了简单起见,在 Docker 容器中)。



// ./pkg/routes/private_routes_test.go

package routes

import (
    "io"
    "net/http/httptest"
    "strings"
    "testing"

    "github.com/gofiber/fiber/v2"
    "github.com/joho/godotenv"
    "github.com/koddr/tutorial-go-fiber-rest-api/pkg/utils"
    "github.com/stretchr/testify/assert"
)

func TestPrivateRoutes(t *testing.T) {
    // Load .env.test file from the root folder.
    if err := godotenv.Load("../../.env.test"); err != nil {
        panic(err)
    }

    // Create a sample data string.
    dataString := `{"id": "00000000-0000-0000-0000-000000000000"}`

    // Create access token.
    token, err := utils.GenerateNewAccessToken()
    if err != nil {
        panic(err)
    }

    // Define a structure for specifying input and output data of a single test case.
    tests := []struct {
        description   string
        route         string // input route
        method        string // input method
        tokenString   string // input token
        body          io.Reader
        expectedError bool
        expectedCode  int
    }{
        {
            description:   "delete book without JWT and body",
            route:         "/api/v1/book",
            method:        "DELETE",
            tokenString:   "",
            body:          nil,
            expectedError: false,
            expectedCode:  400,
        },
        {
            description:   "delete book without right credentials",
            route:         "/api/v1/book",
            method:        "DELETE",
            tokenString:   "Bearer " + token,
            body:          strings.NewReader(dataString),
            expectedError: false,
            expectedCode:  403,
        },
        {
            description:   "delete book with credentials",
            route:         "/api/v1/book",
            method:        "DELETE",
            tokenString:   "Bearer " + token,
            body:          strings.NewReader(dataString),
            expectedError: false,
            expectedCode:  404,
        },
    }

    // Define a new Fiber app.
    app := fiber.New()

    // Define routes.
    PrivateRoutes(app)

    // Iterate through test single test cases
    for _, test := range tests {
        // Create a new http request with the route from the test case.
        req := httptest.NewRequest(test.method, test.route, test.body)
        req.Header.Set("Authorization", test.tokenString)
        req.Header.Set("Content-Type", "application/json")

        // Perform the request plain with the app.
        resp, err := app.Test(req, -1) // the -1 disables request latency

        // Verify, that no error occurred, that is not expected
        assert.Equalf(t, test.expectedError, err != nil, test.description)

        // As expected errors lead to broken responses,
        // the next test case needs to be processed.
        if test.expectedError {
            continue
        }

        // Verify, if the status code is as expected.
        assert.Equalf(t, test.expectedCode, resp.StatusCode, test.description)
    }
}

// ...


Enter fullscreen mode Exit fullscreen mode

↑ 目录

在本地运行项目

让我们运行 Docker 容器,应用迁移并转到http://127.0.0.1:5000/swagger/index.html

结果

成功了!哇哦!🎉

↑ 目录

自我检查知识块

尽量不要偷看教程内容,并尽可能快速诚实地回答。如果忘记了什么也不用担心!继续往下读:

  • 允许应用程序在隔离环境中运行的技术名称是什么?
  • 应用程序的业务逻辑应该位于哪里(文件夹名称)?
  • 应该在项目根目录中创建什么文件来描述为应用程序创建容器的过程?
  • 什么是 UUID 以及为什么我们使用它作为 ID?
  • 我们使用什么类型的 PostgreSQL 字段来创建书籍属性模型?
  • 为什么在 Go 应用程序中使用纯 SQL 更好?
  • 您需要在哪里描述自动生成文档的 API 方法(通过 Swagger)?
  • 为什么要在测试中分离配置?

↑ 目录

进一步发展计划

为了进一步(独立)开发此应用程序,我建议考虑以下选项:

  1. 升级CreateBook方法:添加一个处理程序将图片保存到云存储服务(例如,Amazon S3 或类似的)并仅将图片 ID 保存到我们的数据库中;
  2. 升级GetBookGetBooks方法:添加处理程序以将图片 ID 从云服务更改为直接链接到此图片;
  3. 添加注册新用户的新方法(例如,注册用户可以获得一个角色,这将允许他们执行 REST API 中的某些方法);
  4. 添加新的用户授权方法(例如,授权后,用户根据其角色收到包含凭证的 JWT 令牌);
  5. 添加一个带有 Redis(或类似)的独立容器来存储这些授权用户的会话;

↑ 目录

照片

聚苯乙烯

如果你想在本博客上看到更多类似的文章,请在下方留言并订阅我。谢谢!😻

❗️ 您可以在Boosty上支持我,可以是永久支持,也可以是一次性支持。所有收益都将用于支持我的开源项目,并激励我为社区创作新的产品和文章。

在 Boosty 上支持我

当然,你也可以帮助我改善开发者的生活!只需以贡献者的身份连接到我的一个项目即可。非常简单!

我的主要项目需要您的帮助(和星星)👇

  • 🔥 gowebly:下一代 CLI 工具,可轻松使用 Go 在后端创建出色的 Web 应用程序,使用 htmx、hyperscript 或 Alpine.js 以及前端最流行的 CSS 框架。
  • create-go-app:通过运行一个 CLI 命令,创建一个具有 Go 后端、前端和部署自动化的新的生产就绪项目。

我的其他小项目:yatrgosljson2csvcsv2api

文章来源:https://dev.to/koddr/build-a-restful-api-on-go-fibre-postgresql-jwt-and-swagger-docs-in-isolated-docker-containers-475j
PREV
欢迎使用 Fiber — 一个用 Go 编写的 Express.js 风格的 Web 框架,️❤️
NEXT
成为更优秀开发者的 2 个网站