Русский
Русский
English
Статистика
Реклама

Open-Closed Principle в Angular

Всем привет! Меня зовут Вова, я фронтендер в Тинькофф. Сейчас перед нашей командой стоит задача редизайна функциональности на пересечении нескольких продуктов. Данная ситуация заставила нас задуматься во-первых о DDD, а во-вторых о гибкости наших решений, применяемых при разработке, и достичь этого нам помогли принципы SOLID, а точнее OCP и Dependency Inversion (не путать с Dependency Injection), о чем и хочется дальше поговорить.

Open-Closed Principle

Принцип открытости-закрытости хорошо описан в статье Роберта Мартина Open-Closed Principle, также можно прочитать адаптацию моего коллеги. Разбирать этот принцип всегда легко на разного рода списках. Проанализировать его правильное использование можно так: добавление нового типа элементов в список должно происходить без изменения старого кода, но изменение бизнес-поведения элементов может происходить через изменение старого кода.

Применение OCP в Angular

Самым сложным во всей этой теории была именно сама адаптация принципа на наш любимый framework. Вопрос решили с помощью Dependency Injection механизма. Чтобы лучше понять, как давайте вместе решим типичную задачу на Angular со следующим ТЗ:

  1. Есть расчетные счета, у которых должны отображаться имя и баланс счета

  2. Есть депозитные счета, у которых должны отображаться имя, баланс и дата закрытия счета

  3. Есть кредитные счета, у которых должны отображаться имя, баланс и статус счета. Статус счета выводим красным цветом

  4. Данные по счетам получаем из разных источников, так как они могут относиться к разным бизнес контекстам

У нас будет компонент, отвечающий за получение списка всех счетов и их отображение:

@Component({...})export class AccountsListComponent {  readonly accounts$ = combineLatest([    this.getBaseAccounts(),    this.getLoanAccounts(),    this.getDepositAccounts()  ]).pipe(map(accounts => accounts.flat()));  constructor(    private readonly baseAccounts: BaseAccounts,    private readonly deposits: DepositsService,    private readonly loans: LoansService  ) {}  private getBaseAccounts(): Observable<AccountListItem[]> {    return this.baseAccounts.getAccounts().pipe(      map(accounts =>        accounts.map(account => ({          info: account,          type: "base"        }))      )    );  }  private getDepositAccounts(): Observable<AccountListItem[]> {    return this.deposits.getAccounts().pipe(      map(accounts =>        accounts.map(account => ({          info: account,          type: "deposit"        }))      )    );  }  private getLoanAccounts(): Observable<AccountListItem[]> {    return this.loans.getAccounts().pipe(      map(accounts =>        accounts.map(account => ({          info: account,          type: "loan"        }))      )    );  }}
<ng-container *ngFor="let account of accounts$ | async">  <div class="account" [ngSwitch]="account.type">        <ng-container *ngSwitchCase="'deposit'">      <div class="name">{{account.info.name}} - {{account.info.amount}}</div>      <div class="status">Закроется {{account.info.closeDate}}</div>    </ng-container>        <ng-container *ngSwitchCase="'loan'"> {{account.info.info.name}} - {{account.info.info.amount}} |      <span style="color: red">{{account.info.info.status}}</span>    </ng-container>        <ng-container *ngSwitchCase="'base'">      {{account.info.name}} - {{account.info.balance}}    </ng-container>      </div></ng-container>

Сервисы по получению этих счетов и их модельки (пример одного из сервисов):

export type BaseAccount = Readonly<{  id: number;  name: string;  balance: number;}>;@Injectable()export class BaseAccounts {  getAccounts(): Observable<BaseAccount[]> {    return of([      {        id: 1000,        name: "Рублевый",        balance: 150      }    ]);  }}

Полный пример реализации можно посмотреть в stackblitz

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

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

Перед стартом можно вспомнить примеры механизмов в Angular, в которых уже используется этот принцип:

  1. Control Value Accessor

  2. Event Manager

  3. Interceptors

Давайте вспомним как добавляется новый Interceptor. Сначала реализуем сервис, который имплементирует HttpInterceptor:

@Injectable()export class DummyInterceptor implements HttpInterceptor {  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {    return next.handle(req);  }}

Подключим наш Interceptor в модуле:

@NgModule({  providers: [{    provide: HTTP_INTERCEPTORS,    useClass: DummyInterceptor,    multi: true  }]})export class AppModule {}

Как видим по примеру выше, секрет в подключении провайдера с параметром multi: true. Такой способ подключения провайдера говорит системе DI, что при получении значения токена HTTP_INTERCEPTORS мы получим массив подключенных провайдеров.

Применим полученные знания для решения задачки со списком счетов, но сначала выделим 2 основных понятия:

  1. Плагин / Plugin - сервис, использующийся для расширения существующего функционала

  2. Менеджер плагинов / Plugin Manager (опционален) - сервис, агрегирующий все подключенные плагины

Опишем интерфейс плагина счета и создадим для него токен:

export type AccountListItem = Readonly<{  id: number;  name: string;  amount: number;  status?: string;}>;export interface AccountListItemPlugin {  getItems(): Observable<AccountListItem[]>;}export const ACCOUNT_LIST_ITEM_PLUGIN = new InjectionToken<  AccountListItemPlugin>("Плагин для подключения счетов");

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

