Статьи

Анатомия большого углового приложения

Новое приложение всегда начинается с того приложения, которое будет разработано для простого обслуживания и разработки.

К сожалению, это просто вопрос времени, пока это приложение не станет нетривиальным и нуждается в реорганизации и / или переписывании. В такие моменты полезно, если вы разработали свое приложение таким образом, чтобы его можно было легко реорганизовать, и, с некоторой предусмотрительностью (и удачей), реорганизация может даже не потребоваться. Большее приложение обычно также означает большую команду, состоящую из людей с разной степенью фронтэнда и знанием углов. Наличие четкого набора рекомендаций относительно архитектуры и стиля кодирования окупается очень быстро.

Вышеупомянутые проблемы — это как раз те проблемы, с которыми мы столкнулись при создании приложения, которое получает более 10 миллионов посетителей каждый месяц. Через некоторое время разработка функции становится рутиной. Всегда возникают одни и те же вопросы:

Где я могу поместить этот кусок кода?

Как я могу изменить данные?

Почему это событие изменило мои данные и состояние?

Почему изменение фрагмента кода неожиданно нарушает более половины моих модульных тестов?

Было ясно — нам нужно новое направление.

Установка направления

Нашей целью в этот момент было создать что-то, что легко разрабатывать, поддерживать и тестировать. Если мы этого добьемся, есть большая вероятность, что наше приложение также будет ориентировано на будущее.

Эта статья призвана рассказать историю лучшей архитектуры, а также предоставить рабочий пример всех принципов, обсуждаемых здесь. Вот почему вы найдете прилагаемый репозиторий с интерактивным демонстрационным приложением . Детали репозитория и как это относится к этой статье, будут обсуждаться позже.

Разделение проблем

Глядя на проблему под другим углом, мы заметили, что самой большой проблемой было написание не слишком хрупких тестов. Простое тестирование означает, что издеваться над различными частями приложения легко, что приводит нас к выводу, что нам нужно лучше разделять проблемы.

эскизЭто также означало, что нам нужен лучший поток данных; тот, где совершенно ясно, кто предоставляет и изменяет данные и кто (и как) вызывает изменения данных. После нескольких начальных набросков мы подошли к черновому наброску потока данных, напоминающего поток React. Довольно ясно, как данные передаются в приложениях, похожих на Flux. В двух словах — событие (например, пользователь или обратный вызов) запрашивает изменение данных у службы, которая изменяет данные и распространяет изменения в компонентах, которым нужны эти данные. Это, в свою очередь, позволяет легко увидеть, кто вызвал изменение данных, и всегда есть один источник данных.

Лучший инструмент

Одна вещь, которая сделала нашу жизнь проще, это использование языка, который переносится в JavaScript. Это то, что я бы серьезно рекомендовал. Два главных претендента сейчас — это TypeScript и Babel. Мы выбрали TypeScript, потому что этот инструмент облегчал обнаружение ошибок во время компиляции и рефакторинг больших кусков кода.

Будущие корректуры

Забота о будущем означает наличие приложения, которое легко обслуживать, но также достаточно просто обновить. Скоро Angular 2 станет готовым к работе, а разумная архитектура с TypeScript значительно облегчит постепенное обновление.

Голые потребности

Ниже приведен список советов, которым, как я ожидаю, разработчики здравомыслящего приложения Angular будут следовать:

  • Разделите ваши проблемы
  • Держите поток данных однонаправленным
  • Управляйте своим состоянием пользовательского интерфейса, используя данные
  • Используйте трансплицированный язык
  • Иметь процесс сборки на месте
  • Тест

Давайте погрузимся в каждого из них.

Разделение проблем

Когда каждый слой приложения может работать как отдельный объект, он не слишком много знает о системе (слои, которые не находятся в прямом контакте) и легко тестируется, у вас будет приложение, с которым приятно работать , Angular предлагает строительные блоки, которые поддаются такому разделению интересов. Если вы хотите глубже понять эту тему, прочитайте этот пост в блоге .

Вертикальное разделение

Проблемы могут быть разделены по горизонтали и вертикали. Вертикальное разделение происходит, когда вы разбиваете приложение на вертикали. Каждая вертикаль имеет свою собственную жизнь и должна иметь горизонтальное разделение. Для нас лучше всего было полностью разделить части приложения (например, отдельную домашнюю страницу, страницу сведений, страницу конфигурации и т. Д.) На отдельные веб-страницы, каждая из которых инициализирует приложение Angular. Связь между этими модулями проста и достижима при использовании стандартных методов, таких как сеансы, параметры URL и т. Д.

