ogen: OpenAPI v3 генератор для Go
Чем больше кода, тем больше багов. Проект 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
, если bark
— Dog
, а если не нашли, то будет ошибка 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
- Удобные сообщения об ошибках в схеме