DevGang
Авторизоваться

Angular: архитектурные паттерны и лучшие практики 

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

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

Проблемы масштабируемости в front-end

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

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

Архитектура программного обеспечения

Чтобы обсудить лучшие практики и шаблоны архитектуры, нам нужно ответить на вопрос, что такое архитектура программного обеспечения. Мартин Фаулер определяет архитектуру как «разбивку системы на части». Кроме того, я бы сказал, что архитектура программного обеспечения описывает, как программное обеспечение состоит из его частей и каковы правила и ограничения, связи между этими частями. Как правило, архитектурные решения, которые мы принимаем при разработке нашей системы, сложно изменить, поскольку система со временем растет. Вот почему очень важно обращать внимание на эти решения с самого начала нашего проекта, особенно если предполагается, что создаваемое нами программное обеспечение будет работать в течение многих лет. Роберт К. Мартин однажды сказал: истинная стоимость программного обеспечения - это его обслуживание. Обоснованная архитектура помогает снизить затраты на обслуживание системы.

Aбстракции высокого уровня

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

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

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

Ниже фрагмент кода содержит компонент CategoriesComponent использующий экземпляр SettingsFacade из уровня абстракции для делегирования взаимодействия с пользователем (через addCategory() и updateCategory()) и представления некоторого состояния в его шаблоне (через isUpdating$).

@Component({
 selector: 'categories',
 templateUrl: './categories.component.html',
 styleUrls: ['./categories.component.scss']
})
export class CategoriesComponent implements OnInit {
 @Input() cashflowCategories$: CashflowCategory[];

 newCategory: CashflowCategory = new CashflowCategory();

 isUpdating$: Observable;

 constructor(private settingsFacade: SettingsFacade) {
   this.isUpdating$ = settingsFacade.isUpdating$();
 }

 ngOnInit() {
   this.settingsFacade.loadCashflowCategories();
 }

 addCategory(category: CashflowCategory) {
   this.settingsFacade.addCashflowCategory(category);
 }

 updateCategory(category: CashflowCategory) {
   this.settingsFacade.updateCashflowCategory(category);
 }
}

Слой абстракции

Уровень абстракции отделяет уровень представления от основного уровня, а также имеет свои собственные определенные обязанности. Этот уровень предоставляет потоки состояния и интерфейса для компонентов в уровне представления, играя роль фасада. Такого рода фасадные песочницы какие компоненты могут видеть и делать в системе. Мы можем реализовать фасады, просто используя поставщиков классов Angular. Классы здесь могут быть названы, например, с помощью постфикса Facade SettingsFacade . Ниже вы можете найти пример такого фасада.

@Injectable()
export class SettingsFacade {

 constructor(
   private cashflowCategoryApi: CashflowCategoryApi, 
   private settingsState: SettingsState
 ) { }

 isUpdating$(): Observable {
   return this.settingsState.isUpdating$();
 }

 getCashflowCategories$(): Observable {
   // здесь мы просто передаем состояние без каких-либо проекций
   // может случиться так, что необходимо объединить два или более потоков и вернуть их компонентам
   return this.settingsState.getCashflowCategories$();
 }

 loadCashflowCategories() {
   return this.cashflowCategoryApi.getCashflowCategories()
     .pipe(tap(categories => this.settingsState.setCashflowCategories(categories)));
 }

 // оптимистичное обновление
 // 1. обновить состояние пользовательского интерфейса
 // 2. вызвать API
 addCashflowCategory(category: CashflowCategory) {
   this.settingsState.addCashflowCategory(category);
   this.cashflowCategoryApi.createCashflowCategory(category)
     .subscribe(
       (addedCategoryWithId: CashflowCategory) => {
         // успех обратного вызова - есть идентификатор , генерируемый сервером, давайте обновить состояние
         this.settingsState.updateCashflowCategoryId(category, addedCategoryWithId)
       },
       (error: any) => {
         // обратный вызов ошибки - нам нужно откатить состояние
         this.settingsState.removeCashflowCategory(category);
         console.log(error);
       }
     );
 }

 // пессимистичное обновление
 // 1. вызов API
 // 2. обновить состояние пользовательского интерфейса
 updateCashflowCategory(category: CashflowCategory) {
   this.settingsState.setUpdating(true);
   this.cashflowCategoryApi.updateCashflowCategory(category)
     .subscribe(
       () => this.settingsState.updateCashflowCategory(category),
       (error) => console.log(error),
       () => this.settingsState.setUpdating(false)
     );
 }
}

Интерфейс абстракции

