Лирическое введение
Вообще, меня давно и прочно будоражит идея тесной интеграции Back-End и Front-End на .NET стеке, что в свою очередь даже вылилось в попытку с наскоку написать целый транслятор из C# в JavaScript. Не скажу, что никаких результатов не было достигнуто — мой транслятор успешно перевел в JavaScript несколько C#-классов и в конечном итоге ушел в анабиоз и переосмысление архитектуры. Когда-нибудь я к нему еще вернусь. Обязательно.
Но, тем не менее, текущие задачи в проектах надо было как-то решать. А текущие задачи в почти любом web-проекте, в целом, достаточно типичны и, не побоюсь этого слова, скучны. Вот сейчас я, с вашего позволения, абстрагируюсь от всяких сложных, но давно понятных материй, вроде использования Automapper, собирания IoC-контейнера и шуршания в БД посредством EF/NH/чего-нибудь, и переключусь ближе к фронтенду. Так вот — на стыке бекенда и фронтенда тоже много скучных и типичных задач (sarcasm). А конкретно — запросы к серверу на предмет JSON с данными, их отображение и выполнение всяких-разных операций AJAX-ом. Reinforced.Typings (а именно так я назвал свою маленькую помогалку) принесет в это царство уныния толику веселья, упрощение типичных задач, избавление от шаблонного кода и несколько больше консистентности.
С тех пор, как Microsoft подарил нам TypeScript, написание клиентских JavaScript-ов стало занятием гораздо более комфортным. TypeScript принес ощущение типизируемости и прекомпилируемость туда, где его не хватало. Если вы его еще не пробовали — то обязательно попробуйте (это не реклама, нет). Можно, конечно, много спорить по вопросу «быть или не быть» TypeScript-у в вашем конкретном проекте, но давайте опустим дискуссию и перейдем " — Ближе к телу! — как говорил Ги де Мопассан".
Практический пример
Итак, рассмотрим простой, но достаточно распространенный пример — вам необходимо сделать запрос к серверу, достать информацию о заказе и отобразить её каким-либо образом на страничке в браузере.
Что мы обычно делаем для решения этой задачи? Правильно. Мы делаем POCO модели заказа и метод контроллера, который будет возвращать ее экземпляр, обернутый в JSON. Вот они, наши герои (как вы поняли, я буду убирать лишний код для экономии места):
public class OrderViewModel
{
public string ItemName { get; set; }
public int Quantity { get; set; }
public decimal Subtotal { get; set; }
public bool IsPaid { get; set; }
public string ClientName { get; set; }
public string Address { get; set; }
}
public ActionResult GetOrder(int orderId)
{
var orderVm = new OrderViewModel()
{
// ... тестовые данные ...
};
return Json(orderVm, JsonRequestBehavior.AllowGet);
}
Здесь все более-менее понятно и комментарии, думаю, излишни. Давайте переключимся на клиент. Чтобы быть предельно понятным, я буду использовать jQuery для ajax-запросов, но при необходимости вы можете заменить его на что-нибудь свое. Как и ранее, я опускаю излишний glue code, а так же создание view, TypeScript-файла, подключение его к станице, установку jQuery из NuGet — это вы все сможете сделать и без меня. Подчеркиваю самую суть (еще раз напоминаю, что этот код на TypeScript):
private btnRequest_click() {
$.ajax({
url: '/Home/GetOrder?orderId=10',
success:this.handleResponse
});
}
private handleResponse(data: any) {
var text = `${data.ClientName}, ${data.Address} (${data.ItemName}, ${data.Quantity})`;
$('#divInfo').text(text);
}
Здесь прекрасно все. За исключением того, что принципиально от JavaScript эта конструкция ничем не отличается. Мы получаем с сервера кусок JSON-а, в котором какой-то объект и мы, рассчитывая на то, что у него есть поля ClientName, Address и прочие — выводим его в div. Звучит не очень стабильно. Если какой-нибудь горе-джуниор удалит из ViewModel-и и из C#-кода, скажем, поле ClientName (или переименует его в целях джуниор-рефакторинга), то все места на фронтенде, где используется такая конструкция — превратятся в детонаторы и будут ждать прихода тестировщика. Ну или end user-а — тут уж кому как повезет. Что же делать? Ответ очевиден — коль скоро мы используем TypeScript, то можно написать тайпинг для этой конкретной ViewModel-и и переписать код вот таким образом:
private handleResponse(data: IOrderViewModel) {
var text = `${data.ClientName}, ${data.Address} (${data.ItemName}, ${data.Quantity})`;
$('#divInfo').text(text);
}
Да, теперь нам стало несколько комфортнее — мы застраховались от доступа к незадекларированному полю. Но ситуация с джуниором, переименовывающим поле лично мне не дает спокойно спать. Да и написание тайпингов для всех ViewModel-ей… руками… ночью… А если их в проекте сотни? А если тысячи? Перспективка-то, откровенно, так себе.
Вот тут-то и вступает в игру Reinforced.Typings и начинается решение задачи кардинально другим путем. Итак, открываем PM-консоль (ну или кому удобно — можете сделать это через графический интерфейс) и ставим:
PM > Install-Package Reinforced.Typings
Замечаем в корне проекта новый файл Reinforced.Typings.settings.xml. Он достаточно детально документирован и переписывать все, изложенное в нем здесь я не вижу смысла (если конечно
<RtTargetFile>$(ProjectDir)Scripts\app\Server.ts</RtTargetFile>
После чего, я иду в код модельки и добавляю всего две строчки кода — юзинг на Reinforced.Typings.Attributes и атрибут [TsInterface] над самим классом модельки. Примерно вот так:
using Reinforced.Typings.Attributes;
[TsInterface]
public class OrderViewModel
{
// в самом коде модельки ничего не изменилось
}
После чего я пересобираю проект (делаю ему Rebuild) и вручную добавляю в Scripts\app\ сгенерированный согласно конфигурации Server.ts. Он уже лежит в указанной папке — просто не добавлен в проект. Давайте откроем Server.ts и посмотрим что же в нем такое:
// This code was generated by a Reinforced.Typings tool.
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
module Reinforced.Typings.Samples.Simple.Quickstart.Models {
export interface IOrderViewModel {
ItemName: string;
Quantity: number;
Subtotal: number;
IsPaid: boolean;
ClientName: string;
Address: string;
}
}
Вот видите? Чудненько. Теперь задача по написанию тайпингов для ViewModel-ей всего проекта не кажется таким уж страшным делом, не правда ли? Да и удаление-переименование properties ViewModel-и уже не является трагедией: при следующей же сборке проекта тайпинги перегенерируются и TypeScript-код, который на них завязан просто перестанет собираться, в чем вы можете убедиться собственноручно.
Думаю, на этом практическую демонстрацию основной возможности можно закончить, оставив более сложные примеры на следующие статьи и перейти к драматическому заключению.
Драматическое заключение и немного о том-о сем
На самом деле Reinforced.Typings поддерживает много чего. Вот краткий список:
- Автоматические тайпинги делегатов, наследников IEnumerable и IDictionary (если вы используете их в качестве properties)
- Тайпинги для enum-ов и классов (правда, тела методов он перевести в TypeScript автоматически не может. Но это можно сделать самостоятельно — об этом ниже)
- Тайпинги со сложными типами — Reinforced.Typings понимает, что вы используете в классе другой экспортируемый тип и автоматически ставит в том месте full-qualified имя используемого типа. В противном случае — тактично использует any
- Можно раскидать генерируемый код по разным файлам (class-per-file) с помощью соответствующего параметра конфигурации
- Можно добавить ///<reference ...> в генерируемые файлы посредством assembly-атрибута [TsReference]. А в случае с class-per-file, reference на соседние файлы добавляется автоматически
- Можно генерировать, .d.ts-файлы вместо обычного .ts-кода (есть некоторые отличия в синтаксисе)
- Вишенка на тортик — у каждого атрибута присутствует свойство CodeGeneratorType, в котором можно указать тип-наследник от Reinforced.Typings.Generators.ITsCodeGenerator<> (как вариант — унаследоваться от существующего генератора) и сделать свою генерацию шаблонного TypeScript-кода для всего, чего угодно. Таким путем можно дойти до автоматического создания knockout-овских ViewModel-ей прямо из кода серверных ViewModel-ей. В проекте по моему текущему месту работы, я перегрузил генератор кода для экшнов контроллера и сгенерировал таким образом для многих методов js-ный glue code, вызывающий соответствующий метод контроллера с указанными параметрами. Возвращают такие методы q-шный promise (просто потому что я люблю Q.js). Об этом я и расскажу в следующем посте
Из минусов: Автоматически сгенерировать тела методов классов Reinforced.Typings не может — он работает через Reflection. Ну и еще хочется отметить некие проблемы в ситуации когда серверные сущности представляют правильный TypeScript-код, но в уже сгенерированном коде содержится семантическая ошибка (например, удалено поле). В силу особенностей сборки TypeScript-а (он собирается самым первым во всем проекте), вы не сможете пересобрать проект и сгенерировать правильные тайпинги, которые исправят ошибку, пока не поправите ошибку вручную. Но я над этим работаю. Магия MSBuild творит чудеса.
Еще по проекту, как было сказано выше, крайне мало документации (эта статья, да readme на гитхабе). НО! Очень детально расписан XMLDOC и прокомментирован файл настроек. Так что, я думаю, на первое время должно хватить. А там я уже завербую студента-техписателя и сделаю нормальную
В общем-то на сегодня все. Если какие-то вопросы есть — пишите в комментариях. Постараюсь ответить.
Ссылочки на проект:
Reinforced.Typings на Github
Reinforced.Typings на NuGet
Полный код рассмотренного примера
Комментарии (30)
Tremor
16.09.2015 01:33Я в своем проекте использую DataContract для помечания ViewModel и все свойства, необходимые на клиенте, явно помечаю DataMember-атрибутами. При конвертации в json использую JSON.Net, который из коробки прекрасно работает с dataconract'ом. Ну и ModelBinder для всего этого написан.
Это позволяет не боятся серверных рефаткорингов даже при использовании чистого js и явно указывать, какие свойства нужны на клиенте (часть свойств вью-моделей нужны только для серверного рендеринга, гонят туда-сюда данные смысла нет). Ну и кроме того в шарпе принято свойство называть с большой буквы, а в js — с маленькой.
Отсюда вопрос: поддерживает ли ваша библиотечка dataconract?
Кстати, при конвертации в typescript generic-классы и классы с наследованием корректно генерируются?pnovikov
16.09.2015 04:52Эм… я боюсь, мы как-то не поняли друг друга. Библиотечка тайпинги пишет, а не занимается сериализацией :) В свете этого не очень понятен вопрос про DataContract.
Но на всякий случай: да, тайпинги для generic-классов она тоже пишет.Tremor
16.09.2015 12:33+1Попробую пояснить на примере: у меня есть ViewModel вида
[DataContract] public class OrderViewModel { [DataMember(Name = "itemName")] public string ItemName { get; set; } [DataMember(Name = "quantity")] public int Quantity { get; set; } public decimal Subtotal { get; set; } public bool IsPaid { get; set; } public string ClientName { get; set; } public string Address { get; set; } }
из нее я бы хотел получить интерфейс вида
interface OrderViewModel { itemName: string; quantity: number; }
А в идеале даже без явного указания Name:
[DataContract] public class OrderViewModel { [DataMember] public string ItemName { get; set; } [DataMember] public int Quantity { get; set; } public decimal Subtotal { get; set; } public bool IsPaid { get; set; } public string ClientName { get; set; } public string Address { get; set; } }
interface OrderViewModel { itemName: string; quantity: number; }
pnovikov
16.09.2015 12:38+1Спасибо, ваш фич-реквест принят :)
Tremor
16.09.2015 12:47Спасибо :)
Но атрибут [TsInterface] все равно нужен. DataContract может использоваться много для чего, и для всех классов, использующих его, генерить ts-интерфейсы не нужно, правильнее явно помечать классы, для которых нужны интерфейсы.
pnovikov
16.09.2015 12:43Правда мне все еще непонятно зачем вы используете DataContract, в то время как Json.NET прекрасно справляется и без них. И к тому же имеет свой, куда более гибкий [JsonProperty]
P.S: совсем забыл — да, и с наследованием у Reinforced.Typings тоже все хорошо.Tremor
16.09.2015 12:52Начал использовать еще до внедрения TypeScript'a. Это помогало не разломать фронтенд при рефакторинге бекэнда. С автоматической генерацией ts-интерфейсов эта проблема решается, но у нас проект большой и чистый js еще долго будет жить с нами.
Кроме того мне кажется важным явно помечать свойства, с которыми нужно работать на клиенте. Это во-первых, позволяет немного сократить объем ненужного траффика между клиентом и сервером, а во-вторых, на клиенте работать с чистыми интерфейсами без лишних серверных свойств.pnovikov
16.09.2015 13:02Ну тут вот фиг знает. На сколько я соображаю в системном дизайне, единственная роль ViewModel-и — хранить в себе данные, которые отображаются. Переиспользовать их же на клиенте (а особенно — переиспользовать на клиенте их часть) — в некотором роде нарушение SRP. Т.е. да, я за то, чтобы View рендерился от одной модели, а на клиент уходила другая (в общем случае). Хотя на практике возникают ситуации, когда в рендер и на клиент отправляется одна и та же модель — и чувствуешь себя с этим SRP как идиот. Для таких случаев можно и [TsInterface] пошалить.
Кстати о птичках — коль пошла такая пляска — можете потыкать у Json.NET свойство атрибута… как же его… по-моему NullValueHandling — и эта радость не будет отправлять на клиент null-поля (которые там будут undefined, но при проверке через if (object.Field) это не страшно).
Ну и чтобы не разносить на два комментария — вы упомянули, что не для всех классов, которые помечены [DataContract] нужно генерировать интерфейсы. Но тогда получается что надо классы еще чем-то помечать. Мол — ты, типа, для этих генерируй, а для этих не генерируй. Можно пометить их тем же [TsInterface], что и является дефолтной практикой для R.T.
А вот, кстати, переименования автоматического у меня нет. Как-то совсем даже забыл про него. Привык на сервере и на клиенте использовать PascalCase (и можете бить меня за это тапками). Но в следующей версии сделаю.Tremor
16.09.2015 21:20ViewModel хранит в себе данные, да. Часть из них мне удобнее рендерить серверно, а часть на клиенте (начали использовать реакт, но при этом разом перевести весь проект на реат невозможно). Что в этом плохого? А что такое SRP не просветите? :)
Кстати о птичках — коль пошла такая пляска — можете потыкать у Json.NET свойство атрибута… как же его… по-моему NullValueHandling — и эта радость не будет отправлять на клиент null-поля (которые там будут undefined, но при проверке через if (object.Field) это не страшно).
В моем случае это не поможет. У меня поля не Null, они заполнены, но на клиенте не используются.
Ну и чтобы не разносить на два комментария — вы упомянули, что не для всех классов, которые помечены [DataContract] нужно генерировать интерфейсы. Но тогда получается что надо классы еще чем-то помечать. Мол — ты, типа, для этих генерируй, а для этих не генерируй. Можно пометить их тем же [TsInterface], что и является дефолтной практикой для R.T.
Не имею ничего против аттрибута [TsInterface] :)
А вот, кстати, переименования автоматического у меня нет. Как-то совсем даже забыл про него. Привык на сервере и на клиенте использовать PascalCase (и можете бить меня за это тапками). Но в следующей версии сделаю.
Стараюсь все же придерживаться общепринятых стандартов. В этом есть свои плюсы)
pnovikov
16.09.2015 13:11Кстати про важные на клиенте свойства — в моем [TsInterface] вы можете явно сказать AutoExportProperties = false и пометить [TsProperty] те проперти (и через [TsField] те филды), которые должны быть экспортированы. При желании указав для них кодогенератор. И даже автоматическую букву I перед интерфейсами можете отключить. :)
При наличии этой информации, фич-реквест еще актуален?Tremor
16.09.2015 21:14Актуален. При таком подходе мне на каждое свойство придется вешать по 2 аттрибута: TsField для корректной конвертации в ts и DataMember/JsonProperty для корректной сериализации/десериализации в Json.
Два аттрибута — это уже перебор, на мой взгляд.
jakobz
16.09.2015 16:47Я тебе советую взять JSON.NET, в него легко встраивается конвертация имен: PersonName => personName при сериализации, и обратно при десериализации, делается автоматом без доп. аттрибутов.
http://www.newtonsoft.com/json/help/html/T_Newtonsoft_Json_Serialization_CamelCasePropertyNamesContractResolver.htmTremor
16.09.2015 21:12Я и так использую JSON.NET, у меня нет цели избаваиться от аттрибутов, я, наоборот, считаю их полезными.
KvanTTT
19.09.2015 02:44Вообще, меня давно и прочно будоражит идея тесной интеграции Back-End и Front-End на .NET стеке, что в свою очередь даже вылилось в попытку с наскоку написать целый транслятор из C# в JavaScript.
Чем не устроили существующие трансляторы, коих сейчас и так много существует List of languages that compile to JS.
Темой тесной интеграции я тоже занимался, правда давно уже: Универсальный код C# под .NET и JavaScript. Сейчас бы, понятное дело, использовал другие инструменты.pnovikov
19.09.2015 11:44Чем не устроили существующие трансляторы
Вы бы знали сколько раз мне задавали этот вопрос. Самый честный ответ: потому что я несколько по-своему вижу Use-Cases транслятора и имею свои представления о usability и flexibility такой штуки. Обратите внимание — ни один из предложенных вариантов не используют как «стандарт индустрии де-факто» и, в общем-то, все понимают почему (затруднена интеграция с MVC-проектом — тот же Script# подменяет mscorlib — то есть в одну сборку с проектом транслируемые классы уже не положишь, затруднена отладка, не очень понятно что у этих продуктов с расширяемостью, ну и много других мелких нюансов — не могу сходу перечислить). У меня свое видение, как это все должно работать, отлаживаться и тестироваться, по сему я и предпочел попытаться сделать свое. К слову, получилось в некотором роде похоже на DuoCode.KvanTTT
19.09.2015 16:11Ну так не лучше ли взять какой-нибудь существующий, на рослине или нет, и доработать? С нуля довольно много придется разрабатывать. А Script# по факту давно устарел уже, сейчас его уже не имеет смысла использовать.
pnovikov
19.09.2015 16:42Я на Roslyn и написал уже, собсно, ту самую версию, которая ушла в анабиоз (при том вот сразу через пару дней после выхода Roslyn-а — пришлось Microsoft.CodeAnalyzis выдирать с мясом из исходников). Так что мне не привыкать много разрабатывать :).
Там, на самом деле, затык в том, что в TS называется тайпинги. Т.е. придется переписывать интерфейсы для внешний библиотек, что руками делать долго и скучно.
А на самом деле, первоначальная версия транслятора внезапно хороша тем, что там есть парсер JavaScript, который превосходно разбирает даже минифицированный jquery в AST. Не спрашивайте зачем он там нужен, но он есть.KvanTTT
19.09.2015 17:07Хорошая грамматика для JavaScript внезапно есть в ANTLR: ECMAScript.g4. :)
pnovikov
19.09.2015 17:30Commits on Apr 24, 2014
@bkiers
Added an ECMAScript grammar.
А я свою грамматику (на Coco\R) написал где-то в марте прошлого года. Время, беспощадная ж ты штука.
А вообще то ли у меня руки не оттуда, то ли лыжи не едут… Но в общем была у меня какая-то грамматика для ANTLR-а, из которой я сгенерил все необходимое, сделал playground для тестирования, нажал F5 — и получил зацикливание на простеньком js-файле. С тех пор я ANTLR-ом не пользуюсь.KvanTTT
19.09.2015 19:13Ну так вообще-то в апреле появилась грамматика для 4 версии ANTLR. Судя по этому сайту, грамматика ECMAScript существует аж с 2008 года: www.antlr3.org/grammar/list.html
Но в общем была у меня какая-то грамматика для ANTLR-а, из которой я сгенерил все необходимое, сделал playground для тестирования, нажал F5 — и получил зацикливание на простеньком js-файле. С тех пор я ANTLR-ом не пользуюсь.
Скорее всего вы не разобрались с устройством ANTLR и его грамматик. Я вот тоже раньше не разбирался в лексических режимах, предикатах и других вещах. А после того, как прочитал книгу и попрактиковался я понял, что это очень мощный инструмент, обладающий большими возможностями по разбору формальных языков, в том числе и контекстно-зависимых. Сейчас вот на работе занимаюсь грамматикой для PHP.
pnovikov
19.09.2015 20:17Я как бы и не говорю что «виновата скамейка», нет :) Просто с ANTLR-ом у меня был неудачный первый опыт (который во многом влияет на восприятие технологии как таковой) и как-то так получилось что я перешел на Coco\R и вот я здесь.
www.antlr3.org/grammar/list.html
Дадада. Вот оттуда я ее скачал и сгенерил парсер, который зациклился на самом простом js-файле.
aleksey_bober
04.10.2015 14:36в Web essentials для visual studio сейчас есть возможность сгенерить ts-файлик с C# класса + при изменении класса файлик будет изменяться автоматичски
pnovikov
04.10.2015 15:37О, а покажите ссылку? Мне что-то не по глазам, а как гуглу задать этот вопрос — не знаю :)
P.S.: я еще допилю в свое творение fluent-конфигурацию (просто пока сильно больно времени нет) и будет следующая статья.
jakobz
У меня свой велосипед. Кроме перечисленного — я еще беру метаданные контроллеров MVC (через GetApiExplorer), и генерирую такие вот штуки на каждый контроллер:
За счет этого, кроме того что не надо это руками писать, мне еще не надо ставить аттрибуты на viewmodels — я просто генерирую интерфейсы для всего что входит и выходит из контроллеров.
Чтобы избавится от проблемы курицы и яйца (компиляция C#, генерация TS, и компиляция TS в одном проекте) — я просто вынес MVC-код в отдельную сборку.
Кстати, твоя библиотека как-то связана с TypeLite ( http://type.litesolutions.net/ )?
pnovikov
Вот у меня тоже началось со своего велосипеда.
Боюсь, если я так сделаю на живом проекте сейчас — ничего хорошего не получится :) Слишком много легаси-кода, слишком много всяких недокументированных методов. Повылазиет всякого… ну его :)
Мне такое решение не нравится, поэтому я предпочел хирургически препарировать MSBuild-скрипт (он же .csproj).
Впервые слышу о ней. Обязательно посмотрю.
pnovikov
Про атрибуты забыл дописать:
Для некоторых экшнов я сделал такую генерацию и вот в следующем посте расскажу-покажу как. А ставить-не ставить атрибуты… ну тут палка о двух концах. В конце-то концов нам нужен всего лишь список методов (при том крайне желательно, чтобы мы могли его контролировать!).
С другой стороны плюс атрибутов в том, что можно много всякого-разного понастраивать индивидуально для метода. Это можно, было бы, конечно, изобразить Fluent-конфигурацией или, упаси б-же, XML, но пока что мне не хочется сильно разделять TypeScript и C#-код, которые его представляет.
С третьей стороны — я писал библиотечку для широкого круга пользователей и кто его знает — не все же используют именно jq-промисы. Кому-то может вообще понадобятся дополнительные фичи вроде добавления в список параметров id-шника индикатора загрузки, или еще какой радости. Или вообще обернуть это в какой-нибудь ангуляровский интерфейс для запросов к серверу. Если я сделаю фиксированное решение и скажу «делайте как я сказал» — меня, боюсь, многие матом будут крыть. :) А так — можно просто дописать пак атрибутов специально для MVC и дать возможность каждому выбрать.
P.S: А вообще у моего [TsClass] можно задать дефолтный код-генератор для всех методов класса. Так-то.