![Иллюстрация процесса разработки. Не понимаю, что мешает ехать Иллюстрация процесса разработки. Не понимаю, что мешает ехать](https://habrastorage.org/getpro/habr/upload_files/889/cd6/a9a/889cd6a9ae5de663f190df23944b9670.png)
Привет! Я Илья, мне 19 лет, и последние полтора года я занимаюсь продуктовой разработкой на языке Go.
В этой статье расскажу про процесс разработки кодогенератора, про проблемы, с которыми столкнулся и про их решения. Также в конце подведем итоги и поговорим про саму библиотеку VK-API-SCHEMA и про то, почему на основе такой схемы сделать что-то хорошее не получится.
Как-то, путешествуя по всемирная информационной сети интернет, я наткнулся на репозиторий с JSON документацией API ВКонтакте. К ссылке на гитхаб было приложено следующее сообщение:
Схема позволяет работать с кодогенераторами, такой подход дает вам возможность уделить больше времени внутренней логике вашего приложения, сэкономив ресурсы на изучении формата данных API.
Существующие реализации SDK либо написаны вручную, либо с генераторами, но недоделаны, поэтому решил создать свою библиотеку. Посмотреть исходники можно здесь.
Реализация
Пару моментов перед реализацией:
В Golang единственная возможность понять, что поле не просто с дефолтным значением, а пустое - это использовать указатели. Хотя с протобафом такое работает и никто не жалуется, в случае с API это может навредить, поэтому поля с is_required==false будем генерировать с указателем, и слайсы тоже.
В схеме могут быть указаны дополнительные параметры к полям, например maxLength для строки, format (json, int64), minimum/maximum для чисел и другие. Такая информация пишется в комментарий для поля. Можно было бы использовать по другому, например, написать валидатор параметров запроса, но смысла в этом я особого не вижу.
Каждый метод, помимо обычной error, возвращает возможную ApiError ошибку от API. Не стоит мешать ошибку клиента, обработки JSON или любую другую внутреннюю ошибку с ошибкой в ответе от ВК.
Итак, определимся с основными сущностями. У нас есть:
Объекты, они же структуры и поля. Бывают разного типа. Такие, как обычный объект, allOf или oneOf имеют поля и непростую логику генерации. В эту же категорию попадают структуры запросов/ответов. Также имеем enum и тип обычное поле (SimpleField), у которого есть просто название и тип.
Объект со ссылкой на другой объект будем трактовать как обычное поле с типом-ссылкой. Нужно преобразовать название ссылочного типа также, как преобразовывается название у внешних объектов, таким образом мы получим реальное название типа.
Коды ошибок. Они хранятся в vk-api-schema с описанием и ссылками на subcode'ы.
Методы. У каждого метода свои параметры запросов и несколько вариантов ответов, зависящих от входных данных.
Объекты
На этапе чтения объектов появилась проблема бесконечной вложенности типов. То есть, из-за того, что мы парсим JSON, объект любого типа может как поле находится в другом объекте любого типа. Здесь на помощь нам приходят интерфейсы. В нашем случае достаточно двух абстракций: что-то, что будет генерироваться на самом верхнем уровне, и что-то, что будет генерироваться внутри. Такой подход дает возможность независимо от типа данных генерировать объекты неограниченной вложенности:
type Genner interface {
Gen() (gen string)
}
type NestedGenner interface {
nestedGen(nestingLvl int, objName string) (nestedGen string, additionalGen string)
}
Скрытый текст
Во время чтения JSON'а в Golang данные сохраняются в map'у, а ее особенность в том, что эти данные хранятся там в случайном порядке. Поэтому для генерируемых структур, методов и всего, что сохраняется в map я добавил метод GetName для сортировки полученного массива по имени, вы же не хотите каждый раз, исполняя go run, получать код в случайном порядке, верно?
P.S. Здесь нужно было читать JSON, как строку, например, используя fastjson. Но понял я это слишком поздно.
У каждого типа объекта своя генерация на внешнем и внутреннем уровне. Например, если enum объект сам по себе, то для него достаточно сгенерировать новый тип и объявить константы со значениями:
// Ads_AccessRolePublic Current user's role
type Ads_AccessRolePublic string
const (
Ads_AccessRolePublic_Manager Ads_AccessRolePublic = "manager"
Ads_AccessRolePublic_Reports Ads_AccessRolePublic = "reports"
)
Но когда enum объявляется внутри объекта, нужно создать новый внешний enum тип и включить его как поле с только что созданным типом:
type Apps_Scope_Name string
const (
Apps_Scope_Name_Friends Apps_Scope_Name = "friends"
Apps_Scope_Name_Photos Apps_Scope_Name = "photos"
Apps_Scope_Name_Video Apps_Scope_Name = "video"
Apps_Scope_Name_Pages Apps_Scope_Name = "pages"
Apps_Scope_Name_Status Apps_Scope_Name = "status"
Apps_Scope_Name_Notes Apps_Scope_Name = "notes"
Apps_Scope_Name_Wall Apps_Scope_Name = "wall"
Apps_Scope_Name_Docs Apps_Scope_Name = "docs"
Apps_Scope_Name_Groups Apps_Scope_Name = "groups"
Apps_Scope_Name_Stats Apps_Scope_Name = "stats"
Apps_Scope_Name_Market Apps_Scope_Name = "market"
)
// Apps_Scope Scope description
type Apps_Scope struct {
// Scope name
Name Apps_Scope_Name `json:"name"`
// Scope title
Title *string `json:"title,omitempty"`
}
Таким образом каждый объектный тип должен имплементировать как внутренний, так и внешний интерфейсы генерации.
Многие внутренние объекты повторяют друг друга в названиях, и когда нам требуется создать новый внутренний объект, чтобы сослаться на него, стреляет ошибка ’Something’ redeclared in this package. Поэтому такие внутренние объекты ObjNested в глобальном объекте ObjMain генерируются с названием ObjMain + ObjNested.
Надеюсь, что мои коллеги по цеху не съедят меня за использование модифицированного snake_case, потому как появляются такой методы, как appWidgets.getGroupImageUploadServer или appWidgets.getAppImageUploadServer, а также такие объекты, как newsfeed_item_holiday_recommendations_block_header или adsweb_getAdCategories_response_categories_category. Это я не придумал, а читать это слитно не очень хочется, поэтому в названиях будем склеивать все, кроме первого слова, подразумевающее раздел использования. А для генерации внутренних объектов, как глобальные будем склеивать названия через нижнее подчеркивание как тут:
type Messages_KeyboardButtonActionOpenApp_Type string
const (
Messages_KeyboardButtonActionOpenApp_Type_OpenApp Messages_KeyboardButtonActionOpenApp_Type = "open_app"
)
// Messages_KeyboardButtonActionOpenApp Description of the action, that should be performed on button click
type Messages_KeyboardButtonActionOpenApp struct {
Type Messages_KeyboardButtonActionOpenApp_Type `json:"type"`
...
}
С генерацией простых структур с полями все понятно. С allOf типом тоже: поля генерируются стандартно, а сторонние структуры можем добавить с помощью включения в структуру:
type Friends_UserXtrPhone struct {
Users_UserFull
// User phone
Phone *string `json:"phone,omitempty"`
}
А вот с oneOf не все так просто. Да, можно использовать пустой интерфейс для хранения любого типа, но тогда для чтения API ответа придется type accert'ить в строку, а оттуда анмаршалить в структуру. Сделать switch/case, чтобы знать в какую структуру заполнить JSON тоже не получится, потому что анмаршалинг не будет жаловаться на лишнее JSON поле. Поэтому для хранения oneOf определим структуру со слайсом байт, чтобы у пользователя была возможность самому определять, по каким признакам и куда переложить JSON:
type LeadForms_Answer_Answer struct {
raw []byte
}
func (o *LeadForms_Answer_Answer) MarshalJSON() ([]byte, error) {
return o.raw, nil
}
func (o *LeadForms_Answer_Answer) UnmarshalJSON(body []byte) (err error) {
o.raw = body
return nil
}
func (o LeadForms_Answer_Answer) Raw() []byte {
return o.raw
}
Упомяну отдельно про JSON-Schema тип `array`. В нем в поле `items` описывается тип объекта для списка. Хоть на уровне схемы это является вложенностью, нам достаточно провалится до корневого объекта, сохранить для него уровень вложенности и сгенерировать насчитанное количество квадратных скобок.
Ошибки
Тут все просто. Файл схемы представляет собой перечисление названий с кодом ошибки и описанием. Генерируем им отдельный числовой тип, а затем сами коды ошибок. На сайте с документацией ошибок я нашел почти для каждой ошибки способы их лечения и генерирую их вместе с описанием:
type Subcode int
const (
TooManyCommunities Subcode = 1
UserReachedLinkedAccountsLimit Subcode = 1000
ServiceUuidLinkWithAnotherUser Subcode = 1001
)
type ErrorCode int
var (
...
// Error_Request Invalid request.
// May contain one of the listed subcodes: [ UserReachedLinkedAccountsLimit, ServiceUuidLinkWithAnotherUser ].
// Solution: Check the request syntax (https://vk.com/dev/api_requests) and used parameters list (it can be found on a method description page).
// IsGlobal: true
Error_Request ErrorCode = 8
...
)
Методы
Структуры ответов у методов API уже описаны и сгруппированы в отдельные объекты в файле responses.json, их мы сгенерировали как объекты с полями. Но поля запроса нам переданы отдельно. Изначально генератор писал их, как отдельный параметр функции, но позже оказалось, что существуют методы с 20+ полями, поэтому от такого решения я отказался.
Для входных данных нужен отдельный тип, который не только генерируется как структура, но и знает, как правильно записать тип объекта в мапу параметров запроса. Тут нам поможет отдельный интерфейс, который расширяет интерфейс вложенной генерации новым методом. Он возвращает информацию для сборки параметров запроса, такую как название параметра, его тип, логическое is_required значение и так далее. Хорошо, что во входных данных кроме `enum` типа и обычного поля больше ничего не используется, поэтому достаточно добавить новый метод для двух типов. Бывают конечно и структуры, но они указаны с полем format==json, и такое маршалиться в строку.
Не забываем опциональные поля во всех запросах, такие как язык возвращаемых данных, включение/выключение тестового режима, а также поля для прохождения каптчи. Для этого заведем отдельный тип Option. И конечно же контекст для переданного HTTP клиента.
Получается такая красота:
type Docs_Edit_Request struct {
// User ID or community ID. Use a negative value to designate a community ID.
// Format: int64
OwnerId int
// Document ID.
// Minimum: 0
DocId int
// Document title.
// MaxLength: 128
Title string
Tags *[]string
}
func (r Docs_Edit_Request) fillIn(values url.Values) (err error) {
setInt(values, "owner_id", r.OwnerId)
setInt(values, "doc_id", r.DocId)
setString(values, "title", r.Title)
if r.Tags != nil {
setStrings(values, "tags", *r.Tags)
}
return
}
// Docs_Edit Edits a document.
// May execute with listed access token types:
// [ user ]
// When executing method, may return one of global or with listed codes API errors:
// [ Error_ParamDocAccess, Error_ParamDocId, Error_ParamDocTitle ]
//
// https://dev.vk.com/method/docs.edit
func (vk *VK) Docs_Edit(ctx context.Context, req Docs_Edit_Request, options ...Option) (resp Base_Ok_Response, apiErr ApiError, err error) {
values := make(url.Values, 6+len(options))
if err = req.fillIn(values); err != nil {
return
}
setOptions(values, options)
apiErr, err = vk.doReq("docs.edit", ctx, values, &resp)
return
}
Итог
Мне удалось сгенерировать работающий удобный код, который в добавок быстро работает из-за отсутствия пакета reflect. Сверху еще докинул easyjson, и получилось очень быстро. Бенчмарки можно глянуть тут.
Кодогенерация сильно облегчает поддержку библиотеку, а также дает возможность пользователям получать быстрые обновления SDK.
Проблемы с vk-api-schema
В процессе работы над генератором, попадаясь на неточности в схеме и вставляя костыли, шоб работало, часто хотелось забросить это дело, но мне хотелось доделать до конца, да и на работе пока не приходится писать генераторы, а опыт очень интересный.
Я находил много мест, где у JSON-объекта типа object, то есть объект с полями, просто не указан тип. Или есть такие объекты, у которых кроме поля type больше ничего нет, и чтобы корректно сгенерировать такое, нужно отправлять запрос, чтобы посмотреть, что придет в ответе. Но это не дает тебе гарантии, что тип именно тот, что пришел: возможно при других параметрах запроса это поле поменяется со строки на массив объектов. Приходится вручную перебирать все варианты в надежде, что скрытых полей там нет. Или структура передается, как параметр запроса, но поля format со значением json, хотя должно быть.
А кодогенератор - штука чувствительная к входным данным. Хорошо, если твоя логика отловит не стыковку, но скорее всего твой код сгенерируется неправильно, и ты никогда не найдешь ошибочный тип в 60к сгенерированных строках.
Множество мест с неточными структурами ответа у методов. Регулярно описания в схеме и в официальной документации расходятся. Иногда верна схема, иногда документация. Получается, что кроме как вызывать все методы подряд в реальном приложении такие ошибки не отловить.
Мой PR висит без ответа в репозитории больше полугода, также, как с десяток заведенных мной Issue, а также 40 открытых проблем с авторством других разработчиков. Люди также пытаются писать генераторы, тщетно стараясь исправить проблемы. Тут не нужен отдельный билд с определенными условиями на определенной ОС, чтобы выявить неточность. На репозиторий тупо забили. Я понимаю, что большинство клиентов VK API написано на JS, и SDK для других языков просто не нужно, но люди сами хотят и уже пишут готовые решения, от ВК просто требуется поддерживать актуальную версию схемы.
Из-за вышеперечисленных проблем я создал форк репозитория и исправил критические для генерации ошибки.
ВКонтакте классный продукт. Но, резюмируя сказанное, кажется, что пока рано делать любые телодвижения в сторону кодогенераторов для API ВКонтакте.