Golang Web服务与数据格式解析指南

Golang Web服务与数据格式解析指南 大家好,

我即将创建我的第一个Web服务,并且需要确认如何在请求特定JSON格式的用户界面(UI)与以不同方式组织数据的关系型数据库之间管理数据。

为了稍作说明,我将举一个简单的例子,一个过度简化的酒店数据库:

Screenshot 2022-04-20 at 18.18.54

一个酒店有一些字段,它有房间和图片,这里没什么特别的。

为了反序列化来自酒店数据库的SQL响应,我会创建这些简单的结构体…

type Hotel struct {
  HotelId string `json:"hotel_id"`
  Name    string `json:"name"`
  Address string `json:"address"`
  NbRooms int32 `json:"nb_rooms"`
  Pictures []Picture `json:"pictures"`
  Rooms []Room `json:"hotel_rooms"`
}

type Picture struct {
  Url    string `json:"url"`
  Order  string `json:"order"`
  Description string `json:"description"`
}

type Room struct {
  RoomNb    string `json:"room_nb"`
  Capacity  string `json:"name"`
  Surface   string `json:"surface"`
  Price    float32 `json:"price"`
}

…另一方面,当用户界面执行请求来填充其视图时,它只希望检索必要的数据,并且格式可能并不总是与上述结构体匹配。

例如,我们可以想象用户界面希望获取可用的酒店(取决于日期、价格等),但只想要一些特定信息,比如名称、地址、房间数量,仅此而已。出于某种原因,用户界面不希望获取任何类型的图片或关于酒店的更多信息。

