Чем больше кода, тем больше багов. Проект ogen генерирует код по OpenAPI спецификации, избавляя от сотен (или даже тысяч) строк скучного шаблонного кода на Go, который приходится писать вручную с риском допустить опечатку или ошибку.


Генератор пишет клиент и сервер, а разработчику остаётся только реализовать интерфейс для сервера. И никаких interface{} и рефлексии, только строгая типизация и кодогенерация.


Я расскажу, чем ogen отличается от существующих решений и почему стоит его попробовать.


Строгая типизация


Генерируется строго-типизированный клиент и сервер, чем-то похоже на gRPC. Дополняется описанием из спецификации в комментариях.


Для сервера генерируется интерфейс, который нужно имплементировать:


// Handler handles operations described by OpenAPI v3 specification.
type Handler interface {
    // AddPet implements addPet operation.
    //
    // Creates a new pet in the store. Duplicates are allowed.
    //
    // POST /pets
    AddPet(ctx context.Context, req NewPet) (AddPetRes, error)
    // DeletePet implements deletePet operation.
    //
    // Deletes a single pet based on the ID supplied.
    //
    // DELETE /pets/{id}
    DeletePet(ctx context.Context, params DeletePetParams) (DeletePetRes, error)
    // FindPetByID implements find pet by id operation.
    //
    // Returns a user based on a single ID, if the user does not have access to the pet.
    //
    // GET /pets/{id}
    FindPetByID(ctx context.Context, params FindPetByIDParams) (FindPetByIDRes, error)
    // FindPets implements findPets operation.
    //
    // Returns all pets from the system that the user has access to
    //
    // GET /pets
    FindPets(ctx context.Context, params FindPetsParams) (FindPetsRes, error)
    // PatchPet implements patchPet operation.
    //
    // Patch a pet.
    //
    // PATCH /pets/{id}
    PatchPet(ctx context.Context, req UpdatePet, params PatchPetParams) (PatchPetRes, error)
}

Клиент генерируется аналогично:


func (c *Client) AddPet(ctx context.Context, request NewPet) (res AddPetRes, err error) {}

// PatchPet invokes patchPet operation.
//
// Patch a pet.
//
// PATCH /pets/{id}  
func (c *Client) PatchPet(ctx context.Context, request UpdatePet, params PatchPetParams) (res PatchPetRes, err error) {} 

Валидация


В ogen поддержаны maxLength, minLength, pattern (regex), minimum, maximum и другие валидаторы строк, массивов, объектов и чисел, для которых статически генерируются проверки на клиенте и сервере.


UpdatePet:  
  type: object  
  properties:  
    name:  
      type: string  
      maxLength: 25  
      minLength: 3  
      pattern: '^[a-zA-Z0-9]+$'  
    tag:  
      maxLength: 10  
      minLength: 1  
      pattern: '^[a-zA-Z0-9]+$'  
      nullable: true  
      type: string

Неизвестные и обязательные поля


Более того, эффективно проверяется, что обязательные поля заданы, а неизвестные (если не разрешены) не передаются:


// Validate required fields.
var failures []validate.FieldError
for i, mask := range [1]uint8{
    0b00000001,
} {
    if result := (requiredBitSet[i] & mask) ^ mask; result != 0 {
        // Mask only required fields and check equality to mask using XOR.
        //
        // If XOR result is not zero, result is not equal to expected, so some fields are missed.
        // Bits of fields which would be set are actually bits of missed fields.
        missed := bits.OnesCount8(result)
        for bitN := 0; bitN < missed; bitN++ {
            bitIdx := bits.TrailingZeros8(result)
            fieldIdx := i*8 + bitIdx
            var name string
            if fieldIdx < len(jsonFieldsNameOfNewPet) {
                name = jsonFieldsNameOfNewPet[fieldIdx]
            } else {
                name = strconv.Itoa(fieldIdx)
            }
            failures = append(failures, validate.FieldError{
                Name:  name,
                Error: validate.ErrFieldRequired,
            })
            // Reset bit.
            result &^= 1 << bitIdx
        }
    }
}

Enum


Поддержаны полностью, для них генерируются константы и проверяются значения и на клиенте, и на сервере:


// Ref: #/components/schemas/Kind  
type Kind string  

const (  
   KindCat   Kind = "Cat"  
   KindDog   Kind = "Dog"  
   KindFish  Kind = "Fish"  
   KindBird  Kind = "Bird"  
   KindOther Kind = "Other"  
)

func (s Kind) Validate() error {
    switch s {
    case "Cat":
        return nil
    case "Dog":
        return nil
    case "Fish":
        return nil
    case "Bird":
        return nil
    case "Other":
        return nil
    default:
        return errors.Errorf("invalid value: %v", s)
    }
}

// Decode decodes Kind from json.
func (s *Kind) Decode(d *jx.Decoder) error {
    if s == nil {
        return errors.New("invalid: unable to decode Kind to nil")
    }
    v, err := d.StrBytes()
    if err != nil {
        return err
    }
    // Try to use constant string.
    switch Kind(v) {
    case KindCat:
        *s = KindCat
    case KindDog:
        *s = KindDog
    case KindFish:
        *s = KindFish
    case KindBird:
        *s = KindBird
    case KindOther:
        *s = KindOther
    default:
        *s = Kind(v)
    }

    return nil
}

Тот же deepmap/oapi-codegen не проверяет значения enum-ов, только генерируя новый тип и константы.


Без указателей


Там, где это возможно.


В большинстве случаев, для опциональных (или nullable) полей в Go принято использовать указатели:


type Pet struct {
    // Name of the pet
    Name string `json:"name"`

    // Type of the pet
    Tag *string `json:"tag,omitempty"`
}

Это пусть и привычный, но семантический костыль:


  • Можно легко получить null pointer exception, привет The Billion Dollar Mistake
  • Больше нагрузка на сборщик мусора, особенно если объектов много или они вложенные (например, слайс из таких []Pet)
  • Невозможно выразить nullable optional, когда может быть передано три состояния: пустота, null и заполненное значение. Особенно полезно для PATCH-операций.

В ogen это решается через генерацию обобщенных типов (дженерики пробовали использовать, но в этом случае они не подошли):


// Ref: #/components/schemas/NewPet  
type NewPet struct {  
   Name string    `json:"name"`  
   Tag  OptString `json:"tag"`  
}

// OptString is optional string.
type OptString struct {  
   Value string  
   Set   bool  
}

С optional nullable deepmap/oapi-codegen не справился:


// UpdatePet defines model for UpdatePet.  
type UpdatePet struct {  
   Name *string `json:"name,omitempty"`  
   Tag  *string `json:"tag"`  
}

А ogen сгенерировал дополнительный тип OptNilString:


// Ref: #/components/schemas/UpdatePet  
type UpdatePet struct {  
   Name OptString    `json:"name"`  
   Tag  OptNilString `json:"tag"`  
}

// OptNilString is optional nullable string.
type OptNilString struct {  
   Value string  
   Set   bool  
   Null  bool  
}

С помощью OptNilString можно выразить и отсутствие значения, и null, и значение пустой строки, и просто строку.


Массивы


Для массивов дополнительный тип можно не генерировать, изменяя семантику nil значения слайса в зависимости от схемы. Например, если поле optional, то nil будет означать отсутствие значения, а если nullable, то null. Для optional nullable поля уже придется сгенерировать обертку.


JSON Без Рефлексии


Отказ от рефлексии достигается за счет того, что ogen не использует стандартный encoding/json с его ограничениями по скорости и возможностям, а генерирует статические энкодеры и декодеры:


// Encode encodes string as json.
func (o OptNilString) Encode(e *jx.Encoder) {  
   if !o.Set {  
      return  
   }  
   if o.Null {  
      e.Null()  
      return  
   }  
   e.Str(string(o.Value))  
}  

Это помогает сделать работу с json эффективнее и гибче, например, декодинг поля в несколько проходов для поддержки oneOf с дискриминатором (сначала парсится значение поля-дискриминатора, а потом уже значение целиком) и без (сначала обходятся все поля и тип выбирается по уникальным полям).


В качестве библиотеки для работы с json используется go-faster/jx, сильно переработанный и оптимизированный форк jsoniter-а (может парсить почти гигабайт json логов в секунду на ядро, а писать — больше двух).


Без внешнего роутера


Для того, чтобы не выбирать между echo и chi, ogen использует свой, эффективный статически сгенерированный роутер на основе radix tree:


// ...
// Static code generated router with unwrapped path search.
switch {
default:
    if len(elem) == 0 {
        break
    }
    switch elem[0] {
    case '/': // Prefix: "/pets"
        if l := len("/pets"); len(elem) >= l && elem[0:l] == "/pets" {
            elem = elem[l:]
        } else {
            break
        }

        if len(elem) == 0 {
            switch r.Method {
            case "GET":
                s.handleFindPetsRequest([0]string{}, w, r)
            case "POST":
                s.handleAddPetRequest([0]string{}, w, r)
            default:
                s.notAllowed(w, r, "GET,POST")
            }

            return
        }
        switch elem[0] {
        case '/': // Prefix: "/"
            if l := len("/"); len(elem) >= l && elem[0:l] == "/" {
                elem = elem[l:]
            } else {
                break
            }
// ...

Статический роутер позволяет компилятору сделать множество оптимизаций: убрать лишние проверки на длину строки, сгенерировать эффективный код для сравнения префиксов вместо runtime.cmpstring, использовать оптимальный алгоритм поиска нужного case в switch вместо бинарного поиска, и т.д.


Всё это позволяет достичь скорости в несколько раз выше, чем у chi и echo (код бенчмарка):


name                        time/op
Router/GithubStatic/ogen-4  18.7ns ± 3%
Router/GithubStatic/chi-4    146ns ± 2%
Router/GithubStatic/echo-4  73.7ns ± 9%
Router/GithubParam/ogen-4   34.0ns ± 3%
Router/GithubParam/chi-4     251ns ± 3%
Router/GithubParam/echo-4    118ns ± 2%
Router/GithubAll/ogen-4     56.6µs ± 3%
Router/GithubAll/chi-4       323µs ± 3%
Router/GithubAll/echo-4      173µs ± 4%

name                        alloc/op
Router/GithubStatic/ogen-4   0.00B
Router/GithubStatic/chi-4    0.00B
Router/GithubStatic/echo-4   0.00B
Router/GithubParam/ogen-4    0.00B
Router/GithubParam/chi-4     0.00B
Router/GithubParam/echo-4    0.00B
Router/GithubAll/ogen-4      0.00B
Router/GithubAll/chi-4       0.00B
Router/GithubAll/echo-4      0.00B

OneOf


Возьмем что-то вроде такой тип-суммы:


Dog:  
  type: object  
  required:  
    - kind  
  properties:  
    kind:  
      $ref: '#/components/schemas/Kind'  
    bark:  
      type: string  
Cat:  
  type: object  
  required:  
    - kind  
  properties:  
    kind:  
      $ref: '#/components/schemas/Kind'  
    meow:  
      type: string  
SomePet:  
  type: object  
  discriminator:  
    propertyName: kind  
  oneOf:  
    - $ref: '#/components/schemas/Dog'  
    - $ref: '#/components/schemas/Cat'

Её ogen сгенерирует следующим образом:


// Ref: #/components/schemas/Cat
type Cat struct {
    Kind Kind      `json:"kind"`
    Meow OptString `json:"meow"`
}

// Ref: #/components/schemas/Dog
type Dog struct {
    Kind Kind      `json:"kind"`
    Bark OptString `json:"bark"`
}

// Ref: #/components/schemas/SomePet
// SomePet represents sum type.
type SomePet struct {
    Type SomePetType // switch on this field
    Dog  Dog
    Cat  Cat
}

И будет использовать дискриминатор сразу при парсинге:


// func (s *SomePet) Decode(d *jx.Decoder) error
if err := d.Capture(func(d *jx.Decoder) error {
    return d.ObjBytes(func(d *jx.Decoder, key []byte) error {
        if found {
            return d.Skip()
        }
        switch string(key) {
        case "kind":
            typ, err := d.Str()
            if err != nil {
                return err
            }
            switch typ {
            case "Cat":
                s.Type = CatSomePet
                found = true
            case "Dog":
                s.Type = DogSomePet
                found = true
            default:
                return errors.Errorf("unknown type %s", typ)
            }
            return nil
        }
        return d.Skip()
    })
}); err != nil {
    return errors.Wrap(err, "capture")
}
if !found {
    return errors.New("unable to detect sum type variant")
}
switch s.Type {
case DogSomePet:
    if err := s.Dog.Decode(d); err != nil {
        return err
    }
case CatSomePet:
    if err := s.Cat.Decode(d); err != nil {
        return err
    }
default:
    return errors.Errorf("inferred invalid type: %s", s.Type)
}

Тот же deepmap/oapi-codegen предполагает дополнительный ручной вызов (ну и на момент написания статьи, сгененированный им код сломан):


// SomePet defines model for SomePet.
type SomePet struct {
    union json.RawMessage
}

func (t SomePet) Discriminator() (string, error) {
    var discriminator struct {
        Discriminator string `json:"kind"`
    }
    err := json.Unmarshal(t.union, &discriminator)
    return discriminator.Discriminator, err
}

// AsCat returns the union data inside the SomePet as a Cat
func (t SomePet) AsCat() (Cat, error) {
    var body Cat
    err := json.Unmarshal(t.union, &body)
    return body, err
}

Видимо, пользователь должен сам вызвать Discriminator, написать switch по возможным значениям и вызывать AsT() (T, error) в зависимости от значений.


Без дискриминатора


Более того, ogen может работать вообще без поля-дискриминатора, выбирая тип по уникальным полям:


var found bool
if err := d.Capture(func(d *jx.Decoder) error {
    return d.ObjBytes(func(d *jx.Decoder, key []byte) error {
        switch string(key) {
        case "bark":
            match := DogSomePet
            if found && s.Type != match {
                s.Type = ""
                return errors.Errorf("multiple oneOf matches: (%v, %v)", s.Type, match)
            }
            found = true
            s.Type = match
        case "meow":
            match := CatSomePet
            if found && s.Type != match {
                s.Type = ""
                return errors.Errorf("multiple oneOf matches: (%v, %v)", s.Type, match)
            }
            found = true
            s.Type = match
        }
        return d.Skip()
    })
}); err != nil {
    return errors.Wrap(err, "capture")
}

Если есть поле meow, то тип Cat, если barkDog, а если не нашли, то будет ошибка unable to detect sum type variant.


Я не уверен, что знаю какой либо генератор для OpenAPI, который бы смог справиться с такой задачей, как минимум на Go.


Сообщения об ошибках


Подробные цветные сообщения об ошибках с контекстом и ссылкой на конкретное место:


$ go generate
  - petstore-expanded.yaml:218:17 -> resolve: can't find value for "components/schemas/Do1"
          217 |       oneOf:    
        → 218 |         - $ref: '#/components/schemas/Do1'      
              |                 ↑       
          219 |         - $ref: '#/components/schemas/Cat'      
          220 |         
          221 |     UpdatePet: 

В итоге


Основные преимущества ogen, которые я вижу:


  • Строгая типизация клиента и сервера
  • Валидация
  • Поддержка oneOf и anyOf, в том числе без дискриминаторов
  • Возможность представить nullable optional
  • Встроенный быстрый статический роутер
  • Быстрая работа с json
  • Удобные сообщения об ошибках в схеме

Комментарии (4)


  1. Glandit
    08.11.2022 08:59

    Генератор выглядит интересно, но вот со своим роутером какое-то странное решение. Что, если мне захочется добавить какие-нибудь технические запросы, не описанные в спеке, типа гошных метрик для Прометея? Генераторы на основе Echo вполне позволяют это сделать. Да и добавление своих middleware выглядит неудобно. Предлагается, как я понял, вместо простого перечисления, наслаивать капустный кочан из хендлеров со сгенерированным роутером в центре. Короче, решение со сторонним роутером выглядит более предпочтительным.


    1. RPG18
      09.11.2022 00:00

      ogen генерит стандартный интерфейс https://github.com/ogen-go/ogen/blob/main/examples/ex_petstore/oas_router_gen.go#L12 который можно засунуть в gin, echo, chi


  1. denim
    08.11.2022 12:34

    понравились сообщения об ошибках. openapi-generator выдает какой то невменяемый стектрейс


  1. anaxita
    09.11.2022 21:35

    Круто, попробуем!

    А как обстоят дела с загрузкой и выгрузкой файлов?