
NG是什么意思:深度解析與應(yīng)用
為您的操作系統(tǒng)安裝并運(yùn)行Docker服務(wù)。順便提一下,在本教程中我使用的是最新版本(截至當(dāng)前)v20.10.2。
好的,讓我們創(chuàng)建一個(gè)新的 Docker 網(wǎng)絡(luò),名為dev-network
:
docker network create -d bridge dev-network
將來,當(dāng)我們在獨(dú)立的容器中分別運(yùn)行數(shù)據(jù)庫和Fiber實(shí)例時(shí),會用到這個(gè)配置。如果不這樣做,兩個(gè)容器將無法相互通信。
那么,讓我們用數(shù)據(jù)庫啟動容器:
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
太棒了!現(xiàn)在我們準(zhǔn)備將原始結(jié)構(gòu)遷移到新的架構(gòu)中。這是用于遷移的文件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;
?? 為了輕松使用其他書籍屬性,我使用
JSONB
類型作為book_attrs
字段。
對于?000001_create_init_tables.down.sql
?滾動遷移腳本:
-- ./platform/migrations/000001_create_init_tables.down.sql
-- Delete tables
DROP TABLE IF EXISTS books;
好的!我們可以滾動此遷移。
?? 我建議使用golang-migrate/migrate工具,通過一個(gè)控制臺命令輕松上下數(shù)據(jù)庫遷移。
migrate \
-path $(PWD)/platform/migrations \
-database "postgres://postgres:password@localhost/postgres?sslmode=disable" \
up
在項(xiàng)目根文件夾中創(chuàng)建一個(gè)?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"]
您說得對,您正在采用兩階段容器構(gòu)建方式,并使用 Golang 1.16.x 版本。在構(gòu)建應(yīng)用程序時(shí),通過設(shè)置?CGO_ENABLED=0
?和?-ldflags="-s -w"
?參數(shù)來減小最終生成的二進(jìn)制文件的大小。否則,這對于任何 Go 項(xiàng)目來說都是最常見的Dockerfile
,您可以在任何地方使用。
構(gòu)建 Fiber Docker 鏡像的命令:
docker build -t fiber .
?? 不要忘記將
.dockerignore
文件添加到項(xiàng)目的根文件夾中,其中包含所有文件和文件夾,創(chuàng)建容器時(shí)應(yīng)忽略該文件。
從鏡像創(chuàng)建并啟動容器的命令:
docker run --rm -d \
--name dev-fiber \
--network dev-network \
-p 5000:5000 \
fiber
就像您從標(biāo)題中可能猜測到的那樣,我們不會太過關(guān)注記錄API方法的事情。僅僅因?yàn)橛邢馭wagger這樣出色的工具可以為我們完成所有工作!
好了,我們已經(jīng)準(zhǔn)備好了所有必要的配置文件和工作環(huán)境,我們知道我們要?jiǎng)?chuàng)建什么。現(xiàn)在是時(shí)候打開我們最喜歡的 IDE 并開始編寫代碼了。
?? 請注意,我將在代碼的注釋中直接解釋一些關(guān)鍵點(diǎn),而不是在文章正文中進(jìn)行說明。
在實(shí)現(xiàn)模型之前,我總是創(chuàng)建一個(gè)具有 SQL 結(jié)構(gòu)的遷移文件(來自第 3 章)。這使得一次呈現(xiàn)所有必要的模型字段變得更加容易。
// ./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"
}
// ...
?? 我建議使用?
google/uuid
?包來生成唯一的ID,因?yàn)檫@是一種更為通用的做法,可以有效地增強(qiáng)您的應(yīng)用程序?qū)τ诔R姅?shù)字暴力攻擊(如猜測ID)的防護(hù)能力。
但這還不是全部。您需要編寫兩個(gè)特殊方法:
Value()
,用于返回結(jié)構(gòu)體的 JSON 編碼表示形式;Scan()
,用于將 JSON 編碼值解碼到結(jié)構(gòu)字段中;它們可能看起來像這樣:
// ...
// 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)
}
好的,讓我們定義在將輸入傳遞給控制器??業(yè)務(wù)邏輯之前需要檢查的字段:
ID
字段,用于檢查有效的UUID;這些字段是我們最關(guān)心的,因?yàn)樵谀承﹫鼍跋滤鼈儠挠脩裟抢飦淼轿覀冞@里。順便說一句,這就是為什么我們不僅驗(yàn)證輸入數(shù)據(jù)的有效性,而且還將它們視為必需項(xiàng)(required)的原因。
這就是我實(shí)現(xiàn)驗(yàn)證器的方式:
// ./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
}
?? 我使用go-playground/validator
v10
來發(fā)布此功能。
為了不損失性能,我傾向于使用不含額外抽象層、類似于原生SQL查詢的方式,避免使用那些會增加復(fù)雜性和潛在性能開銷的ORM(對象關(guān)系映射)框架或類似的包。它能夠讓我們更深入地理解應(yīng)用程序的工作原理,這將有助于我們在將來優(yōu)化數(shù)據(jù)庫查詢時(shí)避免犯下愚蠢的錯(cuò)誤!
// ./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
}
方法原理:
// ./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,
})
}
// ...
方法原理:
Header
有有效的 JWT;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,
})
}
// ...
方法原理:
Header
有有效的 JWT;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)
}
// ...
方法原理:
Header
有有效的 JWT;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)
}
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,
})
}
這是我們整個(gè)應(yīng)用程序中的核心功能。它負(fù)責(zé)從?.env
?文件中加載配置,設(shè)置 Swagger,創(chuàng)建 Fiber 實(shí)例,連接所需的端點(diǎn)組,并最終啟動 API 服務(wù)器。
// ./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)
}
由于在這個(gè)應(yīng)用程序中,我希望能展示如何利用JWT對某些查詢進(jìn)行授權(quán),因此我們需要編寫一個(gè)額外的中間件來進(jìn)行驗(yàn)證。
// ./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(),
})
}
// ./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
}
// ./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
}
// ./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
}
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",
})
},
)
}
數(shù)據(jù)庫連接無疑是該應(yīng)用程序中最為關(guān)鍵的一環(huán)(事實(shí)上,對于任何應(yīng)用程序而言都是如此)。我傾向于將這個(gè)連接過程拆分為兩個(gè)步驟。
// ./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
}
// ./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
}
?? 這種方法有助于在需要時(shí)更輕松地連接其他數(shù)據(jù)庫,并始終在應(yīng)用程序中保持清晰的數(shù)據(jù)存儲層次結(jié)構(gòu)。
// ./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)
}
}
// ./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
}
// ./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
}
那么,我們已經(jīng)到了最重要的階段了!讓我們通過測試來檢查我們的 Fiber 應(yīng)用程序。我會通過測試受JWT保護(hù)的私有路由來為您展示其工作原理。
?? 一如既往,我將利用Fiber的內(nèi)置
Test()
方法以及出色的stretchr/testify
包來測試Golang應(yīng)用程序。
此外,我傾向于將測試配置保存在單獨(dú)的文件中,以避免將生產(chǎn)配置與測試配置混雜在一起。因此,我使用名為.env.test
的文件,并將其添加到項(xiàng)目的根目錄中。
注意代碼中定義路由的部分。我們正在調(diào)用應(yīng)用程序的真實(shí)路由,因此在運(yùn)行測試之前,您需要啟動數(shù)據(jù)庫(例如,為了簡單起見,在 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)
}
}
// ...
讓我們運(yùn)行 Docker 容器、應(yīng)用遷移并轉(zhuǎn)到http://127.0.0.1:5000/swagger/index.html
:
好的,我會盡量不參考教程文字,快速且誠實(shí)地回答。如果遺忘了某些內(nèi)容,也希望不要影響到我們的交流。那么,我們可以開始了!
為了進(jìn)一步(獨(dú)立)開發(fā)此應(yīng)用程序,我建議考慮以下選項(xiàng):
CreateBook
方法:我們需要添加一個(gè)處理程序,用于將圖片上傳到云存儲服務(wù)(例如Amazon S3或其他類似服務(wù)),并且只將圖片ID保存在我們的數(shù)據(jù)庫中。GetBook
方法GetBooks
:添加一個(gè)處理程序,將云服務(wù)中的圖片ID更改為直接鏈接到該圖片;原文鏈接:https://dev.to/koddr/build-a-restful-api-on-go-fiber-postgresql-jwt-and-swagger-docs-in-isolated-docker-containers-475j