Чем больше кода, тем больше багов. Проект 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
- Удобные сообщения об ошибках в схеме
ссылка на оригинал статьи https://habr.com/ru/post/694090/
Добавить комментарий