Хочу поделиться своим небольшом положительном опытом об проекте основанном на Angular + Typescript по прошествии года. Это далеко не новая связка, и я уверен, что уже многие её успешно используют. Конечно, уже многие ждут больше статей об React или Angular 2.0, но мне кажется, и этот опыт будет кому-то полезен.
Контекст
Я работаю в продуктовой компании. Основной продукт — это корпоративное приложение и историей 10+лет (Web, ASP.Net, C# MSSQL). Не смотря на почетный возраст и codebase 1M+ LoC, проект все еще актуальный, поддерживаемый, систематически проводятся и рефакторинг и обновления библиотек и подходов.
Но тут клиент захотел сделать замену основного UI на SPA (Angular) что бы он работал на устройствах. — Так и начался новый проект год тому назад.
До этого я работал архитектором на backend C# части, и работал над бизнес-правилами, SQL-performance, SQL-deadlocks, очередями и тп.
С Javascript активно работал только на своих pet-проектах и обьем кода был не значительным. Один свой pet-проект мне даже пришлось переводить с javascript на java (GWT траслятор) после 15k LoC! Из за того что проект перестал быть поддерживаемым. Тяжелый переход оправдал себя (пока не появился Angular).
Потому, мне было сложно представить как можно написать javascript проект с > 20k LoC и при этом сберечь его поддерживаемось, читабельность и расширяемость.
После анализа текущих похожих наших SPA проектов на Backbone, Angular я в этом еще раз убедился — они имели по 20k-35k LoC, структура была, но не четкая, любой рефакторинг в них уже был не возможен.
Требования
Требования же нового Angular проекта оценивались как 2-4 раза большие к текущим.
Более того, в требованиях также требовалась поддержка HTML5 Offline режима, при чем с поддержкой редактирования данных (а это значит что кроме REST мы еще должны иметь похожую реализацию на IndexedDB).
Старт проекта планировался через месяц-два. Потому я имел достаточно календарного времени на исследование текущего опыта текущей команды javascript и вход в новую для меня javascript — Angular экосистему.
Принятие Решений
Angular
Даже не обсуждался. У нас была команда с 1-2 годами его использования. Клиент настаивал на нем. Мне он тоже импонировал и после мого давного опыта с чистым jQuery, GWT UI, тяжеловесными UI Toolkits — казался глотком свежего воздуха.
Knockout, и старый-добрый Backbone требовали построения модели на своих обьектах/методах. Я этого наелся таких «ограничений» сполна до этого. Возможность использования Plain Javascript Objects/Arrays в Angular перевешивали любые performance выгоды Knockout, Backbone.
С другой стороны у Angular есть свои особенности — свой DI и overdesign.
Service, Module — единица структурирования кода (подобно классу), Factory, Controller — это сахар вокруг Service. Мне не очень хотелось использовать Angular DI, который к тому-же полностью сбивал с толку WebStorm 9.
Typescript
Главное решение которое я долго обдумывал, пробовал, прототипировал и продвигал перед клиентом. Кстати, достаточно сложно тогда было объясниться клиенту (и команде) почему мы должны писать на другом языке, а не на Javascript который к тренде, тем более на стратегическом проекте рассчитанном на года.
Важно понять основной посыл — Typescript это НЕ другой язык, это расширение Javascript (так же как LESS — препроцессор CSS).
Также важно понимать зачем вообще нужен нам Compile-time Type Checking (по аналогии с C#, Java, C++):
- Делает элементарные проверки корректности кода, иначе — нужно писать сотни элементарых Unit-test которые добавляют размер проекту и ломаются при любом изменении в приложении.
- Делает возможным рефакторинг, изменение структуры, мерж кода возможным и контролируемым
- Поддержка IDE обычно на порядок лучше — а это высокая скорость разработки. В любом месте кода вызываешь auto-complite после "." и видишь 5-10 вариантов, а не 100 приблизительно возможных. Find-usage реально работает, в IDE поиск по user.id не перепутает с product.id
- Разработчик меньше запускает тяжелое приложение, IDE, grunt build сразу предупреждает об простых, а иногда сложных ошибках
- Упрощает ревью/анализ кода
- Особенно актуален для больших проектов (по моему опыту > 10k LOC)
С моей точки зрения, killer-фича Typescript — опциональная типизация.
Когда я работал с GWT (там ты пишешь на java в своей IDE, а компилятор конвертирует это в javascript), то меня утомляло что ВСЕ, каждую переменную нужно помечать типом, это затратно и не нужно. Обозначать типами хочется только некоторые вещи внутри приложения (контракты между модулями, данные и их конвертацию в модель, аргументы и возврат функций тп).
Кстати, C#, Java, С++ тоже движутся к опциональной типизации — var, auto, dynamic.
Если просто скопировать strict-Javascript код в Typescript файл — то компилируется без проблем. Если проверка типов не нужна — просто привели к специальному типу «any», и можно все:
var a=<any>0; //вариант 1
var a:any=0; //вариант 2
a().some().thing()[15].else();
Особенно удобна поддержка Generic-types (так же как C# и Java), совместно с ES6 Arrow functions все пребразования и работа с промисами и моделью предельно читаема и безопасна, и она сохраняет this:
function loadUsers():ng.IPromise<Array<User>>{ /*some*/}
function loadBirthDates():ng.IPromise<Array<Date>>{
return loadUsers().then(users=>users.map(u=>u.birthDate));
}
Тут IDE четко знает что u — имеет класс User, и выдаст ошибку если birthDate там нет или у него не тип Date.
Поддержка ES6 сlass в Typescript.
На первый взгляд ES6 сlass — это обычный сахар (на примере babeljs, Typescript компилятора который генерирует prototype). Но тут дело не в runtime, тут дело в поддержке IDE и миллионах программистов мыслящих больше в OOP, а не в Functional стиле.
Пример 1:
function a(){
function b(){
function с(){
function d(){}
}
}
}
Допустим, что этот код написан в стиле OOP, где тут модуль, где класс, а где методе класса? А может это все класс в внутри него private метод b в котором с? Может, пример и надуманный, но порою так тоже оформляют код, как и через prototype.
Пример 2:
module a{
class b{
c(){
var d =>{}
}
}
}
Тут уровни в терминах OOP уже ясны без объяснений. Причем, они ясны не только человеку но и IDE. И IDE уже точно знает что есть что.
Читабельность в разы выше, хотя компилируется это во все те-же вложенные функции.
Также в отличии от GWT, Typescript компилируется по-файлово с сохранением всех отступов и комментариев (в WebStorm — по сохранению файла). Я не включаю source-maps в Chrome при просмотре Typescript файла — cгенерированный JS файл мало чем отличается.
Также в нашей Backend команде все знают С# и OOP. И использования Typescript — делает возможным ротацию их на Frontend (знания CSS и верстки не критично, повторюсь у нас корпоративное приложение).
Структура и реализация
Структура проекта выглядит так:
- framework
- business
- security
- dto
- UserDto.ts
- GroupDto.ts
- dal
- SecurityAdapter.ts
- SecurityOfflineAdapter.ts
- SecurityFacade.ts
- dto
- security
- ui
- security
- SecurityController.ts
- Security.html
- SecurityMobile.html
- security
- routing
- routingStates.ts
framework «видит» библиотеки, business «видит» framework и библиотеки, ui и routing видят всё.
Эта структура и соглашения выбрана не случайно, она напоминает наш ASP.Net проект к которому все в backend-команде привыкли. Может тянуть такое из backend в SPA покажется странным, но для меня параллели и удобство разработчиков очевидны.
Используется стандартная Typescript поддержка модулей (хотя там много вариантов доступны): import(s) в начале файла и _references.d.ts файлы. Не очень удобное решение, но понятное IDE, думаю что перейдем на что то другое если IDE его будет понимать.
Ui слой обращается к business только через *Facade классы.
Типичный Facade — это класс в котором практически все методы возвращают промисы. Facade внутри отвечает за вызовы Adapters.
///<reference path="../_references.d.ts"/>
module business.security {
export class SecurityFacade {
loadUsers():ng.IPromise<Array<UserDto>>{}
loadGroups():ng.IPromise<Array<GroupDto>>{}
renameUser(userId:string, name:string):ng.IPromise<void>{}
}
}
Offline поддержка осуществляться просто, например SecurityFacade.loadUsers реализован так: вызываем SecurityAdapter.loadUsers — если промис вернул ошибку — загружаем из IndexedDb SecurityOffineAdapter.loadUsers, если успешно то мы кешируем успешный результат в IndexedDb — UserOffineAdapter.saveUsers. Есть много деталей (например очереди на сохранение, лимит Indexed DB и тп), но грубо работает так.
///<reference path="../_references.d.ts"/>
module business.security {
export class SecurityFacade {
loadUsers():ng.IPromise<Array<UserDto>>{
SecurityAdapter.loadUsers().then(data => {
SecurityOffineAdapter.saveUsers(data);
return data;
}).catch(ex => {
if (isOffline()) { return SecurityOffineAdapter.loadUsers();}
throw ex
});
}
}
}
Решение не использовать Angular DI возможно, нестандартно. Даже без особой необходимости в Angular на бизнесе, нам приходиться оборачивать все типы promice (напр jQuery) и разношостные onSuccess/onError в Angular promice. $q как и любой другой провайдера легко получить вне Angular DI:
var $q = angular.element(document.body).injector().get("$q");
Любые обьекты хранимые в Json после загрузки и до отправки обязательно проверяются Adapter-ом. Typescript вам в этом не поможет. Потому в каждом Dto классе у нас есть copyAndVerify статический метод, который может внутри проверяет и очищает каждое свойство обьекта а также вызывает её у под-объектов, а также может сделать «upgrade» объекта до версии выше (например переименование поля и смена типа). Вызов copyAndVerify делает Adapter для каждого объекта в коллекции. При сохранение это также нужно — Angular навешивает свои методы на обьект которые нужно очистить до сериализации, а также Angular директивы могут сменить тип — например сохранить число как текст.
UI: SecurityController — это тоже обычный класс, только он регистрироваться в Angular, а так-же выбирает нужный view.
///<reference path="../_references.d.ts"/>
module ui.security {
import SecurityFacade = business.shared.SecurityFacade;
export class SecurityController {
users:Array<User>;
constructor(public $scope:IScope, public $window:ng.IWindowService) {
new SecurityFacade().loadUsers().then(users=>this.users); //Arrow-functions позволяют не писать var _this=this;
}
}
// @ngInject
function SecurityDirective ():ng.IDirective {
return {
scope: {},
templateUrl: routing.isMobile() ? 'ui/security/Security.html' : 'app/security/SecurityMobile.html',
controller:SecurityController ,
controllerAs: 'securityCtrl'
}
}
angular.module('app').directive('securityDirective', SecurityDirective);
}
Спустя год
Связкой довольны. В продакшене уже пол-года. Уже около 23k LoC и при этом проект легко пережил 7 средних рефакторингов, смену команды. Он продолжает быть легко поддерживаемым, скоро планируем добавлять очередную большую фичу. Недавно меняли IndexedDb либу на свою. Code-review проводить легко.
С поддержкой offline пришлось повозиться, но 200Mb храним легко на всех iPhone, iPad, Chome, IE10. Плюс, весь SPA (2,5Mb) загружаться из локального кеша, потому стартует шустро вне зависимости от интернет-подключения.
Производительности на Angular таки приходить периодически уделять внимание — убирать watch и заменять на события, пересматривать директивы и код views.
Autotests, кстати, написаны тоже на Typescript с использованием Protractor и PageObject подхода.
Сейчас Typescript поддерживается прекрасно в WebStorm, Visual Studio (в 2015 уже хорошо) и ряде других наверное. Если бы выбирали сейчас, может выбрали-бы React вместо Angular. Также думаю частично применять Typescript в ASP.Net на тяжелых UI контролах.
Автор: vitrilo