{
  "hotel_list":[
  {
    "hotel_id": "0001",
    "name": "The Nice Hotel",
    "address": "...",
    "nb_rooms": 4,
  },
  {
    "hotel_id": "0001",
    "name": "The Nice Hotel",
    "address": "..." "nb_rooms": 4,
  }
]

为了满足用户界面的需求,酒店API将需要其他类型的结构体来匹配用户界面的数据要求:

var hotelList = []HotelListItem{}

type HotelListItem struct {
  HotelId string `json:"hotel_id"`
  Name string `json:"name"`
  Address string `json:"address"`
  NbRooms int32 `json:"nb_rooms"`
}

这意味着模型将负责将发送到数据库的SELECT请求的结构体结果,转换为适合用户界面期望的响应的结构体。换句话说,预订服务的“领域”部分不仅包含CRUD方法,还包含用于完成这项工作的转换方法。

你们对这些假设有什么看法?

我们可以想象,“转换”函数的数量可能会增长并占用大量空间,因为用户界面正在构建数百个不同的视图,这将使数据传输对象(DTO)变得越来越大……即使我们可以想象有几个视图使用相同的JSON响应格式,在这种情况下是否有其他策略可以实施?

这里有一个链接指向一个图表,稍作说明:

The Hotel API.drawio

如果您能推荐关于这个主题的读物,我将不胜感激。

非常感谢您的帮助


更多关于Golang Web服务与数据格式解析指南的实战教程也可以访问 https://www.itying.com/category-94-b0.html

6 回复

“我们可以想象,‘转换’函数的数量可能会增长并占用大量空间,因为UI正在构建数百种不同的视图,这将使DTO变得越来越大……即使我们可以想象多个视图使用相同的JSON响应格式,在这种情况下是否有其他策略可以实施?”

听起来你真正想要的——但可能没有意识到——是实现一个GraphQL服务器。

GraphQL的设计目的正是为了解决你刚才提到的问题,即客户端可以根据其提供的GraphQL查询来决定输出数据的外观。

如果你不熟悉GraphQL,我刚刚搜索到的这个链接或许能帮助你:

希望这能有所帮助。

更多关于Golang Web服务与数据格式解析指南的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


BigBoulard:

函数可能会变得庞大并占用大量空间,因为用户界面正在构建数百个不同的视图,这会使数据传输对象(DTO)变得越来越大……即使我们可以设想多个视图使用相同的JSON响应格式,在这种情况下是否有其他策略可以实施?

有一些技术可以减少对结构体和视图的需求。可以看看 SQL 驱动 sqlx,它不需要任何结构体。还有一些方法可以减少 API 中的端点数量。我创建了一些测试,也许至少能给你提供另一个视角。如果记录很多,使用 AJAX 可以减少页面闪烁。

// 示例代码:使用 sqlx 查询数据库
func queryUsers(db *sqlx.DB) error {
    var users []map[string]interface{}
    err := db.Select(&users, "SELECT * FROM users")
    if err != nil {
        return err
    }
    // 处理 users...
    return nil
}

可能有四到五种情况,用户界面需要获取酒店信息,但同时还需要其他不同的信息……我在想,这难道不应该是API中“服务”部分(在控制器、服务、领域这种架构中)的工作吗?例如,酒店服务可以向酒店领域请求一些数据,并根据传入的HTTP请求,选择性地从其他领域(如“客房”领域或“餐厅”领域)获取其他信息,并负责汇总来自这些领域的所有数据,然后创建HTTP响应和JSON负载。你觉得呢?在我看来,这应该符合关注点分离原则。我们在现实生活中的API项目中会这样做吗?

是的——这取决于你的前端。我通常倾向于让我的API调用稍微通用一些,然后由前端框架组合多次调用来生成特定页面所需的所有数据。谷歌有一个通用的设计指南,其中包含一些他们偏离标准方法而使用自定义方法的例子:

Google Cloud

自定义方法 | Cloud API | Google Cloud

Google Cloud

虽然不完全是全面的,但我认为你提到的一些问题可能会通过这个得到解决。如果你想的话,可以通读那个API设计指南。它包含很多有用的信息,并且是与平台无关的。

你好 @Dean_Davidson

感谢分享你的经验。为了提供更多细节,我实际上在使用 pgx。 在我看来,这个 API 中有 3 种类型的结构体:

  1. 用于接收 JSON 负载数据(反序列化)的请求结构体
  2. 用于接收 SQL 查询响应的响应结构体
  3. 可选的……用于生成返回给客户端的 JSON 响应的结构体。这一点曾是我的疑问,因为有时这些结构体可能就是第 2 点中的那些,但我不确定是否总是如此。如果没必要的话,我不想返回酒店的所有字段,因为我们不想耗尽客户端的电池,对吧 🙂

为了进一步说明,API 需要能够处理许多不同的传入查询。因此,为了加快实现速度,POST 和 PATCH 请求包含了需要添加或更新的内容的动态定义,例如:

POST /api/hotel
{
  [{
      field:"name"
      value:"The Madison Hotel"
    },
   {
     field:"address",
     value:"21 E 27th St NYC"
   }]
}

在这里,我将 JSON 反序列化到结构体中

type HotelReq struct{
   Fields []FieldValue
}
type FieldValue struct{
   Field string      `json:"field" binding:"required"`
   Value interface{} `json:"value" binding:"required"`
}

然后,我使用一点反射来创建插入或更新的字符串请求,并提交给数据库客户端。

INSERT INTO HOTEL(name, address) values ('The Madison Hotel', '21 E 27th St NYC')

更新操作同理。

现在我需要处理查询部分(并且我想我需要添加一些检查,比如检查提供的字段是否存在以及特殊字符……以避免任何 SQL 注入)(暂且不谈认证服务器,那是另一个话题 :D)。可能会有 4 到 5 种情况,UI 想要获取酒店信息,但同时需要其他不同的信息……考虑到这一点……我在想,这会不会是 API 的“服务”部分(在控制器、服务、领域这种架构中)应该完成的工作。例如,酒店服务可以向酒店领域请求一些数据,并可选地(取决于传入的 HTTP 请求)向其他领域(如“房间”领域或另一个“餐厅”领域)请求其他信息,并负责从这些领域收集所有数据,然后创建 HTTP 响应以及 JSON 负载。你觉得呢?在我看来,这应该遵循 SoC(关注点分离)原则。在实际的 API 项目中,我们会这样做吗?

非常感谢。

我曾使用过与您描述的 XListItem 类型结构体类似的方法。以下是我通常采用的做法:对于简单的 CRUD 类型操作,例如检索单个酒店及其关联数据,直接使用模型并生成 SQL(可以在项目中手动编写,也可以使用 Gorm 或 SQLX)。对于任何相对高级的操作,我通常更倾向于手动编写 SQL。因此,在您的示例中,我会为 HotelPictureRoom 生成 SQL。以 Gorm 类型为例:

func GetHotel(hotelID string) Hotel {
    var hotel Hotel 
    db.First(&hotel, 1)
    return hotel
}

但是,对于 HotelListItem,我可能会希望编写自定义查询,并直接将其扫描到 HotelListItem 结构体切片中。同样以 Gorm 类型为例:

// GetHotelList 根据条件获取酒店列表项。借鉴其他语言的经验,
// 如果我想在更具体的 UI 上下文中考虑这些列表项,有时会将它们视为 ViewModel。
// 例如 `HotelSearchItem` 和 `HotelDetailView` 或类似的概念。
func GetHotelList(priceMax int64, capacityMin int32) []HotelListItem {
	result := hotelList = []HotelListItem{}
	db.Raw(`
select
    h.id as hotel_id,
    h.name,
    h.address,
    r.room_nb
from hotel h
    join room r on r.hotel_id = h.id
where r.price <= ?
    and r.capacity >= ?`, priceMax, capacityMin).Scan(&result)
	return result
}

为什么这样做?

  • 因为我通常不信任 ORM 处理过多的连接或任何过于复杂的查询(尽管这个例子并不复杂,但通常我的查询比这更复杂)。Gorm 生成的 SQL 比我用过的任何其他 ORM 都更合理,但对于复杂查询,你往往需要与 ORM 作斗争。
  • 因为这使得手动调整非 CRUD 查询变得容易。这在小型应用中并不重要,但在大型生产应用中却非常重要(至少根据我的经验;您的结果可能有所不同!)。
  • 这里没有任何魔法,对于任何未来的开发者来说,正在发生什么以及如何调整都非常明显。如果需要,我可以直接将 SQL 复制/粘贴到编辑器中运行。

其他选项包括:

  • 为所有内容创建数据库视图/存储过程,并建立与之对应的模型。您可以使用 volatiletech/sqlboiler 来生成它们。
  • 您也可以研究一下 Go GraphQL 实现。我在这方面经验较少。

在Golang Web服务中处理不同数据格式是常见需求。以下是针对你场景的专业实现方案:

1. 核心解决方案:使用DTO模式

你的方法基本正确,但可以优化转换逻辑。以下是改进的实现:

// 领域模型(数据库结构)
type Hotel struct {
    HotelID  string    `db:"hotel_id"`
    Name     string    `db:"name"`
    Address  string    `db:"address"`
    NbRooms  int32     `db:"nb_rooms"`
    Pictures []Picture `db:"-"`
    Rooms    []Room    `db:"-"`
}

// DTO结构体
type HotelListItemDTO struct {
    HotelID string `json:"hotel_id"`
    Name    string `json:"name"`
    Address string `json:"address"`
    NbRooms int32  `json:"nb_rooms"`
}

type HotelDetailDTO struct {
    HotelID  string         `json:"hotel_id"`
    Name     string         `json:"name"`
    Address  string         `json:"address"`
    NbRooms  int32          `json:"nb_rooms"`
    Pictures []PictureDTO   `json:"pictures"`
    Rooms    []RoomListItem `json:"rooms"`
}

// 转换方法
func (h *Hotel) ToListItemDTO() HotelListItemDTO {
    return HotelListItemDTO{
        HotelID: h.HotelID,
        Name:    h.Name,
        Address: h.Address,
        NbRooms: h.NbRooms,
    }
}

func (h *Hotel) ToDetailDTO() HotelDetailDTO {
    pictureDTOs := make([]PictureDTO, len(h.Pictures))
    for i, p := range h.Pictures {
        pictureDTOs[i] = p.ToDTO()
    }
    
    roomItems := make([]RoomListItem, len(h.Rooms))
    for i, r := range h.Rooms {
        roomItems[i] = r.ToListItem()
    }
    
    return HotelDetailDTO{
        HotelID:  h.HotelID,
        Name:     h.Name,
        Address:  h.Address,
        NbRooms:  h.NbRooms,
        Pictures: pictureDTOs,
        Rooms:    roomItems,
    }
}

2. 使用组合减少重复代码

// 基础结构体
type HotelBase struct {
    HotelID string `json:"hotel_id"`
    Name    string `json:"name"`
    Address string `json:"address"`
    NbRooms int32  `json:"nb_rooms"`
}

// 组合DTO
type HotelListItemDTO struct {
    HotelBase
}

type HotelDetailDTO struct {
    HotelBase
    Pictures []PictureDTO   `json:"pictures,omitempty"`
    Rooms    []RoomListItem `json:"rooms,omitempty"`
}

// 使用嵌入
type HotelSearchResult struct {
    HotelBase
    AvailableRooms int32   `json:"available_rooms"`
    MinPrice       float32 `json:"min_price"`
}

3. 使用函数式转换器

type HotelTransformer func(*Hotel) interface{}

var transformers = map[string]HotelTransformer{
    "list":   transformToList,
    "detail": transformToDetail,
    "search": transformToSearch,
}

func transformToList(h *Hotel) interface{} {
    return HotelListItemDTO{
        HotelID: h.HotelID,
        Name:    h.Name,
        Address: h.Address,
        NbRooms: h.NbRooms,
    }
}

func transformToDetail(h *Hotel) interface{} {
    return HotelDetailDTO{
        HotelID:  h.HotelID,
        Name:     h.Name,
        Address:  h.Address,
        NbRooms:  h.NbRooms,
        Pictures: transformPictures(h.Pictures),
        Rooms:    transformRooms(h.Rooms),
    }
}

// 在handler中使用
func GetHotels(w http.ResponseWriter, r *http.Request) {
    viewType := r.URL.Query().Get("view")
    transformer, exists := transformers[viewType]
    if !exists {
        transformer = transformers["list"]
    }
    
    hotels := fetchHotelsFromDB()
    result := make([]interface{}, len(hotels))
    for i, h := range hotels {
        result[i] = transformer(h)
    }
    
    json.NewEncoder(w).Encode(map[string]interface{}{
        "hotel_list": result,
    })
}

4. 使用结构体标签控制JSON输出

type HotelResponse struct {
    HotelID  string          `json:"hotel_id"`
    Name     string          `json:"name"`
    Address  string          `json:"address,omitempty"`  // 为空时不输出
    NbRooms  int32           `json:"nb_rooms"`
    Pictures []Picture       `json:"pictures,omitempty"`
    Rooms    []Room          `json:"rooms,omitempty"`
    
    // 使用自定义标签控制不同视图
    IncludePictures bool     `json:"-" query:"include_pictures"`
    IncludeRooms    bool     `json:"-" query:"include_rooms"`
}

// 自定义MarshalJSON
func (h HotelResponse) MarshalJSON() ([]byte, error) {
    type Alias HotelResponse
    aux := &struct {
        *Alias
        Pictures []Picture `json:"pictures,omitempty"`
        Rooms    []Room    `json:"rooms,omitempty"`
    }{
        Alias: (*Alias)(&h),
    }
    
    if h.IncludePictures {
        aux.Pictures = h.Pictures
    }
    if h.IncludeRooms {
        aux.Rooms = h.Rooms
    }
    
    return json.Marshal(aux)
}

5. 使用SQL查询优化

// 根据视图类型构建查询
func buildHotelQuery(viewType string) string {
    switch viewType {
    case "list":
        return `SELECT hotel_id, name, address, nb_rooms FROM hotels`
    case "detail":
        return `
            SELECT h.hotel_id, h.name, h.address, h.nb_rooms,
                   p.url, p.order, p.description,
                   r.room_nb, r.capacity, r.surface, r.price
            FROM hotels h
            LEFT JOIN pictures p ON h.hotel_id = p.hotel_id
            LEFT JOIN rooms r ON h.hotel_id = r.hotel_id
        `
    default:
        return `SELECT hotel_id, name FROM hotels`
    }
}

// 使用sqlx扫描到不同结构体
func fetchHotels(viewType string) (interface{}, error) {
    query := buildHotelQuery(viewType)
    
    switch viewType {
    case "list":
        var hotels []HotelListItemDTO
        err := db.Select(&hotels, query)
        return hotels, err
    case "detail":
        var hotels []HotelDetailDTO
        err := db.Select(&hotels, query)
        return hotels, err
    default:
        var hotels []HotelBase
        err := db.Select(&hotels, query)
        return hotels, err
    }
}

6. 使用代码生成工具

对于大量DTO,可以考虑使用代码生成:

// 使用go:generate指令
//go:generate dto-generator -type=Hotel -output=hotel_dto.go

// 生成的代码示例
type HotelListResponse struct {
    Hotels []HotelListItem `json:"hotels"`
    Total  int             `json:"total"`
    Page   int             `json:"page"`
}

type HotelSearchResponse struct {
    Hotels []HotelSearchItem `json:"hotels"`
    Filters map[string]interface{} `json:"filters"`
}

这些方案提供了可扩展的方式来处理不同UI视图的数据格式需求。DTO模式在Golang Web开发中是标准实践,结合结构体组合和转换器模式可以有效管理复杂度。

回到顶部