使用Golang构建基础但设计周全的RESTful API指南

使用Golang构建基础但设计周全的RESTful API指南 我看到了Go语言的诸多优点。它"简单",标准库也很棒。但似乎所有的内容都停留在"看……我可以用标准库启动一个Web服务器并提供单个端点!"这种表面层次。要找到一篇描述如何组织(Web)应用程序的好文章或书籍似乎极为罕见。

任何构建过具有一定规模(中小型)项目的开发者都知道,在构建软件时通常会有一些不想做的事情。我想知道这些是否是我在以往语言经历中学到的"坏"习惯?也许在Go中我需要换一种思维方式?

让我们举一个简单的例子。我主要来自Web应用程序背景。我学到的一点是不要在控制器中填充大量业务逻辑。相反,我倾向于将大量逻辑推回到模型中。我通过多种方式实现这一点,最近最喜欢的方式是使用CQRS模式。然而,Go很少提及CQRS或DDD相关内容。基本上所有内容都展示如何创建"控制器"(HandlerFunc)、查询数据库并返回数据。这对其他Web开发者来说可以接受吗?在Go中这样做合适吗?

另一个例子……假设我正在构建一个允许用户创建待办事项的简单应用程序。在我熟悉的语言中,我通常不习惯通过API直接暴露我的领域对象。

例如,如果我有一个如下所示的领域对象:

public class TodoItem {
	public int Id { get; set; } // 数据库专用(内部)
	public string Name { get; set; }
	public bool IsProcessed { get; set; } // 内部属性
	public bool IsCompleted { get; set;
}

根据我的经验,我宁愿不向外界暴露IdIsProcessed。通常在C#中,这是通过ViewModel来管理的,或者当仅通过API暴露时,我更喜欢称之为ResourceModel

我的控制器通常负责将领域模型转换为资源模型。通过使用某种映射库(AutoMapper)或简单地手动映射它们。

然而,我从未在Go教程中看到过这方面的提及?我甚至找不到任何人讨论类似的内容。这在Go中不是问题吗?向外界暴露整个领域对象被认为是可接受的吗?这似乎不是语言特定的问题,而是API设计和保持领域封装的问题。所以当我在Go社区中看不到任何人讨论这些事情时,我感到担忧。

有人能帮我理解我遗漏了什么吗?我应该努力按照DDD或CQRS类型的模式进行组织吗?如何构建比"在控制器中从数据库获取数据并直接返回"更复杂的东西?有人能提供一个他们构建的RESTful API链接供我学习吗?

非常感谢任何反馈。


更多关于使用Golang构建基础但设计周全的RESTful API指南的实战教程也可以访问 https://www.itying.com/category-94-b0.html

2 回复

Kat Zien 已经多次就此主题进行演讲。你可以在 YouTube 上找到相关视频,例如 https://youtu.be/oL6JBUk6tj0。幻灯片和示例代码位于 https://github.com/katzien/talks/blob/master/how-do-you-structure-your-go-apps/

更多关于使用Golang构建基础但设计周全的RESTful API指南的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


在Go中构建结构良好的RESTful API时,确实需要考虑架构模式和组织方式。虽然Go社区倾向于简洁,但分层架构和关注点分离同样重要。以下是一个基于你需求的实现示例:

项目结构示例

todo-api/
├── cmd/
│   └── api/
│       └── main.go
├── internal/
│   ├── domain/
│   │   └── todo.go
│   ├── handlers/
│   │   └── todo.go
│   ├── services/
│   │   └── todo.go
│   ├── repositories/
│   │   └── todo.go
│   └── dto/
│       └── todo.go
└── go.mod

领域模型与DTO分离

// internal/domain/todo.go
package domain

type TodoItem struct {
    ID          int    `json:"-"`
    Name        string `json:"name"`
    IsProcessed bool   `json:"-"`
    IsCompleted bool   `json:"isCompleted"`
}

// internal/dto/todo.go
package dto

type CreateTodoRequest struct {
    Name string `json:"name" validate:"required"`
}

type TodoResponse struct {
    ID          int    `json:"id"`
    Name        string `json:"name"`
    IsCompleted bool   `json:"isCompleted"`
}

服务层处理业务逻辑

// internal/services/todo.go
package services

import (
    "context"
    "todo-api/internal/domain"
    "todo-api/internal/dto"
    "todo-api/internal/repositories"
)

type TodoService struct {
    repo repositories.TodoRepository
}

func NewTodoService(repo repositories.TodoRepository) *TodoService {
    return &TodoService{repo: repo}
}

func (s *TodoService) CreateTodo(ctx context.Context, req dto.CreateTodoRequest) (*dto.TodoResponse, error) {
    todo := &domain.TodoItem{
        Name:        req.Name,
        IsProcessed: false,
        IsCompleted: false,
    }
    
    err := s.repo.Create(ctx, todo)
    if err != nil {
        return nil, err
    }
    
    return &dto.TodoResponse{
        ID:          todo.ID,
        Name:        todo.Name,
        IsCompleted: todo.IsCompleted,
    }, nil
}

func (s *TodoService) GetTodo(ctx context.Context, id int) (*dto.TodoResponse, error) {
    todo, err := s.repo.GetByID(ctx, id)
    if err != nil {
        return nil, err
    }
    
    return &dto.TodoResponse{
        ID:          todo.ID,
        Name:        todo.Name,
        IsCompleted: todo.IsCompleted,
    }, nil
}

仓储层抽象数据访问

// internal/repositories/todo.go
package repositories

import (
    "context"
    "database/sql"
    "todo-api/internal/domain"
)

type TodoRepository interface {
    Create(ctx context.Context, todo *domain.TodoItem) error
    GetByID(ctx context.Context, id int) (*domain.TodoItem, error)
    Update(ctx context.Context, todo *domain.TodoItem) error
    Delete(ctx context.Context, id int) error
}

type todoRepository struct {
    db *sql.DB
}

func NewTodoRepository(db *sql.DB) TodoRepository {
    return &todoRepository{db: db}
}

func (r *todoRepository) Create(ctx context.Context, todo *domain.TodoItem) error {
    query := `INSERT INTO todos (name, is_processed, is_completed) VALUES ($1, $2, $3) RETURNING id`
    return r.db.QueryRowContext(ctx, query, todo.Name, todo.IsProcessed, todo.IsCompleted).Scan(&todo.ID)
}

func (r *todoRepository) GetByID(ctx context.Context, id int) (*domain.TodoItem, error) {
    query := `SELECT id, name, is_processed, is_completed FROM todos WHERE id = $1`
    row := r.db.QueryRowContext(ctx, query, id)
    
    var todo domain.TodoItem
    err := row.Scan(&todo.ID, &todo.Name, &todo.IsProcessed, &todo.IsCompleted)
    if err != nil {
        return nil, err
    }
    
    return &todo, nil
}

处理器层处理HTTP请求

// internal/handlers/todo.go
package handlers

import (
    "encoding/json"
    "net/http"
    "strconv"
    "todo-api/internal/dto"
    "todo-api/internal/services"
)

type TodoHandler struct {
    service *services.TodoService
}

func NewTodoHandler(service *services.TodoService) *TodoHandler {
    return &TodoHandler{service: service}
}

func (h *TodoHandler) CreateTodo(w http.ResponseWriter, r *http.Request) {
    var req dto.CreateTodoRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }
    