Мы уже знаем основные обязанности для этого слоя; выставлять потоки состояния и интерфейса для компонентов. Начнем с интерфейса. Методы для работы с состоянием loadCashflowCategories(), addCashflowCategory() и updateCashflowCategory() абстрагируют детали управления состоянием и внешние вызовы API из компонентов. Мы не используем поставщиков API (например CashflowCategoryApi) в компонентах напрямую, поскольку они находятся на основном уровне. Кроме того, изменения состояния не имеют значения для компонентов. Уровень представления не должен заботиться о том, как все делается, и компоненты должны просто вызывать методы из уровня абстракции, когда это необходимо (делегировать). Изучение открытых методов в нашем уровне абстракции должно дать нам быстрое понимание вариантов использования высокого уровня.

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

Состояние

Когда дело доходит до состояния, уровень абстракции делает наши компоненты независимыми от решения по управлению состоянием. Компонентам дают Observables с данными для отображения в шаблонах (обычно с конвейером async), и им должно быть все равно, откуда эти данные. Для управления нашим состоянием мы можем выбрать любую библиотеку управления состоянием, которая поддерживает RxJS (например, NgRx), или просто использовать BehaviorSubjects для моделирования нашего состояния. В приведенном выше примере мы используем объект состояния, который внутренне использует BehaviorSubjects (объект состояния является частью нашего основного уровня). В случае с NgRx мы будем отправлять действия в хранилище.

Такая абстракция дает нам большую гибкость и позволяет изменять способ управления состоянием, даже не затрагивая уровень представления. Можно даже легко перейти на серверную часть в реальном времени, например Firebase, что делает наше приложение real-time. Мне лично нравится работать с BehaviorSubjects, чтобы управлять состоянием. Если позднее, на каком-то этапе разработки системы, возникнет необходимость использовать что-то еще, с такой архитектурой, будет очень легко провести рефакторинг.

Стратегия синхронизации

Теперь давайте подробнее рассмотрим другой важный аспект уровня абстракции. Независимо от выбранного нами решения по управлению состоянием мы можем реализовывать обновления пользовательского интерфейса либо оптимистично, либо пессимистично. Представьте, что мы хотим создать новую запись в коллекции некоторых сущностей. Эта коллекция была получена из бэкэнда и отображена в DOM. При пессимистичном подходе мы сначала пытаемся обновить состояние на стороне сервера (например, с помощью HTTP-запроса), а в случае успеха мы обновляем состояние в приложении веб-интерфейса. С другой стороны, при оптимистичном подходе мы делаем это в другом порядке. Во-первых, мы предполагаем, что обновление бэкэнда выполнится успешно и немедленно обновит состояние внешнего интерфейса. Затем мы отправляем запрос на обновление состояния сервера. В случае успеха нам не нужно ничего делать, но в случае неудачи, нам нужно откатить изменения в нашем приложении и сообщить пользователю об этой ситуации.

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

Кэширование

Иногда мы можем решить, что данные, которые мы получаем из бэкэнда, не будут частью состояния нашего приложения. Это может быть полезно для данных  предназначенных только для чтения (read-only), которыми мы вообще не хотим манипулировать и просто передаем (через уровень абстракции) компонентам. В этом случае мы можем применить кеширование данных на нашем фасаде. Самый простой способ добиться этого - использовать оператор RxJS shareReplay(), который будет воспроизводить последнее значение в потоке для каждого нового подписчика. Взгляните на фрагмент кода RecordsFacade, с помощью RecordsApi для извлечения, кэширования и фильтрации данных для компонентов.

@Injectable()
export class RecordsFacade {
 private records$: Observable;

 constructor(private recordApi: RecordApi) {
   this.records$ = this.recordApi
       .getRecords()
       .pipe(shareReplay(1)); // кешируем данные
 }

 getRecords() {
   return this.records$;
 }

 // проецируем кэшированные данные для компонента
 getRecordsFromPeriod(period?: Period): Observable {
   return this.records$
     .pipe(map(records => records.filter(record => record.inPeriod(period))));
 }

 searchRecords(search: string): Observable {
   return this.recordApi.searchRecords(search);
 }
}

Подводя итог, что мы можем сделать на уровне абстракции:

  • раскрыть методы для компонентов, в которых мы:
    • делегируем выполнение логики на основной уровень,
    • принять решение о стратегии синхронизации данных (оптимистично или пессимистично),
  • выставить потоки состояния для компонентов:
    • выбрать один или несколько потоков состояния пользовательского интерфейса (и объединить их при необходимости),
    • кешировать данные из внешнего API.

