使用Golang建立RESTful API的最佳實踐案例

banq 發表於 2022-07-31
Go

以下建立一個 REST API 應用的最佳實踐

庫包:

完整程式碼示例


模型
使用ORM模型,在本例中,gorm使用該模型將結構轉換為 SQL 語句。例如:

type Workspace struct {
  ID        uuid.UUID      `gorm:"type:uuid;default:uuid_generate_v4()" json:"id"`
  Name      string         `gorm:"not null,type:text" json:"name"`
  CreatedAt time.Time      `gorm:"autoCreateTime" json:"created_at"`
  UpdatedAt time.Time      `gorm:"autoUpdateTime" json:"updated_at"`
  DeletedAt gorm.DeletedAt `gorm:"index,->" json:"-"`
}

  • ID用作主鍵,使用隨機 UUID 而不是自增整數。
  • name是模型的一個屬性,它可以是任何名稱下的任何有限數量的東西。
  • 建立模型時的CreatedAt由 gorm 自動處理。
  • UpdatedAt模型更新時,由 gorm 自動處理。
  • DeletedAt這是 gorm 處理軟刪除的方式。它需要是 gorm.DeletedAt 的型別



儲存庫
儲存庫是一種設計模式,可以幫助我們進行CRUD操作。

我們先定義一個介面:

type Repository interface {
  Configure(*gorm.DB)
  List(after time.Time, limit int) (any, error)
  Get(id any) (any, error)
  Create(entity any) (any, error)
  Update(id any, entity any) (bool, error)
  Delete(id any) (bool, error)
}

然後他們使用 gorm 作為 ORM 的實現:

func (repository *WorkspaceRepository) List(after time.Time, limit int) (any, error) {
  var wc model.WorkspaceCollection
  order := "created_at"
  err := r.db.Limit(limit).Order(order).Where(fmt.Sprintf("%v > ?", order), after).Limit(limit).Find(&wc).Error

  return wc, err
}

func (repository *WorkspaceRepository) Get(id any) (any, error) {
  var w *model.Workspace

  err := r.db.Where("id = ?", id).First(&w).Error

  return w, err
}

func (repository *WorkspaceRepository) Create(entity any) (any, error) {
  w := entity.(*model.Workspace)

  err := r.db.Create(w).Error

  return w, err
}

func (repository *WorkspaceRepository) Update(id any, entity any) (bool, error) {
  w := entity.(*model.Workspace)

  if err := r.db.Model(w).Where("id = ?", id).Updates(w).Error; err != nil {
    return false, err
  }

  return true, nil
}

func (repository *WorkspaceRepository) Delete(id any) (bool, error) {
  if err := r.db.Delete(&model.Workspace{}, "id = ?", id).Error; err != nil {
    return false, err
  }

  return true, nil
}


路由
為了處理 HTTP 路由,我們需要建立一些稱為控制器的函式,在這個例子中,使用了Gin

func (server *Server) registerRoutes() {
  var router = server.router

  workspaces := router.Group("/workspaces")
  {
    workspaces.GET("", GetWorkspaces)
    workspaces.POST("", CreateWorkspace)
    workspaces.GET("/:uuid", GetWorkspace)
    workspaces.PATCH("/:uuid", UpdateWorkspace)
    workspaces.DELETE("/:uuid", DeleteWorkspace)
  }
}


控制器
控制器負責處理 HTTP 呼叫並返回有用的資訊,這些資訊可以是帶有來自 ORM 的物件的 JSON 或錯誤。讓我們實現所有的 CRUD 操作:

func GetWorkspaceRepository(ctx *gin.Context) repository.Repository {
  return ctx.MustGet("RepositoryRegistry").(*repository.RepositoryRegistry).MustRepository("WorkspaceRepository")
}

func GetWorkspaces(ctx *gin.Context) {
  var q = query{}

  if err := ctx.ShouldBindQuery(&q); err != nil {
    HandleError(err, ctx)
    return
  }

  entities, err := GetWorkspaceRepository(ctx).List(q.After, q.Limit)
  if err != nil {
    HandleError(err, ctx)
    return
  }

  WriteHAL(ctx, http.StatusOK, entities.(model.WorkspaceCollection).ToHAL(ctx.Request.URL.Path, ctx.Request.URL.Query()))
}

func GetWorkspace(ctx *gin.Context) {
  p := params{}

  ctx.ShouldBindUri(&p)

  if err := validate.Struct(p); err != nil {
    HandleError(err, ctx)
    return
  }

  entity, err := GetWorkspaceRepository(ctx).Get(p.ID)
  if err != nil {
    HandleError(err, ctx)
    return
  }

  WriteHAL(ctx, http.StatusOK, entity.(*model.Workspace).ToHAL(ctx.Request.URL.Path))
}