    todo, err := h.service.CreateTodo(r.Context(), req)
    if err != nil {
        http.Error(w, "Failed to create todo", http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(todo)
}

func (h *TodoHandler) GetTodo(w http.ResponseWriter, r *http.Request) {
    idStr := r.URL.Query().Get("id")
    id, err := strconv.Atoi(idStr)
    if err != nil {
        http.Error(w, "Invalid todo ID", http.StatusBadRequest)
        return
    }
    
    todo, err := h.service.GetTodo(r.Context(), id)
    if err != nil {
        http.Error(w, "Todo not found", http.StatusNotFound)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(todo)
}

应用入口点

// cmd/api/main.go
package main

import (
    "database/sql"
    "log"
    "net/http"
    "todo-api/internal/handlers"
    "todo-api/internal/repositories"
    "todo-api/internal/services"
    
    _ "github.com/lib/pq"
)

func main() {
    db, err := sql.Open("postgres", "postgres://user:pass@localhost/todo_db?sslmode=disable")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()
    
    todoRepo := repositories.NewTodoRepository(db)
    todoService := services.NewTodoService(todoRepo)
    todoHandler := handlers.NewTodoHandler(todoService)
    
    http.HandleFunc("/todos", func(w http.ResponseWriter, r *http.Request) {
        switch r.Method {
        case http.MethodPost:
            todoHandler.CreateTodo(w, r)
        case http.MethodGet:
            todoHandler.GetTodo(w, r)
        default:
            http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        }
    })
    
    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

这种架构实现了:

  • 领域模型与API响应的分离
  • 清晰的关注点分离(处理器、服务、仓储)
  • 内部属性(ID、IsProcessed)不暴露给外部
  • 可测试的独立组件

Go社区确实倾向于更简单的解决方案,但对于中等规模的项目,这种分层架构提供了更好的可维护性和可测试性。

回到顶部