Горизонтальное разделение

Где это становится интересным, это горизонтальное разделение. Вот где вы на самом деле создаете свое приложение Angular и размещаете все его строительные блоки. Важно отметить, что каждый слой (и блок внутри слоя) знает только о слое над ним и не заботится о слоях под ним, которые будут поглощать его открытые функции.

Каждая вертикаль имеет похожую структуру:

  • уровень услуг
  • фасадный слой
  • слой компонентов

Слой компонентов

Слой компонентов является слоем , что пользователи могут взаимодействовать с.
Он содержит директивы с сопровождающими шаблонами HTML и контроллерами. При тестировании (и концептуальном проектировании) директивы и шаблоны HTML создают один блок, а контроллеры — другой блок этого уровня.

Причина проста — тестирование контроллеров легко, потому что они могут быть протестированы без зависимости от Angular. Эта точная особенность контроллеров делает их также идеальным местом для размещения любой функциональности, необходимой вашей директиве. Тогда предпочтительным способом будет использование controllerAs и bindToController в директивах для создания компонентов.

Блоки в этом слое вводят части слоя фасада и через них могут извлекать данные и запрашивать изменение данных.

На этом уровне часто возникает вопрос  : собираемся ли мы доставить данные к компоненту через изолированную область или получить услугу и запросить ее? 

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

Фасадный слой

Фасадный слой представляет собой слой абстракции. Фасад определяется следующим образом :

Фасад может (…) уменьшить зависимости внешнего кода от внутренней работы библиотеки, так как большая часть кода использует фасад, тем самым обеспечивая большую гибкость при разработке системы.

В нашей архитектуре единственной задачей является абстрагирование задней части приложения ( уровень служб ) от передней части приложения ( уровень компонентов ). Блоки этого уровня — это сервисы , методы которых вызываются из уровня компонентов, а затем перенаправляются в соответствующие сервисы на уровне сервисов .

Это так просто.

Но также и мощный, потому что такую ​​абстракцию легко разделить, а изменения, сделанные на уровне сервисов, никогда не влияют на уровень компонентов .

Уровень услуг

Уровень услуг включает в себя все умные вещи, которые должно делать ваше приложение. Будь то изменение данных, асинхронная выборка, изменение состояния пользовательского интерфейса и т. Д. Этот уровень также является уровнем, на котором ваши данные живут и передаются на уровень компонентов через уровень фасада .

Этот слой обычно собирается:

  • Сервисы, которые обрабатывают ваши данные или состояние пользовательского интерфейса (например, DataService и UIStateService ),
  • Услуги, которые помогают им в этом (например, DataFetchService или LocalStorageService ) и
  • Другие сервисы, которые могут вам понадобиться, например сервис, который сообщит вам, на какой точке прерывания вы находитесь в адаптивном макете.

Сохранение потока данных однонаправленным

Настало время объяснить, как все слои и блоки совмещаются в однонаправленном потоке данных.

Получение данных

Уровень услуг включает службы, которые знают, как получить данные. Начальный набор данных уже присутствует как часть HTML, асинхронно извлекается или жестко кодируется. Эти данные преобразуются в объекты (ваши модели) и доступны через методы, присутствующие в сервисах на уровне ваших сервисов .

Блоки в слое компонентов теперь могут выполнять запрос данных через слой фасада, получать уже проанализированные данные и отображать их. Легко.

Изменение данных

Если происходит событие, которое должно изменить данные, блоки на уровне компонентов отправляют запрос на уровень фасада (например, « обновить список пользователей » или « обновить содержимое этой статьи этими данными »).

Фасад слой передает запрос на нужную службу.

На уровне служб запрос обрабатывается, необходимые данные изменяются, и все директивы получают новые данные (потому что они уже были связаны с директивами). Это работает благодаря циклу дайджеста. Большинство происходящих событий запускают цикл дайджеста, который затем обновляет представления. Если у вас есть событие , которое не запускает цикл дайджеста (например, слайдер слайда — событие), вы можете вызвать дайджест цикла вручную.

Держите это течет

Как видите, в вашем приложении есть только одно место, которое изменяет ваши данные (или их часть). Это же место предоставляет эти данные и является единственной частью, где может произойти что-то не так с данными, что значительно облегчает отладку.