@Injectable()export class AccountsListManager {  constructor(    @Inject(ACCOUNT_LIST_ITEM_PLUGIN)    private readonly accountListItemPlugins: AccountListItemPlugin[]  ) {}  getAccounts(): Observable<AccountListItem[]> {    return combineLatest(      this.accountListItemPlugins.map(plugin => plugin.getItems())    ).pipe(map(items => items.flat()));  }}

Опишем плагин с основными счетами:

@Injectable()export class BaseAccountsPluginService implements AccountListItemPlugin {  getItems(): Observable<AccountListItem[]> {    return of([      {        id: 1000,        name: "Рублевый",        amount: 150      }    ]);  }}

И подключим его в главном модуле:

@NgModule({  providers: [    {      provide: ACCOUNT_LIST_ITEM_PLUGIN,      useClass: BaseAccountsPluginService,      multi: true    },  ]})export class AppModule {}

Теперь самое интересное: смотрим на преображение нашего компонента со списком счетов:

@Component({  ...})export class AccountsListComponent {  readonly accounts$ = this.accountsListManager.getAccounts();  constructor(private readonly accountsListManager: AccountsListManager) {}}

и его шаблон:

<div *ngFor="let account of accounts$ | async" class="account">  <div class="name">{{account.name}} - {{account.amount}}</div>  <div *ngIf=accounts.status class="status">{{account.status}}</div></div>

Полный пример реализации можно посмотреть в stackblitz

В момент времени, когда список счетов перестал зависеть от конкретной реализации счета, мы и применили OCP. НО! Мы потеряли стилизацию для статуса кредитного счета. Решать такую проблему можно разными способами, и мы решили использовать для таких случаев библиотеку от коллег ng-polymorheus (статья на хабре), которая позволяет не привязываться к конкретному типу данных для отображения информации в шаблоне. Сделаем несколько ВЖУХ, и отображение статуса станет полиморфным.

Первый вжух - меняем модельку плагина счетов

export type AccountListItem<A> = Readonly<{  id: number;  name: string;  amount: number;  account: A;  status?: PolymorpheusContent<AccountListItemContext<A>>;}>;export type AccountListItemContext<A> = Readonly<{   account: A;}>

Второй вжух - добавляем компонент для отображения статуса у кредитных счетов:

@Component({    selector: 'loan-account-status',    template: `<span class="negative">{{context.account.info.status}}</span>`,    styles: ['.negative {color: red;}'],    changeDetection: ChangeDetectionStrategy.OnPush,})export class LoanAccountStatusComponent {    constructor(      @Inject(POLYMORPHEUS_CONTEXT)    readonly context: AccountListItemContext<LoanAccount>  ) {}}

Третий вжух - правим плагин кредитных счетов:

@Injectable()export class LoanAccountsPluginService  implements AccountListItemPlugin<LoanAccount> {  private readonly accountStatus = new PolymorpheusComponent(    LoanAccountStatusComponent,    this.injector  );  constructor(private readonly injector: Injector) {}  getItems(): Observable<AccountListItem<LoanAccount>[]> {    return this.getAccounts().pipe(      map(accounts => {        return accounts.map(account => ({          id: account.id,          name: account.info.name,          amount: account.info.amount,          account,          status: this.accountStatus        }));      })    );  }  private getAccounts(): Observable<LoanAccount[]> {    return of([      {        id: 1,        info: {          name: "Кредитный счет",          amount: 1000,          status: "Activation"        }      }    ]);  }}

Последний вжух - учимся рисовать polymorpheus в шаблоне списка счетов для статуса:

<div *ngFor="let accountItem of accounts$ | async" class="account">  <div class="name">{{accountItem.name}} - {{accountItem.amount}}</div>  <div class="status">    <polymorpheus-outlet      [content]="accountItem.status"      [context]="{account: accountItem.account}"    >    </polymorpheus-outlet>  </div></div>

Полный пример реализации можно посмотреть в stackblitz

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

Еще чуть-чуть про плагины

Думаю, с OCP мы разобрались. А где же здесь Dependency Inversion Principle? В конечной реализации компонент со списком счетов предоставляет интерфейс плагина, который может подключаться без изменения кода компонента списка счетов.

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

Компонент списка счетов без использования DIКомпонент списка счетов без использования DI

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

Компонент списка счетов при использовании DIКомпонент списка счетов при использовании DI

Итог

В результате проделанной работы мы смогли добиться следующего:

  • Добавление новых типов счетов не меняет компонент со списком счетов

  • Изменение стиля отображения статуса не меняет компонент со списком счетов

  • Работа с конкретным типом счета изолирована в пределах его плагина и не пересекается с другими типами счетов (превентивный подход к нарушению Single Responsibility Principle из SOLID)

Главное, помните - никакую систему нельзя закрыть на 100%, так что изменения в модели плагина при добавлении новых бизнес-правил - это нормально.

Разберем на примерах:

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

  2. Для расчетных счетов теперь при выводе баланса необходимо учитывать и долги клиента перед банком.Делать изменения в модели плагина в таком случае плохо

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

Источник: habr.com
К списку статей
Опубликовано: 19.01.2021 12:23:47
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании tinkoff

Анализ и проектирование систем

Совершенный код

Angular

Typescript

Solid

Ocp

Open-closed

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru