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. И еще — опыт программирования транслируется между стеками, если он достаточно отрефлексирован. А если не достаточно, то его всё равно что и нет.

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

Комментарии (3)


  1. Vasjen
    28.06.2024 10:40

    Не отношу себя к "опытным коллегам", но по ощущениям, вам не хватило кого-то уровнем мидла и выше, который сначала бы спроектировал API, а потом уже их реализовывали. Просто если внешний вид страницы и ее элементов зависит от возвращаемого типа из базы, то это выглядит не просто как нарушение принципов разработки, а как дичайшее надругательство. Отсюда и идут дальше все проблемы, но они уже следствие основной.


    1. webppdigital Автор
      28.06.2024 10:40
      +4

      Возможно, мне не удалось донести что именно мы делали. Дело не во внешнем виде, а в динамическом содержимом. Нам нужно было сделать свой упрощённый аналог Google Forms, то есть динамическую форму, состав полей которой полностью контролируется через админ-панель. И, соответственно, данные, полученные из админки, мы хранили в БД.


  1. breninsul
    28.06.2024 10:40
    +1

    EAV не антипаттерн, вполне себе нормальный паттерн, просто нужно понимать возможные проблемы, в первую очередь с производительностью.

    В принципе во вью или запросе eav и в json/b можно собрать, относительно легко, если есть желание.