ACHTUNG! Все примеры кода в данной статье набросаны на коленке и не пригодны для использования в том виде, в котором они приведены. Мы даже сборку не тестировали. Но статья и не про код!
Всем привет, меня зовут Андрей, я — php-разработчик в wpp.digital.
Сегодня я поделюсь с вами историей. Она о том, как поверхностное понимание (или непонимание) паттернов проектирования отстрелило мне ногу. А еще поделюсь примером реализации простой истины: знание чего-то не равно умению это применять. Кстати, главным героем поэмы являюсь (неожиданная информация) я.
Кому будет полезен данный текст? В первую очередь, мне для рефлексии. Во вторую — той редкой породе новичков, которая умеет учиться на чужих ошибках. Ну и в последнюю очередь — опытным коллегам, которые могут поностальгировать по временам джуновых задач и огромных перспектив. Последние еще могут разнести в комментариях всё, что я здесь написал.
Теперь к задаче.
Дано
Небольшой IT-отдел в фирме-дистрибьюторе. Жесткие требования на использование только своего софта в целях безопасности конфиденциальных данных и не самые космические бюджеты на IT-специалистов прилагаются.
Я, ваш покорный слуга, года 3 назад закончивший профильный вуз и вынужденный уже третий раз сменить стек — это и есть вся продуктовая команда на MVP нового продукта.
Внутреннее веб-приложение (SPA) для торгового представителя, облегчающее работу с потенциальным клиентом: отчеты о посещении, методички по продажам, отчет о прогрессе KPI менеджеров. Бизнес-требование — продукт должен содержать рекомендации и инструментарий для отчетов о посещениях.
Найти
В приложении нужно было сделать опросник. При этом сами вопросы и варианты ответов должны редактироваться менеджером без привлечения разработчиков. Интегрировать в приложение какие-нибудь Google Forms или аналоги нельзя из-за требований по хранению конфиденциальных данных только на своей инфраструктуре.
Решение
Технологически решили делать SPA на Angular 2+ с бэкэндом на C# + SqlAnywhere. Выбор обусловлен наличием лицензий и какого-никакого опыта у исполнителей и более ничем.
В БД, ничтоже сумняшеся, применили всем известный антипаттерн Entity-Attribute-Value [https://habr.com/ru/companies/tensor/articles/657895/]. Можем отдельно рассмотреть возможные варианты решения этой проблемы. В том числе вариант использования отдельно документо-ориентированной БД. Однако на тот момент, да и на этот, подобное решение всё ещё кажется мне достаточно оптимальным.
На бэкэнде тоже сильно не заморачивались. Просто собирали опросник в JSON со списком вопросов примерно такого вида
{
"id": "12345",
"name": "очень нужный опросник",
"metadata": {},
"questions": [
{
"id": "123",
"name": "В чем смысл жизни?",
"description": "Отвечать развернуто, не ограничиваясь ссылками на Сартра",
"type": "text"
},
{
"id": "456",
"name": "Лучший фильм о космосе?",
"description": "По мнению генерального директора вашей компании",
"type": "select",
"options": [
"Кин-дза-дза!",
"Точно Кин-дза-дза!",
"Однозначно Кин-дза-дза!"
]
}
]
}
Вот это летело в SPA, а SPA в свою очередь должно было нарисовать формочку, обработать введенные пользователем ответы и выплюнуть очень похожий JSON с ответами обратно на бэк.
Думаю, опытные коллеги уже поняли, к чему я веду. У вопроса есть такой неприятный параметр, как тип ответа. От него зависит, как будет отображаться элемент для ввода этого самого ответа и как будут интерпретированы данные.
Как же мы можем нарисовать «то, сам видишь что» в момент, когда приходят данные от бэка?
«Нам нужна какая-нибудь фабрика!» — воскликнет даже юный падаван.
И будет прав. Очень прав. Но есть одно большое «но». Мы имели дело с компонентным фреймворком, управляемым версткой. И в тот момент это сломало мое восприятие и заставило принять ряд очень плохих решений, за которые мне до сих пор стыдно перед коллегами, принявшими у меня проект на развитие и поддержку. Прости, Артем.
Понятно, что фабрику надо было реализовывать каким-то компонентом, который внутри себя в зависимости от каких-то условий, выставляет другие компоненты, заряжая их данными из пришедшей JSON. И тут ваш покорный слуга сделал ошибку номер раз.
Та самая ошибка
Я зачем-то сделал компонент-фабрику в виде цепочки директив ngIf, которую, естественно, при добавлении любого нового типа пришлось бы дополнять.
@Component({
selector: "questionary",
standalone: true,
imports: [NgIf, NgFor],
template: `
<ul class='questonary'>
<li *ngFor="let question of questions">
<question-type-text
*ngIf="question.type=='text'"
[question]="question"
>
</question-type-text>
<question-type-select
*ngIf="question.type=='select'"
[question]="question"
>
</question-type-select>
</li>
</ul>
`
})
export class QuestionaryComponent
{
}
В целом такое решение на тот момент даже рекомендовалось официальной документацией. Но есть нюанс. Дальше компонент должен был бы обрабатывать свой ввод, а после — отсылать на сервер общий json, собранный из ответов на все вопросы.
«Круто», — подумал я и не смог инкапсулировать работу с данными в компонент. После этого таких штук со switch-case или if-else у меня стало 3, потому что работа с данными была вынесена в сервис.
А потом нам пришло 5 задач по добавлению разных типов, потому что надо было реализовать, понимаешь ли, и простые текстовые вводы, и ввод с проверкой шаблона, и обычный дропбокс, и комбобокс (который дропбокс + простой строковый инпут а-ля для варианта «другое») и еще что-то, что я успел забыть за давностью лет. И оно все разрасталось и превращало поддержку этой штуки просто в ад.
В целом проблема понятная. У нас тут нет настоящей реализации паттерна Factory и на лицо нарушение того самого Open-Closed принципа, который O из SOLID.
Что можно использовать в качестве решения
Вариант раз. Как минимум инкапсулировать всё, что можно в сами компоненты вопросов. Например, создав интерфейс, который можно было бы имплементировать в модели каждого из компонентов.
interface IQuestion {
name: string;
description: string;
type: QuestionType;
/* Два этих поля здесь для все того же Entity-Attribute-Value.
Можно было бы обойтись и одним, но это создаст еще одну
точку принятия решения на бэкэнде */
answerStringValue: ?string;
answerNumberValue: ?number;
}
@Component({
selector: "questionary",
standalone: true,
imports: [NgIf],
template:`
<ul class="questonary">
<li *ngFor="let question of questions">
<question-type-text
ngIf="question.type=='text'"
[(question)]="question"
>
</question-type-text>
<question-type-select
ngIf="question.type=='select'"
[(question)]="question"
>
</question-type-select>
</li>
</ul>
`
})
export class QuestionaryComponent
{}
@Component({
selector: "question-type-text ",
standalone: true,
template: `
<textarea
[(ngModel)] = "question.answerStringValue"
(change)="onAnswerChange()"
>
</textarea>
`
})
export class QuestionTypeTextComponent
{
@Input() question: IQuestion = {
name: "";
description: "";
type: "text";
answerStringValue: "";
answerStringValue: null;
};
@Output() questionChange = new EventEmitter<IQuestion>();
constructor() {}
onAnswerChange(): void {
/*В более сложном случае здесь вызывался бы маппер, преобразующий
данные из инпутов в нужные значения
answerStringValue и answerNumberValue */
this.questionChange.emit(this.question);
}
}
Есть и еще более красивый, но менее производительный вариант. Вот тут [https://habr.com/ru/companies/skyeng/articles/652855/] описана технология, на которой можно было бы реализовать подобную фабрику.
Делаем для наших компонентиков базовый класс. В базовом классе достаточно реализовать метод, статически возвращающий тип компонента. Или нейминг-конвенцию. Или что-то еще такое. Главная идея — сделать список компонентов, которыми можно зарядить список; мэп или что угодно, из чего мы можем получить соответствующую связь типа вопроса и типа в смысле typescript того компонента, который мы будем отображать.
Вот набросок варианта на синглтоне, хранящем список доступных компонентов в виде мапа с «типа вопроса» на тип компонента в смысле typescript:
/* Базовый класс компонента */
class ComponentBase {
/* Метод, возвращающий тип. Чтобы был. */
static getComponentType(): string {
throw new Error("not implemented!");
}
}
/* Тип для типа компонента */
type ComponentType = typeof ComponentBase;
export {ComponentBase, ComponentType}
/* Синглтон для хранения всех возможных компонентов вопросов-ответов */
export class MapOfQuestionTypesSingleton {
private static instance: MapOfQuestionTypesSingleton;
private map: Map<string, ComponentType> = new Map<string, ComponentType>();
private constructor() {
}
static getInstance() {
if (!MapOfQuestionTypesSingleton.instance) {
MapOfQuestionTypesSingleton.instance
= new MapOfQuestionTypesSingleton();
}
return MapOfQuestionTypesSingleton.instance;
}
/*Методы для работы со скрытым внутри синглтона мапом */
public addMappedQuestion(key:string, value: ComponentType): void {
this.map.set(key, value);
}
public getMappedQuestion(key:string): ComponentType | undefined {
return this.map.get(key);
}
}
/* Компонент вопроса-ответа определенного типа */
@Component({ ...})
class ComponentText extends ComponentBase {
static getComponentType(): string {
return "text";
}
}
/* Вот этот код будет повторяться в файле каждого компонента с точностью
до имени класса
Этакая регистрация компонента для использования в динамической форме */
MapOfQuestionTypesSingleton.getInstance()
.addMappedQuestion(ComponentText .getComponentType(), ComponentText);
export ComponentText;
/* Компонент вопроса-ответа какого-нибудь другого типа */
@Component({ ...})
class ComponentNumber extends ComponentBase {
static getComponentType(): string {
return "number";
}
}
MapOfQuestionTypesSingleton.getInstance()
.addMappedQuestion(ComponentNumber.getComponentType(), ComponentNumber);
export ComponentNumber;
/* Компонент опросника. Заметим, что в цикле подключается один и тот же
компонент-обертка */
@Component({
selector: "questionary",
standalone: true,
template:`
<ul class="questonary">
<li *ngFor="let question of questions">
<question-dynamic [(question)]="question"></question-dynamic>
</li>
</ul>
`
})
export class QuestionaryComponent {}
import {MapOfQuestionTypesSingleton} from " . . . "
/* Компонент-обертка, динамически подгружающий нужный компонент вопроса-ответа */
@Component({
selector: "question-dynamic",
standalone: true,
template: `<ng-template #dynamic></ng-template>`
})
export class QuestionDynamicComponent {
@ViewChild('dynamic', { read: ViewContainerRef }
@Input() question: IQuestion;
prvate viewRef: ViewContainerRef;
private componentRef: ComponentRef<QuestionBase>;
ngOnInit() {
/*А вот тут мы достаем из мапа нужный компонент. */
this.componentRef =
this.viewRef.createComponent(
MapOfQuestionTypesSingleton
.getInstance()
.getMappedQuestion(question.type)
);
}
}
Конечно, нужно еще реализовать работу с данными подобно первому случаю. Детали я, пожалуй, оставлю на откуп опыту и воображению читателя. Однако таким образом, мы получим ситуацию, когда при добавлении нового типа вопроса достаточно лишь соблюсти концепцию и добавить соответствующий тип в словарь. Остальное фреймворк выполнит без нас.
Вместо ответа
Вообще этот текст вовсе не про то, как решать конкретные задачи. Он про соответствие между духом и буквой, между формальным следованием принципам проектирования и их реальным пониманием.
Мне осознать свою неправоту помог собственный травмирующий опыт, а приобрести идею того, как это можно было сделать лучше — работа в совершенно другой команде на совершенно другом стеке. Здесь присутствующим, надеюсь, помогут набитые мной шишки.
В сухом остатке — то, чему я научился в процессе работы над своей задачей, можно выразить так:
Если появляется более одной точки принятия решения, касающейся одной сущности (в моем случае — несколько блоков условных свич-кейсов, описывающих отдельно отображение вопросов, отдельно работу с данными), 100% при проектировании что-то пошло не так;
И еще — опыт программирования транслируется между стеками, если он достаточно отрефлексирован. А если не достаточно, то его всё равно что и нет.
Такая вот философская получилась статья. Всем добра.