4. Resource oriented design

Переход от глаголов к существительным: Что такое ROD?

В операционной системе Unix иерархическая файловая система всегда служила образцом инженерной ясности: структурированное дерево каталогов и файлов, где каждая сущность — это строгое существительное (/usr/bin/cat, /etc/passwd), а любые манипуляции над ними осуществляются через универсальный, стандартизированный набор системных вызовов (open, read, write, unlink). Ни одному системному разработчику в здравом уме не пришло бы в голову создавать специальный системный вызов CreateNewUserRecordAndSaveToDisk().

Однако на заре карьеры веб-разработчики почти неизбежно повторяют одну и ту же ошибку: они проектируют сетевые интерфейсы в парадигме процедурного удаленного вызова (Remote Procedure Call — RPC). Мы привыкли мыслить императивными глаголами и действиями: «получить список пользователей», «создать новый заказ», «заблокировать спамера». В структуре URL это выливается в хаотичный набор эндпоинтов: /getUsers, /createNewOrder, /banUser.

Resource-Oriented Design (ROD — ресурсно-ориентированное проектирование) — это строгая архитектурная методология, являющаяся естественным и логическим развитием классических принципов REST (3. REST. Основные принципы). В парадигме ROD мы объявляем войну глаголам в путях и начинаем структурировать предметную область исключительно через ресурсы (существительные).

Общепризнанным отраслевым стандартом ресурсно-ориентированного дизайна сегодня выступает Google API Design Guide. Изначально созданный для высокопроизводительных gRPC-сервисов, этот стандарт безупречно транслируется на HTTP/REST, формируя единообразные, интуитивно понятные и долговечные контракты для распределенных систем любого масштаба.

Базовые концепции ROD

Архитектура ROD упорядочивает предметную область через три фундаментальные категории:

  1. Ресурс (Resource): Обособленный объект данных, обладающий четкой идентичностью, схемой и жизненным циклом. Ресурс может включать в себя состояние и содержать вложенные дочерние ресурсы.
  2. Коллекция (Collection): Упорядоченный список однотипных ресурсов. В URL коллекция всегда именуется во множественном числе: например, /users или /orders.
  3. Документ (Document): Конкретный единичный экземпляр ресурса внутри коллекции, адресуемый через уникальный идентификатор: например, /users/123.

В парадигме ROD все ваше API представляет собой строгое, логически сбалансированное иерархическое дерево:

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    API["API Root"]:::network-->|"Коллекция"|Users["users"]
    Users-->|"Документ"|User123["123"]:::process
    User123-->|"Суб-коллекция"|Orders["orders"]:::process
    Orders-->|"Документ"|Order99["99"]:::process
    User123-->|"Custom method"|Ban["ban"]:::process
  

Роутинг в Go: Цена красивых URL

Когда входящий HTTP-пакет попадает в сетевой сокет с адресом GET /users/123/orders/99, перед маршрутизатором рантайма Go встают две вычислительные задачи:

  1. Детерминированно сопоставить входящий путь с зарегистрированным обработчиком (Handler).
  2. Извлечь динамические переменные сегментов пути (123 и 99) и передать их в прикладную бизнес-логику с минимальным оверхедом на память.

Исторически, вплоть до версии Go 1.21, стандартный пакет net/http предлагал до смешного примитивный ServeMux, способный распознавать лишь точные совпадения (Exact Match) либо префиксы поддеревьев. Разработчикам приходилось подключать сторонние библиотеки: тяжеловесный gorilla/mux (использовавший регулярные выражения) или высокоскоростной go-chi/chi (построенный на базе префиксного дерева).

Начиная с версии Go 1.22, стандартная библиотека совершила долгожданный скачок: встроенный http.ServeMux получил первоклассную поддержку HTTP-методов и параметризованных шаблонов прямо «из коробки»:

Go
package main

import (
    "fmt"
    "net/http"
)

func handleOrderGet(w http.ResponseWriter, r *http.Request) {
    // Идиоматичное извлечение параметров в Go 1.22+ без внешних зависимостей
    userID := r.PathValue("userID")
    orderID := r.PathValue("orderID")

    fmt.Fprintf(w, "User: %s, Order: %s", userID, orderID)
}

func main() {
    mux := http.NewServeMux()

    // Метод и шаблон пути декларируются в единой строке
    mux.HandleFunc("GET /users/{userID}/orders/{orderID}", handleOrderGet)

    http.ListenAndServe(":8080", mux)
}

Custom Methods: Когда CRUD недостаточно

Канонический REST опирается на базовый CRUD и стандартные глаголы протокола HTTP: GET (чтение), POST (создание), PUT (полная замена), PATCH (частичное обновление), DELETE (удаление).

Однако реальный бизнес редко укладывается в рамки манипуляции свойствами сущностей. Как выразить в строгой парадигме REST операции, не являющиеся прямым созданием или редактированием записей в таблице?

  • Сбросить забытый пароль учетной записи.
  • Принудительно заблокировать аккаунт за нарушение правил.
  • Применить скидочный промокод к сформированной корзине.

Распространенный антипаттерн новичков — попытка замаскировать бизнес-действие под частичное обновление статуса через метод PATCH:

JSON
// АНТИПАТТЕРН: Неявный побочный эффект под видом мутации поля
// PATCH /users/123
{
  "status": "banned",
  "reason": "spam"
}

Этот подход порождает запутанный процедурный код внутри обработчика. Хендлер превращается в лабиринт условных операторов: он вынужден сравнивать старое и новое состояние полей, вычисляя: «Ага, статус изменился на banned — значит, нужно послать почтовое уведомление, сбросить кэш в Redis и разорвать все активные соединения WebSocket».

В ресурсно-ориентированном дизайне (по спецификации Google API Design Guide) для подобных операций зарезервированы Custom Methods (Пользовательские методы). Они всегда используют сетевой метод POST, так как вызывают недетерминированные побочные эффекты, и добавляются в конец канонического ресурса через специальный разделитель (двоеточие либо слэш):

  • POST /users/123:ban (Стандартный синтаксис Google Cloud API)
  • POST /users/123/ban (Широко распространенный альтернативный вариант)

Такой подход сохраняет строгость ресурса (/users/123), но недвусмысленно декларирует бизнес-намерение операции, избавляя код от неявных сайд-эффектов.

Разделение ресурсов и проблема PATCH в Go

Наиболее острая инженерная проблема реализации Resource Oriented Design в языке Go проявляется при попытке поддержать операцию частичного обновления ресурса (PATCH).

Представим типовой DTO (Data Transfer Object) для частичного редактирования профиля пользователя. Клиент, отправляющий JSON-документ, может выразить четыре принципиально разных семантических намерения:

  1. "age": 25 — обновить возраст, записав число 25.
  2. "age": 0 — обновить возраст, записав валидное числовое значение 0.
  3. Поле age полностью отсутствует в теле запроса — не трогать возраст пользователя в базе данных вообще.
  4. "age": null — сбросить значение возраста в NULL (если колонка в БД допускает обнуление).

Если мы наивно опишем структуру Go традиционным способом:

Go
type UpdateUserRequest struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

То при получении от клиента документа {"name": "Ivan"} стандартный пакет encoding/json послушно инициализирует поле Age нулевым значением по умолчанию (zero value для типа int — это 0).

Бэкенд оказывается в семантическом тупике: рантайм Go не способен определить, хотел ли клиент действительно сбросить возраст в ноль ("age": 0), либо он просто не передавал поле age в запросе!

Итог

  1. Resource Oriented Design превращает хаос императивных RPC-вызовов в гармоничную иерархическую структуру ресурсов (существительных).
  2. Используйте существительные во множественном числе для коллекций и уникальные идентификаторы для документов (/users/123).
  3. Для нетиповых бизнес-операций, выходящих за рамки CRUD, задействуйте спецификацию Custom Methods (POST /users/123:ban или POST /users/123/ban).
  4. Избегайте глубокой вложенности путей (не более двух уровней) во избежание хрупкости контрактов и деградации производительности СУБД.
  5. При реализации методов частичного обновления PATCH учитывайте семантику Zero Value в Go: применяйте структуры с указателями либо кастомные структуры с реализацией UnmarshalJSON.

В этой лекции мы зафиксировали, что кастомные методы всегда опираются на метод POST, а безопасное чтение — на GET. Это разделение фундаментально для сетевой устойчивости. В следующей статье мы досконально препарируем семантику HTTP-глаголов, их безопасность и важнейшее свойство распределенных систем — идемпотентность: 5. HTTP методы и идемпотентность.