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

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

730547e00bee28f39af35131ee11076f.png

Всем привет, меня зовут Андрей, я — 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: `
	
` }) 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:`
    	
` }) export class QuestionaryComponent {} @Component({ selector: "question-type-text ", standalone: true, template: ` ` }) export class QuestionTypeTextComponent { @Input() question: IQuestion = { name: ""; description: ""; type: "text"; answerStringValue: ""; answerStringValue: null; }; @Output() questionChange = new EventEmitter(); 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 = new Map();
 
    	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:`
    	
` }) export class QuestionaryComponent {} import {MapOfQuestionTypesSingleton} from " . . . " /* Компонент-обертка, динамически подгружающий нужный компонент вопроса-ответа */ @Component({ selector: "question-dynamic", standalone: true, template: `` }) export class QuestionDynamicComponent { @ViewChild('dynamic', { read: ViewContainerRef } @Input() question: IQuestion; prvate viewRef: ViewContainerRef; private componentRef: ComponentRef; ngOnInit() { /*А вот тут мы достаем из мапа нужный компонент. */ this.componentRef = this.viewRef.createComponent( MapOfQuestionTypesSingleton .getInstance() .getMappedQuestion(question.type) ); } }

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

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

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

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

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

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

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

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

© Habrahabr.ru