[Анализ и проектирование систем, Совершенный код, Angular, TypeScript] Open-Closed Principle в Angular
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Всем привет! Меня зовут Вова, я фронтендер в Тинькофф. Сейчас перед нашей командой стоит задача редизайна функциональности на пересечении нескольких продуктов. Данная ситуация заставила нас задуматься во-первых о DDD, а во-вторых о гибкости наших решений, применяемых при разработке, и достичь этого нам помогли принципы SOLID, а точнее OCP и Dependency Inversion (не путать с Dependency Injection), о чем и хочется дальше поговорить.
Open-Closed PrincipleПринцип открытости-закрытости хорошо описан в статье Роберта МартинаOpen-Closed Principle, также можно прочитатьадаптацию моего коллеги. Разбирать этот принцип всегда легко на разного рода списках. Проанализировать его правильное использование можно так: добавление нового типа элементов в список должно происходить без изменения старого кода, но изменение бизнес-поведения элементов может происходить через изменение старого кода.Применение OCP в AngularСамым сложным во всей этой теории была именно сама адаптация принципа на наш любимый framework. Вопрос решили с помощью Dependency Injection механизма. Чтобы лучше понять, как — давайте вместе решим типичную задачу на Angular со следующим ТЗ:
- Есть расчетные счета, у которых должны отображаться имя и баланс счета
- Есть депозитные счета, у которых должны отображаться имя, баланс и дата закрытия счета
- Есть кредитные счета, у которых должны отображаться имя, баланс и статус счета. Статус счета выводим красным цветом
- Данные по счетам получаем из разных источников, так как они могут относиться к разным бизнес контекстам
У нас будет компонент, отвечающий за получение списка всех счетов и их отображение:
@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, в которых уже используется этот принцип:
- Control Value Accessor
- Event Manager
- 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 основных понятия:
- Плагин / Plugin - сервис, использующийся для расширения существующего функционала
- Менеджер плагинов / 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Для избежания таких ситуаций мы инвертируем зависимость и говорим, что компонент списка счетов умеет отображать имя счета, его статус и баланс. Хочешь такое отображение, будь добр, реализуй интерфейс AccountListItemPlugin, именно в этот момент мы инвертируем зависимость.
Компонент списка счетов при использовании DIИтогВ результате проделанной работы мы смогли добиться следующего:
- Добавление новых типов счетов не меняет компонент со списком счетов
- Изменение стиля отображения статуса не меняет компонент со списком счетов
- Работа с конкретным типом счета изолирована в пределах его плагина и не пересекается с другими типами счетов (превентивный подход к нарушению Single Responsibility Principle из SOLID)
Главное, помните - никакую систему нельзя закрыть на 100%, так что изменения в модели плагина при добавлении новых бизнес-правил - это нормально. Разберем на примерах:
- Под счетом может отображаться список карточек, привязанных к нему. Делать изменения в модели плагина в таком случае нормально
- Для расчетных счетов теперь при выводе баланса необходимо учитывать и долги клиента перед банком.Делать изменения в модели плагина в таком случае плохо
На данный момент мы применяем подход с плагинами везде, где работаем со списком сущностей, относящихся к разным бизнес контекстам (примеры списков - список счетов, список операций, список нотификаций). Такое разделение дает еще один бонус — сущности, относящиеся к единому бизнес контексту, можно упаковывать в отдельную папку. Разберем на примере депозита. Он отображается в списке счетов, у него есть своя страница счета и есть плашка с открытием депозита, весь этот код мы не разносим по конкретным реализациям списком, а держим реализации плагинов в единой папке deposit, что добавляет прозрачности при работе с конкретным бизнес контекстом
===========
Источник:
habr.com
===========
Похожие новости:
- [Совершенный код, Разработка под Android, Kotlin] Руководство по стилю Kotlin для Android разработчиков (Часть I)
- [JavaScript, TypeScript] Ant Design Component Customization and Bundle Optimization
- [Совершенный код, Assembler, Системное программирование, Компиляторы, Реверс-инжиниринг] И на Солнце есть пятна
- [Программирование, Совершенный код, Assembler, Алгоритмы] Перевод числа в строку с помощью FPU
- [Программирование, Анализ и проектирование систем, Проектирование и рефакторинг] Представление модели предметной области (МПО) и точек интеграции
- [Разработка веб-сайтов, JavaScript, Интерфейсы, Big Data, TypeScript] Автоматическая виртуализация рендеринга произвольной вёрстки
- [Анализ и проектирование систем, Микросервисы] Мониторинг и управление потоком задач в рамках взаимодействия микросервисов (перевод)
- [Анализ и проектирование систем, Проектирование и рефакторинг, Бизнес-модели, Научно-популярное, Инженерные системы] 7 заповедей любого инженера
- [JavaScript, Flutter] Пакет валидации mobx form validation kit 2.0 (TypeScript / Flutter)
- [Open source, PHP, PostgreSQL, Совершенный код, GitHub] Интеграция PHP проекта на GitHub и Scrutinizer
Теги для поиска: #_analiz_i_proektirovanie_sistem (Анализ и проектирование систем), #_sovershennyj_kod (Совершенный код), #_angular, #_typescript, #_angular, #_solid, #_ocp, #_openclosed, #_blog_kompanii_tinkoff (
Блог компании Tinkoff
), #_analiz_i_proektirovanie_sistem (
Анализ и проектирование систем
), #_sovershennyj_kod (
Совершенный код
), #_angular, #_typescript
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 08:52
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Всем привет! Меня зовут Вова, я фронтендер в Тинькофф. Сейчас перед нашей командой стоит задача редизайна функциональности на пересечении нескольких продуктов. Данная ситуация заставила нас задуматься во-первых о DDD, а во-вторых о гибкости наших решений, применяемых при разработке, и достичь этого нам помогли принципы SOLID, а точнее OCP и Dependency Inversion (не путать с Dependency Injection), о чем и хочется дальше поговорить. Open-Closed PrincipleПринцип открытости-закрытости хорошо описан в статье Роберта МартинаOpen-Closed Principle, также можно прочитатьадаптацию моего коллеги. Разбирать этот принцип всегда легко на разного рода списках. Проанализировать его правильное использование можно так: добавление нового типа элементов в список должно происходить без изменения старого кода, но изменение бизнес-поведения элементов может происходить через изменение старого кода.Применение OCP в AngularСамым сложным во всей этой теории была именно сама адаптация принципа на наш любимый framework. Вопрос решили с помощью Dependency Injection механизма. Чтобы лучше понять, как — давайте вместе решим типичную задачу на Angular со следующим ТЗ:
@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 } ]); } }
@Injectable()
export class DummyInterceptor implements HttpInterceptor { intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { return next.handle(req); } } @NgModule({
providers: [{ provide: HTTP_INTERCEPTORS, useClass: DummyInterceptor, multi: true }] }) export class AppModule {}
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> 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" } } ]); } } <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> Компонент списка счетов без использования DIДля избежания таких ситуаций мы инвертируем зависимость и говорим, что компонент списка счетов умеет отображать имя счета, его статус и баланс. Хочешь такое отображение, будь добр, реализуй интерфейс AccountListItemPlugin, именно в этот момент мы инвертируем зависимость. Компонент списка счетов при использовании DIИтогВ результате проделанной работы мы смогли добиться следующего:
=========== Источник: habr.com =========== Похожие новости:
Блог компании Tinkoff ), #_analiz_i_proektirovanie_sistem ( Анализ и проектирование систем ), #_sovershennyj_kod ( Совершенный код ), #_angular, #_typescript |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 08:52
Часовой пояс: UTC + 5