Как я выстрелил себе в ногу, не соблюдая паттерны

от автора

ACHTUNG! Все примеры кода в данной статье набросаны на коленке и не пригодны для использования в том виде, в котором они приведены. Мы даже сборку не тестировали. Но статья и не про код!

Всем привет, меня зовут Андрей, я — php-разработчик в wpp.digital.

Сегодня я поделюсь с вами историей. Она о том, как поверхностное понимание (или непонимание) паттернов проектирования отстрелило мне ногу. А еще поделюсь примером реализации простой истины: знание чего-то не равно умению это применять. Кстати, главным героем поэмы являюсь (неожиданная информация) я.

Кому будет полезен данный текст? В первую очередь, мне для рефлексии. Во вторую — той редкой породе новичков, которая умеет учиться на чужих ошибках. Ну и в последнюю очередь — опытным коллегам, которые могут поностальгировать по временам джуновых задач и огромных перспектив. Последние еще могут разнести в комментариях всё, что я здесь написал.

Теперь к задаче.

Дано

  1. Небольшой IT-отдел в фирме-дистрибьюторе. Жесткие требования на использование только своего софта в целях безопасности конфиденциальных данных и не самые космические бюджеты на IT-специалистов прилагаются.

  2. Я, ваш покорный слуга, года 3 назад закончивший профильный вуз и вынужденный уже третий раз сменить стек — это и есть вся продуктовая команда на MVP нового продукта.

  3. Внутреннее веб-приложение (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)                         );     }   }

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

Вместо ответа

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

Мне осознать свою неправоту помог собственный травмирующий опыт, а приобрести идею того, как это можно было сделать лучше — работа в совершенно другой команде на совершенно другом стеке. Здесь присутствующим, надеюсь, помогут набитые мной шишки.

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

  1. Если появляется более одной точки принятия решения, касающейся одной сущности (в моем случае — несколько блоков условных свич-кейсов, описывающих отдельно отображение вопросов, отдельно работу с данными), 100% при проектировании что-то пошло не так;

  2. И еще — опыт программирования транслируется между стеками, если он достаточно отрефлексирован. А если не достаточно, то его всё равно что и нет.

Такая вот философская получилась статья. Всем добра.


ссылка на оригинал статьи https://habr.com/ru/articles/825322/