На шляху до повної типізації з TypeScript, Swashbuckle і AutoRest

Введення
У даній статті розглядається питання про те, як реалізувати обмін типізованими повідомленнями між Back-End на основі ASP.NET Web API і Front-End, створеного з використанням TypeScript. Це набуває особливого значення при роботі над об'ємними проектами, і тим більш важливо, якщо команда є розподіленою. Наприклад, коли Back-End і Front-End розробники працюють з різних місць, в різних часових поясах, і не завжди мають можливість контактувати й обговорити що-небудь. У цьому випадку відстеження змін являє копітку роботу, яка може бути чревата безліччю важковловимих помилок.

Для автора статті, як для людини, який прийшов до розробки Front-End з боку WPF і Silverlight, великою проблемою, стало відсутність статичної типізації. Скільки разів замість того щоб скласти «2» і «2» складав «2» і «Функцію повертає 2», або передавав DOM об'єкт замість його jQuery обгортки. Поява статичних аналізаторів коду, таких як JSLint, дещо полегшило проблему, але справжнім проривом, особливо в командному розробці, для нас став TypeScript.




Суть проблеми
TypeScript – мова, яка дозволяє досягти статичної типізації, хоча дехто називає її "ілюзією" ( habrahabr.ru/post/258957, habrahabr.ru/post/272055 ). Цікаво, що критики особливо виділяють роботу з Back-End типово нетипобезопасный сценарій.
Однак, суть проблеми полягає в тому, що при написанні Front-End програм на JavaScript раніше, і на TypeScript в даний час, ми не маємо такого інструментарію для роботи з метаданими і авто-генерації клієнтського коду, як колись мали в WCF.

Метадані
Якщо звернутися до досвіду WPF+WCF, то там в цьому відношенні все досить добре. Звичайно дані, взагалі кажучи, завжди подорожують в нетипизированном вигляді, але при відправці вони залишаються типізованими майже до самого кінця, і лише безпосередньо перед посилкою іншій стороні сериализуются в рядок або бінарний потік. На іншій стороні вони, знову ж таки, потрапляють в якогось клієнта, який перетворює їх в типізовані. Для того, щоб не писати руками такого клієнта і не намагатися виловити множинні помилки і існують метадані. У світі .NET у 90% випадків взагалі не потрібно ніякої роботи, ні для їх генерації, ні для їх обробки. Ви просто пишіть свій сервіс, не забувши додати відповідний endpoint, і отримуєте автогенерируемые метадані. Також в один клік генеруєте клієнта і в результаті отримуєте обмін типізованими повідомленнями.

При розробці Single Page Application на JavaScript/TypeScript на зміну WCF приходить Web API. У свій час було трохи дивно, чому немає ніякого способу генерації метаданих для Web API з коробки (не вважати ж help-pages метаданими). Мабуть відповідь в тому, що головним отримувачем даних від Web API був код JavaScript, у якому типізація не має сенсу. Однак, у нас тепер не JavaScript а TypeScript, і бажання оперувати типізованими даними знову стає актуальним.

Дуже популярним форматом метаданих зараз є OpenAPI/Swagger. Не дивно, що з'являються можливості генерувати метадані та документацію в цьому форматі.

Далі ми продемонструємо процес організації типізованого взаємодії.
Коротко, ми виконаємо наступні кроки:

  1. Підключимо і налаштуємо бібліотеку Swashbuckle
  2. Згенеруємо документацію/метадані
  3. Переконаємося в зручності зберігання згенерованого файлу в системі контролю версій
  4. Підключимо AutoRest
  5. Згенеруємо клієнтські моделі
  6. Випробуємо їх у справі.


Swashbuckle
github.com/domaindrivendev/Swashbuckle

Для початку ми хочемо створити метадані.
Отже, припустимо, у нас є Web API, а в ньому — контролер, що відповідає за роботу з співробітниками.
/// < summary>
/// Gets all employees.
/ / / < /summary>
/// <remarks>
/// Gets the list of all employees.
/// </remarks>
/ / / < returns>
/// The list of employees.
/// </returns>
[Route("api/employees")]
[HttpGet]
public Employee[] GetEmployees()
{
return new[]
{
new Employee { Id = 1, Name = "John Doe" },
new Employee { Id = 2, Name = "Jane Doe" }
};
}

