Пишем утилиту автоматической генерации C# клиента для проектов Wargaming
WG API предоставляет очень подробное описание API, но при этом не предоставляет никаких библиотек для доступа к API. Сгенерировать правильные модели из JSON не получилось из за особенностей структуры JSON. В итоге оказалось, что проще написать модели вручную, но это занятие оказалось очень рутинным и скучным. В статье рассмотрим автоматизацию создания модели из описания HTML, а также полученные преимущества и недостатки (Осторожно, много текста)
Спойлер: Если сразу хотите увидеть, что получилось, переходите сразу к Итогам
Разработка
В качестве следующего примера на тему Xamarin остановил свой выбор на WG API, cоблазнившись красивым и очень подробным описанием. На удивление не было ни одной рабочей библиотеки, и когда начал работать с API быстро понял почему. API меняется достаточно часто, при этом, несмотря на очень подробную и тщательную работу над описанием, нельзя сказать что структура JSON является очевидной.
Нашел еще проект http://code.google.com/p/wg-api-sharp-library/, но коммит недавно отпраздновал годовщину и похоже что и дальше не планирует обновляться.
Единственный пример на официальном сайте может подойти если нужно совсем немного данных с сайта. Описанный подход может очень быстро утомить если писать полноценный клиент.
Удивительно, что сам Wargaming, предоставив настолько подробное API (сразу видно что старались на славу, что бы так тщательно и подробно документировать весь API), не позаботилась о том, чтобы предоставить клиентские библиотеки с реализацией для наиболее популярных языков, ведь на самом деле, как увидим далее, это очень простая задача, тем более, если есть доступ сразу к моделям, а не HTML описанию, который получается на выходе.
Не в тему: В целом для меня удивительно, что индустрия не популяризировала аналог WSDL, который в свое время получил большую популярность в мире корпоративных (и не только корпоративных) приложений. WSDL тяжеловесен со своим XML, но есть множество аналогов для JSON. Если бы кто-нибудь из них (JSON-WSP, WSDL 2.0, WADL и т.п.) получил бы популярность, то, на что я потратил несколько вечеров, можно было бы получить за несколько секунд, сгенерировав клиент автоматически. Даже наличие JSON-Schema мог бы упростить рутинную работу по генерации моделей. В итоге огромное количество клиентских разработчиков тратят огромное количество человеко-часов на разработку клиентских приложений под соответствующее API. Если очень повезет то будет полный, достаточный и хорошо структурированный JSON с примером для того что бы сгенерировать соответствующие модели автоматически. В случае WG API у нас есть большое количество данных и нет никаких гарантий, что ответ будет полным, так как очень часто нет каких-либо данных в соответствующих структурах. А так как структура JSON в WG API совершенно не очевидна во многих местах, то сгенерировать правильную модель по JSON автоматически скорее невозможно.
Парсинг HTML
Как уже упоминал выше, WG API очень подробно документирован и в принципе можно одинаково распарсить все страницы. Первое о чем подумал — это конечно о библиотеках вроде htmlagilitypack и аналогах. Такое решение дает преимущество, что решение получится кроссплатформенным, и полученное решение можно легко запустить как на любых клиентских приложениях, так и в серверных приложениях без UI. Но мне было проще и быстрее использовать хромиум (CefSharp) в WPF приложении для отладки и быстрого прототипирования решения.
Установка Cefsharp для WPF
Установить CefSharp на самом деле очень просто — необходимо просто подключить библиотеку с nuget CefSharp.WPF. Единственное с чем можно столкнуться и неочевидно — это то, что после установки пакета студию надо перезапускать. Так же необходимо выбрать платформу (x86 / x64) вместо AnyCPU.
Далее при запуске приложения для примера прописал страницу с WG API
<wpf:ChromiumWebBrowser x:Name="MyWebBrowser" Address="https://ru.wargaming.net/developers/api_reference/wot/account/info/" />
Убедившись, что страница отображается в нашем браузере, осталось извлечь данные со всех страниц.
Извлечение данных:
Для начала сделал класс куда будет сохраняться необходимая информация, которая разрослась постепенно до следующих размеров:
public class MethodItem
{
public MethodItem()
{
RequestFields=new List<RequestFieldItem>();
RootResponse=new ResponseClass();
}
public string MethodName { get; set; }
public string DescriptionPath { get; set; }
public string AlertText { get; set; }
public string DescriptionUrl { get; set; }
public string RequestUri { get; set; }
public string SupportedProtocol { get; set; }
public string SupportedHttpMethod { get; set; }
public List<RequestFieldItem> RequestFields { get; set; }
public ResponseClass RootResponse { get; set; }
public MethodLinkItem MethodLink { get; set; }
}
После того, как мы открыли соответствующую страницу, мы можем вытащить необходимые данные со страницы простыми XPath запросами (если по каким то причинам не дружите с XPath то проще всего в обычном браузере (например, Chrome) получать XPath элемента выбирая его на странице).
К примеру, заголовок можно вытащить следующим образом:
await GetFromJquerySelector("#name");
А имя метода:
await GetFromJquerySelector("body>div>div.b-content.clearfix>div.b-content-column>div.b-page-header.js-page-header>div>span");
Где GetFromJquerySelector — вспомогательный метод, который отправляет XPath запрос через JQuery (к счастью, сам JQuery уже есть на странице и инжектировать JS код не пришлось).
Таким же образом можно вытащить всю информацию со страницы.
Реализация метода тоже достаточно простая:
private async Task<string> GetFromJquerySelector(string jquerySelector)
{
var result = await GetFromJS<string>($"$('{jquerySelector}').text()");
return result?.Trim();
}
private async Task<T> GetFromJS<T>(string javaScript)
{
var selectorResult = await MyWebBrowser.EvaluateScriptAsync(javaScript);
return (T)selectorResult.Result;
}
Далее все параметры запроса вытаскиваем в «плоский» список из трех полей
public class RequestFieldItem
{
public string FieldName { get; set; }
public string FieldType { get; set; }
public string FieldDescription { get; set; }
}
Пройдя циклом по всем элементам:
var requestParamLen = await GetFromJS<int>("$('#parameters_block > table > tbody > tr').length");
for (var i = 1; i <= requestParamLen; i++)
{
var requestItem = new RequestFieldItem();
requestItem.FieldName = await GetFromJquerySelector($"#parameters_block > table > tbody > tr:nth-child({i}) > td:nth-child(1)");
requestItem.FieldType = await GetFromJquerySelector($"#parameters_block > table > tbody > tr:nth-child({i}) > td:nth-child(2)");
requestItem.FieldDescription = await GetFromJquerySelector($"#parameters_block > table > tbody > tr:nth-child({i}) > td:nth-child(3)");
methodItem.RequestFields.Add(requestItem);
}
Здесь MethodItem — это отдельный объект куда складывается вся полученная информация с конкретной страницы. (т.е. каждая страница — один MethodItem)
Описание ответа сохраняется в объекте:
public class ResponseClass
{
public ResponseClass()
{
ResponseFieldItems = new List<ResponseFieldItem>();
ResponseClasses = new Dictionary<string, ResponseClass>();
}
public string ClassName { get; set; }
public string ClassDescription { get; set; }
public List<ResponseFieldItem> ResponseFieldItems { get; set; }
public Dictionary<string, ResponseClass> ResponseClasses { get; set; }
}
Где
public class ResponseFieldItem
{
public string FieldName { get; set; }
public string FieldType { get; set; }
public string FieldDescription { get; set; }
}
Сохраняет исключительно примитивные поля, такие как string, number и т.п., а в ResponseClasses сохраняется древовидная структура ответа.
После разбора HTML из полученной структуры MethodItem можем собрать клиентский код.
Генерация C# клиента
Из собранной информации можно сгенерировать клиентский кода для любого языка.
При реализации этого решения мне как раз был необходим клиент на C# и, соответственно, обдумывал какой API хочу получить в итоге (окончательный вариант конвертора в C# реализован в файле CSConverter.cs ):
Допустим, это будет некий объект, у которого будут соответствующие методы для отправки запросов к серверу.
Псевдокод для страницы с персональными данными игрока:
var client=new WGClient();
client.GetPersonalData(...);
Здесь сразу предпочел отказался от варианта с придумыванием собственного имени метода так как проще было искать вызов по методам, указанным в WG API ( например, «account/info» для страницы с персональными данными игрока)
Получается следующий вариант:
client.GetAccountInfo(...);
А что насчет аргументов? А что если я хочу не все передавать а только некоторые из них?
Т.е. в итоге получалось нечто вроде
var accountInfo =await сlient.GetAccountInfo(ApplicationId: "demo", Search: "Amirchan");
В последствии обнаружилась одна очень неприятная проблема — к сожалению WG совершенно не документируют структуру того, что API будет отдавать на выходе. На выходе мы можем получить как просто единичный ответ, так же можно получить массив или словарь. Узнать что же будет на выходе можно будет только отправив запрос и получив ответ в виде JSON, где и увидим, как возвращаются данные на выходе (благо есть ссылка на API Explorer в каждом методе, который позволяет очень легко формировать запрос и видеть ответ прямо в браузере).
В конечном итоге остановился на следующем варианте: любой запрос должен начинаться с Request+Method, а ответ Response+Method
т.е. в нашем случае
var response=сlient.SendRequest<ResponseType>(new RequestType()
{
});
Так как мы не знаем, что у нас будет на выходе, кроме самого типа ответа может быть ответ в виде массива или словаря, добавил еще два дополнительных метода, но какой из них необходимо вызывать можно узнать только посмотрев структуру json в ответе:
сlient.SendRequestArray<ResponseType>(new RequestType()
client.SendRequestDictionary<ResponseType>(new RequestType()
Наприме, так как у нас в методе account/info возвращается словарь, для нашего примера у нас получается следующий запрос:
var response = await client.SendRequestDictionary<ResponseAccountInfo>(new RequestAccountInfo()
{
ApplicationId = "demo",
AccountId = 111
});
Получилось немного многословно и двойной RequestRequest, но на этом решил остановить оптимизацию.
Дальше выяснилось, что одни и те же методы используются в разных проектах.
Так, к примеру, метод account/list используется как в WoT , так и в WGN
Соответственно, имена объектов разрослись до:
[Request/Response][префикс проекта][имя метода]
А значит, пример с запросом вырастает до следующих размеров:
var response = await client.SendRequestDictionary<ResponseWotAccountInfo>(new RequestWotAccountInfo()
{
ApplicationId = "demo",
AccountId = accountId
});
Остановимся на этом с оптимизацией и теперь рассмотрим формирование самих объектов запросов и ответов.
К примеру у нас будет следующий объект запроса
public class RequestWotAccountInfo
{
...
}
Так как мы вытаскиваем назначение этого запроса при парсинге, мы можем добавить в метод описание:
///<summary>
/// Персональные данные игрока
/// https://ru.wargaming.net/developers/api_reference/wot/account/info/
///</summary>
Так же при отправке запроса нам надо знать на какой именно адрес должен уйти запрос, добавим аттрибут Method, где будем указывать адрес запроса:
[Method(Url = "api.worldoftanks.ru/wot/account/info/")]
В случае обнаружения ошибки в запросе в тексте ошибки желательно выдать сообщение со ссылкой на описание метода. Для этого добавим еще один атрибут DescriptionUrl и где будет указываться адрес страницы с информацией о методе, который будет считываться при формировании текста ошибки:
[DescriptionUrl(Url = https://ru.wargaming.net/developers/api_reference/wot/account/info/)]
Генерация полей этого класса-запроса тоже не сильно отличается по сложности. Надо привести название поля к C# стилю, удалив подчеркивания и каждую часть слова начиная с большой буквы и при этом сохранить оригинальное название для сериализации.
К примеру для json поля access_token получаем название AccessToken но при этом нам надо сохранить информацию о том как это поле должно сериализовываться в JSON. Так как я предпочитаю пользоваться библиотекой Json.NET, то добавил аттрибуты для этой библиотеки.
С учетом того, что у нас так же есть подробное описание каждого поля, мы можем добавить его сразу же в генерируемое поле:
///<summary>
///Ключ доступа к персональным данным пользователя имеет срок действия. Для получения ключа доступа необходимо запросить аутентификацию.
///string
///</summary>
[JsonProperty("access_token")]
public string AccessToken { get; set; }
Все поля запроса (в отличие от полей ответа) для простоты сделаны типа string, но что бы не потерять какой тип данных принимает запрос в описании метода отмечается тип поля.
Так же у нас часть полей является обязательным и в описании помечены звездочкой, к примеру:
*account_id — Идентификатор аккаунта игрока
для таких полей добавил атрибут FieldIsMandatory который при сериализации проверяет на наличие значения в этом поле. Если значения нет, то соответственно выбрасывается исключение со ссылкой на описание метода из атрибута DescriptionUrl
В итоге получается следующее описание поля для account_id:
///<summary>
///Обязательный параметер
///Идентификатор аккаунта игрока
///numeric, list
///</summary>
[JsonProperty("account_id")]
[FieldIsMandatory]
public string AccountId { get; set; }
Генерация примитивных полей ответа, на самом деле, отличается не сильно. Основное отличие — это то, что типы из WG API преобразуются в типы C#
///<summary>
///Идентификатор клана
///</summary>
[JsonProperty("clan_id")]
public Int64? ClanId { get; set; }
Собрав в хеше все типы данных, которые встречаются в описании WG API сделал следующее сопоставление типов в словаре:
WGTypeToCSType.Add("string", "string");
WGTypeToCSType.Add("string, list", "string[]");
WGTypeToCSType.Add("numeric", "Int64?");
WGTypeToCSType.Add("numeric, list", "Int64[]");
WGTypeToCSType.Add("timestamp", "int?");
WGTypeToCSType.Add("list of integers", "int[]");
WGTypeToCSType.Add("boolean", "bool");
WGTypeToCSType.Add("associative array", "Dictionary<string,string>");
WGTypeToCSType.Add("float", "double");
WGTypeToCSType.Add("list of strings", "string[]");
WGTypeToCSType.Add("list of timestamps", "int[]");
WGTypeToCSType.Add("timestamp/date", "int?");
WGTypeToCSType.Add("список словарей", "Dictionary<string,string>");
А подтипы рекурсивно генерируются и складываются в отдельный StringBuilder для того, чтобы добавить их после объявления класса:
private Tuple<string, string> GetClass(string prefix, ResponseClass classItem)
{
var modelTypeName = prefix + GetNormalizedName(new[] { classItem.ClassName });
int tab = 1;
var sb = new StringBuilder();
AppendLine(sb, tab, "public class " + modelTypeName);
AppendLine(sb, tab, "{");
tab++;
foreach (var responseField in classItem.ResponseFieldItems)
{
if (responseField.FieldDescription.Contains("Внимание! Поле будет отключено."))
{
continue;
}
var fieldName = GetNormalizedName(responseField.FieldName.Split(new[] { ',', '_' }, StringSplitOptions.RemoveEmptyEntries));
sb.AppendLine();
AppendLine(sb, tab, "///<summary>");
AppendLine(sb, tab, "///" + responseField.FieldDescription.Trim().Replace("rn", "rn///").Replace("n", "n///"));
AppendLine(sb, tab, "///</summary>");
AppendLine(sb, tab, $@"[JsonProperty(""{responseField.FieldName}"")]");
Append(sb, tab, "public ");
sb.Append(GetTypeString(responseField.FieldType));
sb.Append(" ");
sb.Append(fieldName);
sb.AppendLine(" {get; set;}");
}
var subClass = new StringBuilder();
foreach (var chieldClass in classItem.ResponseClasses.Values)
{
if (chieldClass.ClassDescription.Contains("Внимание! Поле будет отключено."))
{
continue;
}
var createClassModel = GetClass(modelTypeName, chieldClass);
var typeName = createClassModel.Item1;
var classModel = createClassModel.Item2;
subClass.Append(classModel);
var fieldName = GetNormalizedName(chieldClass.ClassName.Split(new[] { ',', '_' }, StringSplitOptions.RemoveEmptyEntries));
sb.AppendLine();
AppendLine(sb, tab, "///<summary>");
AppendLine(sb, tab, "///" + chieldClass.ClassDescription.Trim().Replace("rn", "rn///").Replace("n", "n///"));
AppendLine(sb, tab, "///</summary>");
AppendLine(sb, tab, $@"[JsonProperty(""{chieldClass.ClassName}"")]");
Append(sb, tab, "public ");
sb.Append(typeName);
sb.Append(" ");
sb.Append(fieldName);
sb.AppendLine(" {get; set;}");
}
tab--;
AppendLine(sb, tab, "}");
tab--;
AppendLine(sb, tab, subClass.ToString());
return new Tuple<string, string>(modelTypeName, sb.ToString());
}
Итоги:
В конечном итоге, у нас получилось очень простое приложение, где по очереди открываются страницы с WG API и распарсиваются. После этого генерируется и выводится C# код клиента в отдельном окне:
Исходный код можете скачать здесь:
Если не хотите или нет возможности скачать проект и запустить его, то там же можете скачать пример сгенерированного кода клиента.
Пример использования:
Исходники нижеприведенного примера можно забрать здесь
У нас получился код который совместимый с PCL, поэтому мы можем использовать этот код как для клиентских приложений, так и серверных веб приложений.
Для примера потратил 15 минут и создал небольшое «голое» Xamarin Forms приложение без дизайна, где набросал, в первой форме поиск по нику, а во второй форме информацию о выбранном нике среди найденных.
В созданный проект добавил cs файл, куда скопировал код , сгенерированный нашей утилитой.
Следующий шаг — добавление библиотеки Json.net (поиск в nuget библиотеки Newtonsoft.Json или командой Install-Package Newtonsoft.Json c nuget консоли).
Код поиска получается достаточно простым:
var client = new WGClient.Client();
var accounts = await client.SendRequestArray<ResponseWgnAccountList>(new RequestWotAccountList()
{
ApplicationId = "demo",
Search = SearchNickname
});
GamerAccounts = accounts;
При этом, благодаря сгенерированному описанию, у нас есть возможность получать подсказки прямо в студии при наборе кода:
Обратите внимание, ApplicationId: «demo» можно использовать только для тестирования API. Для релиза необходимо создать свой ApplicationId в личном кабинете
Теперь осталось отобразить список найденных Nickname:
К сожалению, у меня уже нет своего аккаунта, спасибо Шериеву Амиру (моему брату) за предоставленный игровой ник для растерзания в примерах:
По тапу из списка найденных открываем вторую форму, передав выбранный AccountId:
var item = e.SelectedItem as ResponseWgnAccountList;
Navigation.PushAsync(new DetailsPageView(item.AccountId));
На второй странице тоже создается запрос к другому методу для получения более подробной информации:
var client=new Client();
var response=await client.SendRequestDictionary<ResponseWotAccountInfo>(new RequestWotAccountInfo()
{
ApplicationId = "demo",
AccountId = accountId
});
Таким образом, с помощью нашего сгенерированного клиента у нас есть возможность сэкономить огромное количество времени на автоматизацию рутины формирования запроса и ответа к WG API и сосредоточить время, силы и внимание на само приложение.
Недостатки:
Необходимость ручного допиливания напильником:
Пожалуй это самый основной недостаток — как и для всего ответа в целом так и для подтипов нет никакой информации, как будут возвращены данные (простой ответ, массив или словарь). Поэтому во многих местах придется делать правки.
Возмьем для примера метод Техника (encyclopedia/vehicles)
Для уменьшения объема ответа отфильтруем ответ по технике одного уровня и одной нации:
Вызов следующего кода вызовет ошибку:
var client = new WGClient.Client();
var response = await client.SendRequestDictionary<ResponseWotEncyclopediaVehicles>(new RequestWotEncyclopediaVehicles()
{
ApplicationId = "demo",
Tier = "8",
Nation = "ussr"
});
выкинув исключение:
Unhandled Exception: Newtonsoft.Json.JsonSerializationException: Cannot deserialize the current JSON array (e.g. [1,2,3]) into type 'WGClient.WorldOfTanks.WotEncyclopediaVehiclesCrew' because the type requires a JSON object (e.g. {«name»:«value»}) to deserialize correctly.
To fix this error either change the JSON to a JSON object (e.g. {«name»:«value»}) or change the deserialized type to an array or a type that implements a collection interface (e.g. ICollection, IList) like List that can be deserialized from a JSON array. JsonArrayAttribute can also be added to the type to force it to deserialize from a JSON array.
Отсюда следует, что не удалось десериализовать подтип Crew, и если мы построим запрос в API Explorer
То увидим, что Crew возвращается в виде массива
При этом поле Engines корректно распозналось благодаря тому, что для него был указан тип поля «list of integers» в отличие от подтипа Crew, для которого нет никакой информации.
Исправить ошибку можно сделав поле Crew массивом, заменив поле
///<summary>
///Экипаж
///</summary>
[JsonProperty("crew")]
public WotEncyclopediaVehiclesCrew Crew { get; set; }
на массив:
public WotEncyclopediaVehiclesCrew[] Crew { get; set; }
Аналогичную ошибку получаем для default_profile.ammo, соответственно, там тоже необходимо исправить сделав массив.
Зависимость от HTML
Так как WG API не использует никаких стандартов описания JSON приходится довольствоваться парсингом HTML описания. А HTML может меняться произвольным образом, а описание одних и тех же типов могут отличаться, и встречаются даже на русском языке, т.е. нет никаких гарантий, что завтра не появится новое название используемого типа.
Развитие
Так как на решение и пример было потрачено примерно пара свободных вечеров и изначально создавался проект который будет не жалко выкинуть и построить на его базе нормальное решение, есть масса возможностей для развития этого проекта. Перечислим некоторые из них:
Браузер
Текущее решение построено на базе CefSharp что уже означает что решение будет работать только на Win32 платформе. Можно переписать с использованием библиотек CefSharp, чтобы получить кроссплатформенное решение.
Если оставаться на парсинге HTML, то возможно имеет смысл переписать на TypeScript, в таком случае можно будет сделать решение не только на клиенте, но и в виде плагина к хрому.
Плохой интернет
Парсер не учитывает, что интернет может пропасть со всеми вытекающими последствиями.
Неоптимальное API клиента
В конченом итоге остановился на достаточно многословном и не очень удобном тяжеловесном варианте
SendRequest<TResponse>(TRequest request)
Есть масса способов сделать решение проще. Но максимально простой вариант можно было б получить, если из описания API было бы понятно вернется ответ в виде словаря, массива или единичного объекта.
Nuget или необходимость вручную добавлять библиотеку Json.NET
К сожалению, на данный момент нет возможности генерировать решение в виде сборки или nuget пакета.
Решение с Nuget пакетом могло бы избавить от необходимости копировать исходный код и подключать вручную Json.NET, но как уже было сказано выше, местами придется допиливать напильником.
Покрытие тестами
1-2 раза в месяц выходит новая версия API. Соответственно перегенерировав весь ответ мы автоматически потеряем все правки напильником, которые у нас уже были сделаны.
Для того, чтобы убедиться, что в новой версии все корректно доработали напильником, можно было бы покрыть тестами полученное решение для того, чтобы убедиться, что не забыли допилить напильником что нибудь. По большому счету, можно было бы автоматически генерировать тесты. В теории это позволит частично убедиться в том что новая версия не разломает все напрочь.
Качество кода
Как и было сказано выше, код писался как прототип решения, который потом не жалко будет выкинуть и переписать в рабочий вариант. На все решение (включая пример написанный за 15 минут) было потрачено суммарно пара вечеров.
Выбор проекта для генерации клиента
На текущий момент генерируется больше 40 тысяч строк для всех проектов сразу. Можно было бы добавить выбор каких проектов и каких методов этого проекта необходимо сгенерировать клиент.
На данный момент все проекты разделены namespace-ами и можно просто удалить проект и тем самым уменьшить конечный размер сборки.
То же самое касается лишних полей.
Можно бесконечно заниматься улучшением, но на этом можно подвести резюме
Резюме
Сам факт того что Wargaming старается быть открытым к разработчикам и старается не просто открывать API, но еще и детально описывает значения полей очень похвальна. Несмотря на важные недостатки в описании и в самой структуре ответа (а именно то что нельзя понять что будет возвращен, одиночный ответ, массив или словарь для сложных типов), это одно из лучших документаций API для подобных сервисов.
Тем не менее, по каким-то своим причинам Wargaming не следует никаким стандартам для своего JSON, который позволил бы автоматизировать процесс генерации клиента сэкономив разработчикам огромную массу сил и времени на написание рутинного кода.
Мало того, как мы видели из этой статьи, WG если и не следует стандартам, то мог бы легко генерировать клиентские библиотеки хотя бы для нескольких основных языков программирования, что могло бы сэкономить огромное количество времени и сил для разработчиков путем простого подключения готовой библиотеки. Будем надеяться что, увидим подобные решения от WG (тем более, что самой WG нет необходимости парсить HTML). Возможно, это поможет существенно увеличить количество пользователей этого API.
Автор: Atreides07