Иллюстрация процесса разработки. Не понимаю, что мешает ехать
Иллюстрация процесса разработки. Не понимаю, что мешает ехать

Привет! Я Илья, мне 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 ВКонтакте.

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