Як видно – повертається масив типізованих об'єктів типу Employee. Запустивши наш проект, ми можемо запросити список співробітників:
http://localhost:1234/api/employees

Давайте тепер підключаємо бібліотеку Swashbuckle. У NuGet існує два пакета Swashbuckle.Core і Swashbuckle. Різниця між ними полягає в тому, що перший є ядром і містить весь код, який робить магію, a другий, у свою чергу, є лише доповненням, яке встановлює бутстраппер, конфигурирующий ядро.

Про це написано в документації github.com/domaindrivendev/Swashbuckle#iis-hosted

Ми віддаємо перевагу установці Core і написання конфігураційного коду самостійно, оскільки його потім зручніше переиспользовать.

Давайте його встановимо
PM> Install-Package Swashbuckle.Core

зареєструємо з допомогою WebActivatorEx
[assembly: WebActivatorEx.PreApplicationStartMethod(typeof(FullyTypedExample.WebApi.SwaggerConfig), "RegisterGlobal")]

а також напишемо код конфігурації
/// < summary>
/// Configures Swagger.
/ / / < /summary>
/ / / < param name="config">
/// The Swagger configuration.
/// </param>
public static void ConfigureSwagger(SwaggerDocsConfig config)
{
config.SingleApiVersion("v1", "FullyTypedExample.WebApi");
config.IncludeXmlComments(GetXmlCommentsPathForControllers());
config.IncludeXmlComments(GetXmlCommentsPathForModels());
config.GroupActionsBy(apiDescription => apiDescription.ActionDescriptor.ControllerDescriptor.ControllerName);
config.OrderActionGroupsBy(Comparer<string>.Default);
config.PrettyPrint();
}

Тут все досить просто: спочатку ми встановлюємо версію і заголовок нашого API. Далі говоримо, що потрібно увімкнути xml-коментарі для контролерів і моделей. Налаштовуємо порядок і угруповання action всередині swagger-документа. Окремо хочеться згадати опцію PrettyPrint. Вона включає форматування JSON для swagger-документа. Ця опція стане в нагоді для того, щоб надалі зберігати документацію в системі контролю версій і з легкістю переглядати її зміни, використовуючи будь-diff переглядач.

Тепер можна запустити проект і побачити інтерфейс Swagger.
http://localhost:1234/swagger



Поруч можна подивитися на сам swagger-документ у вигляді JSON.
http://localhost:1234/swagger/docs/v1

Тепер нам потрібно скласти згенеровану документацію в систему контролю версій. Оскільки Swashbuckle використовує під капотом майкрософтовський IApiExplorer, для того щоб згенерувати swagger файл обов'язково доведеться запустити Web API (детальніше про це тут github.com/domaindrivendev/Swashbuckle/issues/559). Тобто кожен раз, коли ви хочете створити документацію, вам доведеться запустити Web API і скопіювати swagger/docs в файл вручну. Звичайно ж, хочеться щось більш автоматизоване.

Ми вирішили це з допомогою запуску Web API у вигляді self-hosted програми, відправлення запиту не endpoint swagger-а і записи відповіді у файл. Тут якраз в нагоді переиспользовать код конфігурації Swashbuckle. Виглядає це приблизно так:
/// < summary>
/// Generate Swagger JSON document.
/ / / < /summary>
/ / / < param name="filePath">
/// The file path where to write the generated document.
/// </param>
private static void GenerateSwaggerJson(string filePath)
{
// Start OWIN host
using (TestServer server = TestServer.Create<WebApiHostStartup>())
{
HttpResponseMessage response = server.CreateRequest("/swagger/docs/v1").GetAsync().Result;

string result = response.Content.ReadAsStringAsync().Result;
string path = Path.GetFullPath(filePath);

File.WriteAllText(path, result);
}
}

Давайте тепер все це запустимо:
nuget.exe restore "..\FullyTypedExample.sln"
"C:\Program Files (x86)\MSBuild\12.0\bin\MSBuild.exe" "..\FullyTypedExample.WebApi.SelfHosted\FullyTypedExample.WebApi.SelfHosted.proj" /v:minimal
"..\FullyTypedExample.WebApi.SelfHosted\bin\Debug\FullyTypedExample.WebApi.SelfHosted.exe" --swagger "swagger.json"