Как мы видим, уровень абстракции играет важную роль в нашей многоуровневой архитектуре. В нем четко определены обязанности, которые помогают лучше понять и рассуждать о системе. В зависимости от вашего конкретного случая, вы можете создать один фасад для каждого модуля Angular или один для каждого объекта. Например, SettingsModule может содержать только SettingsFacade, если он не слишком раздутый. Но иногда может быть лучше создать более детальные фасады абстракции для каждого объекта индивидуально, как UserFacade для объекта User.

Основной слой

Последний слой является основным слоем. Вот где реализована основная логика приложения. Все манипуляции с данными и связь с внешним миром происходят здесь. Если бы для управления состоянием мы использовали решение, такое как NgRx, то здесь есть место для определения нашего state, action и reducer. Поскольку в наших примерах мы моделируем состояние с помощью BehaviorSubjects, мы можем инкапсулировать его в удобный класс состояний. Ниже вы можете найти пример SettingsState из основного слоя.

@Injectable()
export class SettingsState {
 private updating$ = new BehaviorSubject(false);
 private cashflowCategories$ = new BehaviorSubject(null);

 isUpdating$() {
   return this.updating$.asObservable();
 }

 setUpdating(isUpdating: boolean) {
   this.updating$.next(isUpdating);
 }

 getCashflowCategories$() {
   return this.cashflowCategories$.asObservable();
 }

 setCashflowCategories(categories: CashflowCategory[]) {
   this.cashflowCategories$.next(categories);
 }

 addCashflowCategory(category: CashflowCategory) {
   const currentValue = this.cashflowCategories$.getValue();
   this.cashflowCategories$.next([...currentValue, category]);
 }

 updateCashflowCategory(updatedCategory: CashflowCategory) {
   const categories = this.cashflowCategories$.getValue();
   const indexOfUpdated = categories.findIndex(category => category.id === updatedCategory.id);
   categories[indexOfUpdated] = updatedCategory;
   this.cashflowCategories$.next([...categories]);
 }

 updateCashflowCategoryId(categoryToReplace: CashflowCategory, addedCategoryWithId: CashflowCategory) {
   const categories = this.cashflowCategories$.getValue();
   const updatedCategoryIndex = categories.findIndex(category => category === categoryToReplace);
   categories[updatedCategoryIndex] = addedCategoryWithId;
   this.cashflowCategories$.next([...categories]);
 }

 removeCashflowCategory(categoryRemove: CashflowCategory) {
   const currentValue = this.cashflowCategories$.getValue();
   this.cashflowCategories$.next(currentValue.filter(category => category !== categoryRemove));
 }
}

На уровне ядра мы также реализуем HTTP-запросы в форме классов поставщиков. Этот вид класса может иметь постфикс Api или Service. Сервисы API несут только одну ответственность - это связь с конечными точками API и ничего больше. Мы должны избегать кеширования, логики или манипулирования данными здесь. Простой пример API-сервиса можно найти ниже.

@Injectable()
export class CashflowCategoryApi {
 readonly API = '/api/cashflowCategories';

 constructor(private http: HttpClient) {}

 getCashflowCategories(): Observable {
   return this.http.get(this.API);
 }

 createCashflowCategory(category: CashflowCategory): Observable {
   return this.http.post(this.API, category);
 }

 updateCashflowCategory(category: CashflowCategory): Observable {
   return this.http.put(`${this.API}/${category.id}`, category);
 }
}

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

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

Однонаправленный поток данных и управление реактивным состоянием

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

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

Как мы знаем из предыдущих глав, над уровнем представления находится базовый уровень, на котором реализована логика нашего приложения. Есть сервисы и провайдеры, которые работают с нашими данными. Что если мы применим тот же принцип манипулирования данными на этом уровне? Мы можем разместить данные приложения (состояние) в одном месте «над» компонентами и распространить значения до компонентов через наблюдаемые потоки (Redux и NgRx называют это место хранилищем). Состояние может распространяться на несколько компонентов и отображаться в нескольких местах, но никогда не изменяться локально. Изменение может произойти только «сверху», а компоненты ниже только «отражают» текущее состояние системы. Это дает нам важное свойство системы, упомянутое ранее - согласованность данных - и объект состояния становится единственным источником правды. Практически говоря, мы можем отображать одни и те же данные в нескольких местах и ​​не бояться, что значения будут отличаться.

Наш объект состояния предоставляет методы для служб в нашем основном уровне для управления состоянием. Всякий раз, когда необходимо изменить состояние, это может произойти только путем вызова метода для объекта состояния (или отправки действия в случае использования NgRx). Затем изменение распространяется «вниз» через потоки на уровень представления (или любую другую услугу). Таким образом, наше хранилище реагирует. Более того, с помощью этого подхода мы также повышаем уровень предсказуемости в нашей системе из-за строгих правил манипулирования и совместного использования состояния приложения. Ниже вы можете найти фрагмент кода, моделирующий состояние с помощью BehaviorSubjects.

@Injectable()
export class SettingsState {
 private updating$ = new BehaviorSubject(false);
 private cashflowCategories$ = new BehaviorSubject(null);

 isUpdating$() {
   return this.updating$.asObservable();
 }

 setUpdating(isUpdating: boolean) {
   this.updating$.next(isUpdating);
 }

 getCashflowCategories$() {
   return this.cashflowCategories$.asObservable();
 }

 setCashflowCategories(categories: CashflowCategory[]) {
   this.cashflowCategories$.next(categories);
 }

 addCashflowCategory(category: CashflowCategory) {
   const currentValue = this.cashflowCategories$.getValue();
   this.cashflowCategories$.next([...currentValue, category]);
 }

 updateCashflowCategory(updatedCategory: CashflowCategory) {
   const categories = this.cashflowCategories$.getValue();
   const indexOfUpdated = categories.findIndex(category => category.id === updatedCategory.id);
   categories[indexOfUpdated] = updatedCategory;
   this.cashflowCategories$.next([...categories]);
 }

 updateCashflowCategoryId(categoryToReplace: CashflowCategory, addedCategoryWithId: CashflowCategory) {
   const categories = this.cashflowCategories$.getValue();
   const updatedCategoryIndex = categories.findIndex(category => category === categoryToReplace);
   categories[updatedCategoryIndex] = addedCategoryWithId;
   this.cashflowCategories$.next([...categories]);
 }

 removeCashflowCategory(categoryRemove: CashflowCategory) {
   const currentValue = this.cashflowCategories$.getValue();
   this.cashflowCategories$.next(currentValue.filter(category => category !== categoryRemove));
 }
}

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

Модульная конструкция

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

Наше приложение по техническим причинам имеет также два дополнительных модуля. У нас есть объект CoreModule, который определяет наши одноэлементные сервисы, компоненты одного экземпляра, конфигурацию и экспорт любых сторонних модулей, необходимых для AppModule. Этот модуль импортируется только один раз в AppModule. Второй модуль SharedModule содержит общие компоненты / каналы / директивы, а также экспортирует часто используемые Angular модули (например CommonModule). SharedModule может быть импортирован любым функциональным модулем. На диаграмме ниже представлена ​​структура импорта.

Структура каталогов модулей

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

Умные и глупые компоненты

Последний архитектурный образец, который мы представляем в этой статье, касается самих компонентов. Мы хотим разделить компоненты на две категории в зависимости от их ответственности. Во-первых, это умные компоненты (или контейнеры). Эти компоненты обычно:

  • работают с фасадами и другими услугами
  • общаются с основным слоем
  • передают данные в глупые компоненты
  • реагируют на события от глупых компонентов
  • являются маршрутизируемыми компонентами верхнего уровня (но не всегда!)

Ранее представленный CategoriesComponent является умным. Он внедрил SettingsFacade и использует его для связи с основным уровнем нашего приложения.

Во второй категории есть глупые компоненты. Их единственная обязанность состоит в том, чтобы представить элемент пользовательского интерфейса и делегировать взаимодействие пользователя «вверх» умным компонентам через события. Подумайте о нативном элементе HTML, как . Этот элемент не имеет никакой конкретной реализованной логики. Мы можем думать о тексте «Click me» как о входных данных для этого компонента. Он также имеет некоторые события, на которые можно подписаться, например, событие клика. Ниже вы можете найти фрагмент кода простого глупого компонента с одним входом и без выходных событий.

@Component({
 selector: 'budget-progress',
 templateUrl: './budget-progress.component.html',
 styleUrls: ['./budget-progress.component.scss'],
 changeDetection: ChangeDetectionStrategy.OnPush
})
export class BudgetProgressComponent {
 @Input()
 budget: Budget;

 today: string;
}

Резюме

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

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

Перевод статьи: Angular Architecture Patterns and Best Practices (that help to scale)
Источник: angular-academy.com

#Angular
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

Поделитесь своим опытом, расскажите о новом инструменте, библиотеке или фреймворке. Для этого не обязательно становится постоянным автором.

Попробовать

В этом месте могла бы быть ваша реклама

Разместить рекламу