Управление состоянием пользовательского интерфейса с использованием данных

Большее приложение Angular, вероятно, будет иметь различные состояния, в которых оно может оказаться. Нажатие на переключатель может привести к изменению вкладки, выбору продукта и выделению строки в таблице одновременно. Делать это на уровне DOM (например, манипуляции с jQuery) было бы плохой идеей, потому что вы теряете связь между вашими данными и представлением.

Поскольку мы уже создали хорошую архитектуру, давайте использовать ее для управления состоянием пользовательского интерфейса. Вы бы создали UIStateService на уровне сервисов . Эта служба будет хранить все соответствующие данные пользовательского интерфейса и изменять их при необходимости. Как уже объяснялось, эта служба будет предоставлять эти данные, но также будет отвечать за их изменение. Фасад слой затем передать все необходимые изменения в правильном обслуживании (ов).

Важно отметить, что UIStateService может не понадобиться. Поскольку представления зависят от данных, большую часть времени можно просто использовать эти данные и контролировать состояние представлений. Отдельная служба состояний имеет смысл, когда вам нужно управлять состоянием пользовательского интерфейса, которое полностью отделено от вашей модели.

Прозрачный код

Из языка в JavaScript есть много преимуществ. Несколько очевидных из них:

  • Использование функций, которые появятся в более новых версиях ECMAScript
  • Абстракция причуд JavaScript
  • Ошибки времени компиляции
  • Лучший инструмент

Вы можете перейти из будущих версий ECMAScript с помощью Babel или даже добавить поддержку ввода с помощью TypeScript или Flow . Вы не можете ошибиться ни с одним из этих вариантов, потому что, в конце концов, вы получите пригодный для использования JavaScript. Если какого-либо из инструментов больше не существует, вы можете продолжить работу с сгенерированным JavaScript.

Машинопись

Видя, как Angular Team объединились с Microsoft и базируют Angular 2 на TypeScript, можно с уверенностью предположить, что поддержка этого стека будет действительно хорошей. В этом смысле имеет смысл познакомиться с TypeScript.

Помимо обеспечения безопасности типов, TypeScript имеет действительно хорошую поддержку инструментов с такими редакторами, как Sublime, Visual Studio Code или WebStorm, которые предлагают автозаполнение, встроенную документацию, рефакторинг и т. Д. Большинство из них также имеют встроенный компилятор TypeScript, так что вы можете найти компиляцию ошибки при кодировании. Отличное автозаполнение и встроенная документация возможны благодаря файлам определения типа. Обычно вы получаете файл определения типа, помещаете его в свой проект и ссылаетесь на него — упомянутые функции работают сразу из коробки. Зайдите на DefinitiveTyped, чтобы увидеть, какие библиотеки и фреймворки поддерживаются (подсказка: шансы, вы найдете все библиотеки или фреймворки, которые вы там используете), а затем используйте tsd, чтобы легко установить их из CLI.

Команда Angular предлагает концепцию, в которой библиотеки напрямую включают файлы определения типа. Преимущества такого подхода двояки: нет необходимости искать файлы определения типа, а файл определения типа, который вы получаете с версией библиотеки, всегда соответствует API этой версии.

Чтобы быстро ознакомиться со всеми преимуществами разработки с использованием TypeScript, вы можете посмотреть это видео из Angular Connect .

Переключение на TypeScript в большинстве случаев безболезненно, поскольку действительный код JavaScript является допустимым кодом TypeScript. Просто измените расширения файлов на  .ts, вставьте компилятор TypeScript в процесс сборки, и все готово .

Говоря о процессе сборки …

Наличие процесса сборки на месте

У вас есть процесс сборки, не так ли?

Если нет, выберите Grunt , Gulp , Webpack или любой другой инструмент для сборки / упаковки, с которым вы хотели бы работать, и приступайте к работе. В репозитории, сопровождающем эту статью, используется Gulp, так что вы можете получить представление о том, как код передается, упаковывается для Интернета и тестируется. Я не буду вдаваться в подробности об инструментах сборки, потому что есть много статей, детализирующих их.

тестирование

Вы должны протестировать все части вашего приложения.

Я часто вижу, что люди отказываются от тестирования шаблонов HTML, потому что у них есть интеграционные тесты. К сожалению, Angular не сообщит вам, если у вас есть опечатка где-то в вашем шаблоне, и интеграционные тесты могут стать большими и медленными очень быстро, но при этом не охватывают достаточно места (не говоря уже о времени, необходимом для их обслуживания).

Дело в том, что при хорошей архитектуре тестирование легко, потому что вы тестируете только написанный код и копируете все зависимости. Внедрение зависимостей Angular также играет большую роль, и тестирование с Angular является простым .

Вероятно, для всех ваших тестовых случаев будет достаточно сочетать Karma в качестве тестового прогонщика и Jasmine в качестве тестового фреймворка. Тестирование в процессе сборки (между переносом и упаковкой) также позволит убедиться, что вы не представляете ошибки регрессии.

Тестирование директив означает отдельное тестирование определения директивы с сопровождающим ее шаблоном и контроллерами.
Контроллеры просты в тестировании, потому что они просто создаются с удалением всех их зависимостей, и вы можете сразу приступить к тестированию его внутренних компонентов. Большую часть времени вы будете просто проверять, делегированы ли ваши контроллеры правильному сервису на уровне фасада .
Создание директив и удаление их контроллера также легко, поскольку контроллер присутствует в скомпилированном элементе после компиляции Angular. Чтобы проверить, что происходит в шаблоне, измените макет контроллера или области действия и запустите цикл дайджеста. Новые значения должны присутствовать.

Тестирование сервисов на уровне фасадов или сервисов так же просто, потому что вы можете удалить каждую зависимость и действительно протестировать только тот код, который присутствует.

Это также основной вывод — тестовый код, который присутствует в компоненте, который вы тестируете. Тесты должны провалиться, если вы изменяете открытые методы компонента, но только тесты, связанные с этим компонентом, а не половину всех ваших тестов. Если написание тестов затруднительно, вы либо слишком много тестируете (и не занимаетесь имитацией), либо испытываете проблемы с архитектурой вашего приложения.

Пример из реального мира

1-uTlQ1o47ZW5ptmhFnKlBvQ

Heroes of Warcraft является товарным знаком, а Hearthstone является товарным знаком или зарегистрированным товарным знаком Blizzard Entertainment, Inc. в США и / или других странах.

В рамках этой статьи вы можете ознакомиться с демонстрационным приложением и поиграть здесь .

Это приложение для управления колодами для карточных игр. В таких играх, как Hearthstone, Magic the Gathering и им подобных, игроки собирают колоды из постоянно растущей коллекции карт и сражаются друг против друга. Вы можете создавать колоды и управлять ими с помощью готового набора карт, изготовленных по заказу HearthCards .

Исходный репозиторий

Мы обсудим здесь репозиторий, из которого было создано демонстрационное приложение, и вы можете найти этот репозиторий здесь . Идея этого репозитория заключается в том, чтобы предоставить вам работающее приложение, которое исследует идеи, обсуждаемые в этой статье, и хорошую шпаргалку, когда вы не уверены, как реализовать функцию в Angular с использованием TypeScript.

Для начала клонируйте репозиторий и следуйте инструкциям README. Это собирается запустить ваш сервер и обслуживать скомпилированные модули Angular.

Для облегчения работы позже я рекомендую запустить наблюдателя в каждой вертикали, запустив часы gulp . Теперь, каждый раз, когда вы изменяете файл внутри вертикали, Gulp собирается скомпилировать и протестировать ваши изменения.

Вертикальное разделение

Приложение разделено на три вертикали: общая , колода менеджеров и колода . Каждая из этих вертикалей является угловым модулем. Общий модуль представляет собой утилиту модуль и получает вводит в другие модули.

Горизонтальное разделение

All verticals feature a similar structure which follows what we’ve already discussed here in the article. You’ll find the directories components and services where the components directory contains directives, controllers and templates making it the components layer and the services directory where you’ll find the facade and services layer.

Let’s explore the layers.

Services Layer

The deckmanager vertical is a good candidate because it features a data managing service and a UI state managing service. Each of these services has its own model consisting of objects that they’ll manage and provide.

DataService, further more, gets LocalStorageService from the common module. This is where separation of concerns pays off — the data (decks and cards in the decks) are going to be stored into local storage. Because our layers are decoupled, it’s easy to replace that storage service with something completely different.

If you take a look at the DataService in the deckbuilder vertical, you’ll see that we’re also injecting a PageValueExtractorService. That service allows us to have pre-populated data in HTML that gets parsed and used right away. This is a powerful technique that can make application startup much faster. Once again, it’s easy to see how trivial it is to combine data storage strategies and, if we decide to change the concept completely, our components won’t notice it. They just care about getting the right data, not how it got there.

Facade Layer

Let’s look at the facade layer and see how it works in practice.

// ... imports

export default class FacadeService implements IFacadeService {
    private dataService:IDataService;
    private uiStateService:IUIStateService;

    constructor(dataService:IDataService, uiStateService:IUIStateService) {
        this.dataService = dataService;
        this.uiStateService = uiStateService;
    }

    public getDecks():IDeck[] {
    return this.dataService.getDecks();
}

public createNewDeck(name:string):void {
    this.dataService.createNewDeck(name);
this.uiStateService.setShowNewDeckForm(false);
}

// ... rest of service
}

FacadeService.$inject = ['DataService', 'UIStateService'];

The FacadeService gets the DataService and UIStateService by injection and can then further delegate logic between the other two layers.

If you look at the createNewDeck() method, you can see that the FacadeService isn’t necessarily just a delegation class. It can also decide simple things. The main idea is that we want a layer between components and services so that they don’t know anything about each other’s implementation.

Components Layer

The structure of components includes the directive definition, a template and a controller. The template and controller are optional but, more often than not, they’re going to be present.

You can notice that the components are, for a lack of better words, dumb. They get their data and request modifications from the facade layer. Such a structure yields two big wins: less complexity and easier testing.

Take a look at a controller:

// ... imports

export default class DeckController {
    private facadeService:IFacadeService;

    constructor(facadeService:IFacadeService) {
        this.facadeService = facadeService;
    }

    public getDecks():IDeck[] {
        return this.facadeService.getDecks();
    }

    public addDeck():void {
        this.facadeService.setShowNewDeckForm(true);
    }

    public editDeck(deck:IDeck):void {
        this.facadeService.editDeck(deck);
    }

    public deleteDeck(deck:IDeck):void {
        this.facadeService.deleteDeck(deck);
    }
}

DeckController.$inject = ['FacadeService'];

A quick glance makes it obvious that this component provides CRUD functionalities for our game decks and that it’s going to be really easy to test this class.

Data Flow

As discussed in the article, the data flow is going to feature components using the facade layer which is going to delegate those requests to the correct services and deliver results.

Because of the digest cycle, every modification is going to also update the values in the components.

To clarify, consider the following image:

This image shows the data flow when a user clicks on a card in the Deck Builder. Even before the user interacts with the card gallery, the application has to read the contents of the current deck and all cards supported in the application. So, the first step is the initial pull of data that happens from the components through the facade to the services.

After a user clicks on a card the facade layer gets notified that a user action needs to be delegated. The services layer gets notified and does the needed actions (updating the model, persisting the changes, etc.).

Because a user click using ngClick triggers a digest cycle, the views are going to get updated with fresh data just like it happened in the first step.

Under Consideration

The application is tested and features a simple build process. I’m not going to dive deep into these topics because the article is big enough as is, but they are self-explanatory.

The build process consists of a main Gulp configuration file and little configuration files for each vertical. The main Gulp file uses the vertical files to build each vertical. The files are also heavily annotated and shouldn’t be a problem to follow.

The tests try to be limited just to files that they’re concerned with and mock everything else away.

What Now?

The application has lots of places where it could be improved upon:

  • Additional filtering of cards by cost, hit points, attack points or card rarity
  • Sorting by all possible criteria
  • Adding Bootstrap’s Affix to the chosen cards in the deck builder
  • Developing a better Local Storage service which has much better object checking and casting
  • Further improving the Page Value Extractor service to allow for metadata being included in the JSON for better type association
  • etc.

If you check the source code of the application, you’ll notice that there are comments marked with TODO. It’s possible to track these comments in IDEs and text editors (WebStorm and Visual Studio Code do it out of the box, Sublime has several plugins that support it). I’ve included several TODOs that range from new features to improvements and you’re very welcome to fix them and learn a few things along the way.

The Devil is in the Details

The points discussed in this article mostly deal with big picture stuff.

If you want to find out about implementation details that can creep up while developing an Angular application, watch this entertaining video from Angular Connect about the usual errors in Angular applications.

Another great resource is this blog post by a developer who re-built the checkout flow at PayPal with Angular.