Разом ми отримали swagger-документ у вигляді JSON файлу і поклали його в систему контролю версій. Тепер Front-End розробники з нашої розподіленої команди можуть з легкістю відстежити зміни в endpoint-ах. Давайте подивимося, як це виглядає.

Припустимо, ми додали новий action для отримання співробітника по його ідентифікатору.
/// < summary>
/// Gets employee by id.
/ / / < /summary>
/ / / < param name="employeeId">
/// The employee id.
/// </param>
/// <remarks>
/// Gets the employee specified by id.
/// </remarks>
/ / / < returns>
/// The <see cref="Employee"/>.
/// </returns>
[Route("api/employees/{employeeId:int}")]
public Employee GetEmployeeById(int employeeId)
{
return this.GetEmployees().SingleOrDefault(x => x.Id == employeeId);
}

І заново згенерували swagger.json. Подивимося, що змінилося


Як бачите, для цього action з'явилася документація, яку легко можна побачити, використовуючи diff переглядач. Завдяки опції PrettyPrint вона відформатована і легко читається.

AutoRest
Отже, перша частина нашого завдання виконано — метадані у нас є. Як же тепер згенерувати клієнтську частину, тобто типи даних (отримуваних з сервера) на клієнтській стороні?

Потрібно сказати, що можна генерувати і сам код для запиту Web API, просто це трохи складніше і вимагає більш трудомісткої роботи по конфігурації кодогенераторов чи написання своїх. Також, багато залежить від того, які бібліотеки (будь то jQuery, SuperAgent або навіть новий експериментальний Fetch API developer.mozilla.org/en/docs/Web/API/Fetch_API) та підходи (Promises, Rx і ін) ви використовуєте у своєму клієнтському програмному коді.

Для кодогенерации існують наступні варіанти:

1. Swagger Code Generator github.com/swagger-api/swagger-codegen
Офіційний інструмент від команди Swagger, написаний на Java і вимагає відповідної інфраструктури. Також може запускатися в Docker. Правда, генерація JavaScript і тим-більше TypeScript у ньому відсутня. Хоча якщо вам потрібно згенерувати код, наприклад, на Java — це ваш вибір. Нам він не підійшов за зрозумілих причин.

2. Swagger JS library github.com/swagger-api/swagger-js
Теж офіційний інструмент від команди Swagger. Вже тепліше. Написаний на PHP і генерує JS код відповідно. Встановлюється через npm або bower. Інфраструктура нам підходить, але на жаль тут немає тієї самої генерації типів.

3. Swagger to JS & Typescript Codegen github.com/wcandillon/swagger-js-codegen
Проект був опублікований дещо пізніше чим ми почали розробляти цей підхід. Можливо в найближчому майбутньому це стане найбільш підходящим рішенням.

4. Написати свій велосипед кодогенератор. В цілому, чому б і ні? Але для початку ми вирішили, що спробуємо AutoRest, і якщо не злетить, або не влаштує нас можливостями, напишемо таки свій, з блек-джеком і… Ну ви зрозуміли.

5. AutoRest github.com/Azure/autorest
І нарешті, AutoRest від Azure команди Microsoft. Зараз актуальна версія — 0.15.0, і чесно кажучи незрозуміло, чи вважається це у них повноцінним релізом чи ні, але позначки Pre, як на попередніх, не спостерігається. Загалом, тут все просто, ми встановили і сходу згенерували *.d.ts файли, які нам були потрібні.

Отже, давайте пройдемо заключний відрізок нашого шляху за допомогою цього інструменту.

Підключаємо AutoRest через NuGet:
PM> Install-Package AutoRest

Пакет не ставиться в якийсь конкретний проект, посилання на нього додається для всього рішення.
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="AutoRest" version="0.15.0" />
</packages>

У пакеті є консольний додаток AutoRest.exe, яке, власне, і виконує генерацію. Для запуску ми використовуємо наступний скрипт
nuget.exe restore "..\FullyTypedExample.sln"
"..\packages\AutoRest.0.15.0\tools\AutoRest.exe" -Input "swagger.json" -CodeGenerator NodeJS
move "Generated\models\index.d.ts" "..\FullyTypedExample.HtmlApp\models.d.ts"

На вхід ми подаємо наш раніше згенерований swagger.json, а на виході отримуємо models\index.d.ts — файл з моделями. Копіюємо його в клієнтський проект.

