ACHTUNG! Все примеры кода в данной статье набросаны на коленке и не пригодны для использования в том виде, в котором они приведены. Мы даже сборку не тестировали. Но статья и не про код!
![](https://habrastorage.org/getpro/habr/upload_files/730/547/e00/730547e00bee28f39af35131ee11076f.png)
Всем привет, меня зовут Андрей, я — 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% при проектировании что-то пошло не так;
-
И еще — опыт программирования транслируется между стеками, если он достаточно отрефлексирован. А если не достаточно, то его всё равно что и нет.
Такая вот философская получилась статья. Всем добра.
ссылка на оригинал статьи https://habr.com/ru/articles/825322/
Добавить комментарий