Чем больше кода, тем больше багов. Проект 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
- Удобные сообщения об ошибках в схеме