Тепер у TypeScript ми маємо наступний опис моделі:
/**
* @class
* Initializes a new instance of the Employee class.
* @constructor
* Represents the employee.
* @member {number} id Gets or sets the employee identifier.
* 
* @member {string} name Gets or sets the employee name.
* 
*/
export interface Employee {
id: number;
name: string;
}

Давайте випробуємо його в справі:
public makeRequest() {
this.repository.getEmployees()
.then((employees) => {
// Generate html using tempalte string
this.table.innerHTML = employees.reduce<string>((acc, x) => {
return `${acc}<tr><td>${x.id}</td><td>${x.name}</td></tr>`;
}, ");
});
}

Тут ми звертаємося до полів моделі id name. Ми навмисно опустили реалізацію запиту на сервер, оскільки вона, як ми вже й говорили, може залежати від вибраних бібліотек і підходів.

Якщо ми спробуємо звернутися до поля age, якого не існує, наш TS код не відбудеться створення. Якщо в API зникне поле, до якого ми зверталися раніше, наш код знову ж таки не відбудеться створення. Якщо додадуться нові поля, ми відразу побачимо, використовуючи все той же diff. Крім того, ми автоматично отримуємо JSDoc документацію на основі метаданих. Загалом, всі принади статичної типізації наявності.

ResponseType
Цікаво, що при необхідності для документації можна вказати інший тип ніж той, що повертається. Наприклад, це може бути корисним при наявності legacy-коду, який працює з нетипізованими DataSet-ами; або, якщо ви повертаєте IHttpActionResult з контролерів. Не зачіпаючи реалізацію методів, ми можемо позначити їх атрибутом ResponseType і розробити спеціальні типи

/// < summary>
/// Gets all departments.
/ / / < /summary>
/// <remarks>
/// Gets the list of all departments.
/// </remarks>
/ / / < returns>
/// The list of departments.
/// </returns>
[Route("api/departments")]
[HttpGet]
[ResponseType(typeof(DepartmentsResponse))]
public DataSet GetDepartments()
{
var dataTable = new DataTable("Departments");

dataTable.Columns.Add("Id", typeof(int));
dataTable.Columns.Add("Name", typeof(string));

dataTable.Rows.Add(1, "IT");
dataTable.Rows.Add(2, "Sales");

var dataSet = new DataSet();
dataSet.Tables.Add(dataTable);

return dataSet;
}

щоб отримати на клієнтській стороні типізовані моделі
/**
* @class
* Initializes a new instance of the Department class.
* @constructor
* Represents the department.
* @member {number} id Gets or sets the department identifier.
* 
* @member {string} name Gets or sets the department name.
* 
*/
export interface Department {
id: number;
name: string;
}


Проблеми
По-перше, зростання розміру файлу models.d.ts з часом. Поки ми ще не займалися, розділивши його на кілька подфайлов, але це безсумнівно потрібно буде зробити.

Також може виникнути проблема з неправильною генерацією імен полів, якщо використовується нестандартна нотація, наприклад, якщо використовуються підкреслення. Поле LAST_NAME з C# код сгенерируется в Swagger як lasT_NAME, а в TypeScrpt — як lasTNAME.

/// < summary>
/// Gets or sets the last name.
/ / / < /summary>
[Required]
// ReSharper disable once InconsistentNaming
public string LAST_NAME { get; set; }

"lasT_NAME": {
"description": "Gets or sets the last name.",
"type": "string"
}

export interface Employee {
id: number;
name: string;
firstName: string;
lasTNAME: string;
}

Зазначимо, що більшість дрібних проблем легко вирішується за допомогою конфігурації і не варто окремої згадки.

Висновок
Даний підхід дозволив нам організувати обмін типізованими повідомленнями. При цьому він забезпечив типізацію клієнтських моделей, зменшив імовірність розбіжності клієнтського і серверного коду, зробив більш простим відстеження змін в API і моделях. Приємним бонусом стало зручне ручне тестування API з вбудованим REST-клієнтом і можливістю генерації payload на льоту за схемою. Використання даного підходу також допомогло поліпшити взаємодію Back-End і Front-End розробника.

Працюючий приклад можна побачити тут.
github.com/EBTRussia/fully-typed-example

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.