func CreateWorkspace(ctx *gin.Context) {
  body := model.Workspace{}

  if err := ctx.BindJSON(&body); err != nil {
    HandleError(err, ctx)
    return
  }

  entity, err := GetWorkspaceRepository(ctx).Create(&body)
  if err != nil {
    HandleError(err, ctx)
    return
  }

  workspace := entity.(*model.Workspace)
  selfHref, _ := url.JoinPath(ctx.Request.URL.Path, workspace.ID.String())
  WriteHAL(ctx, http.StatusCreated, workspace.ToHAL(selfHref))
}

func UpdateWorkspace(ctx *gin.Context) {
  p := params{}

  ctx.ShouldBindUri(&p)

  if err := validate.Struct(p); err != nil {
    HandleError(err, ctx)
    return
  }

  body := model.Workspace{}

  if err := ctx.BindJSON(&body); err != nil {
    HandleError(err, ctx)
    return
  }

  repository := GetWorkspaceRepository(ctx)

  _, err := repository.Update(p.ID, &body)
  if err != nil {
    HandleError(err, ctx)
    return
  }

  entity, err := repository.Get(p.ID)
  if err != nil {
    HandleError(err, ctx)
    return
  }

  WriteHAL(ctx, http.StatusOK, entity.(*model.Workspace).ToHAL(ctx.Request.URL.Path))
}

func DeleteWorkspace(ctx *gin.Context) {
  p := params{}

  ctx.ShouldBindUri(&p)

  if err := validate.Struct(p); err != nil {
    HandleError(err, ctx)
    return
  }

  _, err := GetWorkspaceRepository(ctx).Delete(p.ID)
  if err != nil {
    HandleError(err, ctx)
    return
  }

  WriteNoContent(ctx)
}


HAL 連結
API 是永恆的。一旦將 API 整合到生產應用程式中,就很難進行可能破壞現有整合的重大更改

Web API 設計原則:使用 API 和微服務交付價值
在實踐中,很難打破 API 契約,因為 API 使用者會生你的氣。新版本的 API 不實用;沒有人會轉移到另一個 API。
考慮到這一點,正式名稱為 JSON 超文字應用程式語言的HAL Links嘗試以一種沒有痛苦的方式解決 API 遷移。API 應該在self欄位中返回資源的表示,而不是對資源使用硬編碼的位置。

{
  "_links": {
    "self": "/workspaces/6424f2b7-8094-48de-a68c-24bbb7de1faa"
  }
}

...


實現非常簡單:

func (model *Workspace) ToHAL(selfHref string) (root hal.Resource) {
  root = hal.NewResourceObject()
  root.AddData(model)

  selfRel := hal.NewSelfLinkRelation()
  selfLink := &hal.LinkObject{Href: selfHref}
  selfRel.SetLink(selfLink)
  root.AddLink(selfRel)

  return
}


問題
你可能已經注意到每個錯誤都會呼叫HandleError函式,這個函式負責透過返回application/problem+json將錯誤變成更有意義的東西。

func HandleError(err error, ctx *gin.Context) {
  var p *problem.Problem

  switch {
  case errors.Is(err, gorm.ErrRecordNotFound):
    p = problem.New(
      problem.Title("Record Not Found"),
      problem.Type("errors:database/record-not-found"),
      problem.Detail(err.Error()),
      problem.Status(http.StatusNotFound),
    )
    break
  default:
    p = problem.New(
      problem.Title("Bad Request"),
      problem.Type("errors:http/bad-request"),
      problem.Detail(err.Error()),
      problem.Status(http.StatusBadRequest),
    )
    break
  }

  p.WriteTo(ctx.Writer)
}

例如,如果after引數不是RFC 3339的格式。它將返回一個錯誤

$ http localhost:8000/workspaces?after=0
HTTP/1.1 400 Bad Request
Content-Length: 164
Content-Type: application/problem+json
Date: Sat, 30 Jul 2022 18:23:54 GMT
{
    "detail": "parsing time \Ŕ\" as \񓟶-01-02T15:04:05Z07:00\": cannot parse \Ŕ\" as \񓟶\"",
    "status": 400,
    "title": "Bad Request",
    "type": "errors:http/bad-request"
}


注意Content-Type,它是HTTP APIs的問題細節的mimetype,正文中有一個詳細的錯誤。

Full example