Как стать автором
Обновить
100.87

Server side Form. Управление веб-формами на стороне сервера

Время на прочтение14 мин
Количество просмотров4.2K

Хабр, привет!

Как человек, побывавший по ту (фронт) и по эту (бэк) стороны разработки, я хочу рассказать о Server Side Form - «Управлении веб-формой на стороне сервера».

Что это такое и зачем это нужно

Наш минимальный стек — это java (+Spring) как бэк и Angular (+NgRx) как фронт. Но это не единственный вариант - ничего не мешает вам реализовать предложенную здесь концепцию на иных технологиях.

Что такое эти ваши формы?

Здесь и далее, под формой понимается веб-форма реализованная на механизме «реактивных форм».

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

Уверен, что большинство на собственном опыте прекрасно знают, как происходит стандартный процесс разработки новой формы. У вас уже есть работающее приложение, разделённое на бэк и фронт-части, а бизнес отправляет в команду разработки требование на создание новой формы. И дальше начинается «магия».

С чего всё начиналось или какую проблему мы решали

Немного конкретики и немного истории. Мы имели стандартное приложение трёхзвенку, то есть фронт(веб приложение), бэк и реляционную базу данных для хранения самих данных.

Современные практики разработки требуют, чтобы в БД хранились только чистые данные (ну, может быть, ещё объектная модель) и никакой логики. Никаких триггеров, хранимых процедур и всего подобного. Это разумно, ведь однажды вы можете захотеть переехать с одного движка БД на другой и чем меньше специфических функций данного конкретного движка вы используете, тем проще вам будет это сделать.

Однако, при этом, довольно частая ситуация, когда часть логики оказывается зашита в веб-приложении, например предварительные проверки вводимых пользователем данных или даже значительно более сложная логика. И ситуация, когда часть логики размазана по фронт-части ничуть не лучше, чем если бы она была бы вынесена в базу данных. Абсолютно те же самые проблемы: разработчикам сложнее вникать в работу приложения с логикой, размазанной по его различным частям, такое приложение сложнее поддерживать и так далее. А самое главное - ведь вы однажды можете захотеть сменить один фронт на другой и если в нём зашита часть логики работы вашего приложения, то у вас будут с этим большие проблемы.

Конкретно в нашем случае, в дополнение к веб-приложению, бэку и БД появился ещё один компонент приложения - его мобильная версия. Тоже вполне себе распространённый случай.

И тут у нас возникла сложность.

Так как часть логики находилась в веб-приложении, то в мобильном приложении эту логику следовало повторить и потом ещё поддерживать две независимые ветки: веб и мобильное приложение. Получается какая-то двойная работа, даже хуже, потому, что в реальности ещё придётся следить чтобы эти две ветки развивались синхронно друг с другом.

Ужасная картина, правда?

Вот и мы подумали абсолютно также и вместо дублирования логики фронта на мобильном приложении пошли по иному пути. Думаю, вы уже догадались по какому.

Нашим лозунгом стало: вся логика должна храниться только и исключительно на бэк-е (точнее даже в определённой части бэк-приложения, но это не существенно). Веб и мобильное приложение не должны содержать никакой логики. Всё, что они показывают пользователю, весь процесс их взаимодействия с пользователем генерируется и управляется бэк-ом и только им одним.

Вот так родилась и начала воплощаться в жизнь идея форм, генерируемых на стороне сервера.

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

Итак, поехали!

Даже если вы не собираетесь создавать копию своего фронта для мобильных устройств (а может быть ваш фронт ещё полноценно поддерживает адаптивную вёрстку?), то формы на стороне сервера всё равно вам пригодятся.

Представьте, у вас есть работающее приложение, разделённое на бэк и фронт-части, и бизнес отправляет в команду разработки требование на создание новой формы.

Дальше начинается работа:

  1. Дизайнеры рисуют макеты;

  2. Бэк-разработчики создают API (хорошо, если они делают самодокументируемое API в виде Swagger-a и плохо, если нет);

  3. Веб-разработчики рисуют веб-формы (хорошо, если они собирают формы из набора заранее созданных и переиспользуемых во всём проекте простых веб-компонент типа «список», «окно ввода» и др. Плохо, если под каждую новую форму приходится создавать новые компоненты или допиливать ранее созданные, рискуя поломать их уже используемую функциональность);

  4. Мобильные разработчики также рисуют формы, но уже для мобильных устройств.

В процессе все постоянно встречаются на ежедневных митингах. Бэк-разработчики ругаются с веб- и мобильными разработчиками, что те шлют им «сырые» данные: null-ы вместо пустых строк, пустые строки вместо null-ов, дату не в том формате и, вишенка на торте, технически верные данные, невалидные с точки зрения бизнес-логики (например, клиента возрастом в 0 лет, взявшего в кредит отрицательное количество денег).

В свою очередь, веб- и мобильные разработчики ругаются с бэк-разработчиками, что они сами должны проверять сырые данные на валидность, чтобы возвращалось развёрнутое описание ошибок. А еще убедительно просят, чтобы бэкеры быстрее закончили свою разработку и не перегружали бы постоянно сервер, потому что так работать абсолютно невозможно.

Другими словами - обычный рабочий процесс.

Всё хорошо.

Вся команда при деле.

Люди трудятся в поте лица и где-то через недельку, а может быть и через две, выдадут бизнесу новую форму в приложении, которую можно начинать тестировать.

Кстати, все события и персонажи вымышлены, любые совпадения случайны)

Возникает логичный вопрос: неужели не видно, что процесс может быть оптимизирован? А если может быть оптимизирован – значит, должен быть оптимизирован!

Уменьшение трудозатрат вкупе с увеличением качества создаваемого кода — вот наш выбор, как настоящих инженеров.

Скажете, что это голословный популизм?

Те, кто перетаскивал камни для пирамид на своей спине также говорили тем, кто предлагал перевозить камни на телегах. И кто в итоге оказался прав?

Конечно же те, кто проложил железную дорогу и пустил по ней поезда!

Но меньше лирики, больше смысла.

Скорее всего, вы знаете и постоянно используете «валидацию на стороне сервера» (server side validation) в дополнение к первичной проверке вводимых пользователем данных на веб-формах. Предлагаю пойти дальше, сделать следующий шаг и перейти на Server side form, то есть создание и управление веб-формами на стороне сервера.

Server side Form это вам не Thymeleaf и HtmlFlow!

Если вы знакомы с java-библиотеками Thymeleaf или HtmlFlow, предназначенными для генерирования html-кода и работы с ними из java, то вы можете заметить определённое сходство с предлагаемой ниже концепцией. Однако это сходство из разряда тех, в которых различий больше, чем общих черт. Thymeleaf и HtmlFlow нацелены на создание и работу с html-страницей в целом. Server side form нацелен на создание и работу с веб-формой. Это несколько разные вещи.

Так что же такое эти ваши Server side form?

На самом деле идея серверных форм довольна проста. И заключается она вот в чём:

Давайте уволим всех веб-разработчиков и, заодно, дизайнеров

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

Преимущества

Один раз вложившись в разработку механизма, вы получите:

  1. Ускорение разработки новых и доработки старых веб-форм. Фактически, чтобы создать новую форму, вам надо будет всего лишь заполнить её описание.

  2. Теперь в вашем приложении все формы гарантированно будут в одном стиле. Плюс, вы можете разом поменять дизайн для всех ваших форм, просто изменив веб-компоненты, на основе которых генерируется пользовательская форма.

  3. Потребность в веб- и мобильных разработчиках снижается. При этом, что важно, потребность в новых бэк-разработчиках не вырастает.

  4. Один раз согласовав использование server side form, вы всегда можете отвечать на предложения бизнеса по точечному изменению дизайна (предложения вида «а давайте сделаем чтобы вот это поле крутилось бы вокруг своей оси при наведении на него мышкой и издавало звуки утиного кряканья при потере фокуса») тем, что это невозможно реализовать в рамках принятой концепции. Ну… или попробовать так ответить во всяком случае.

Недостатки

Как это часто бывает, минусы - суть обратная сторона плюсов.

  1. Если бизнес захочет создать нестандартную форму (или форму нестандартной структуры, что, в данном случае, тоже самое), то… вам придётся разрабатывать нестандартную форму обычным образом как будто вы не читали данной статьи и не использовали предлагаемый в ней подход. А между тем количество специалистов в команде разработки вы уже уменьшили и теперь закономерно горите по срокам.

Что нужно сделать чтобы не попасть в описанную ситуацию? Всё очень просто. Если хотите использовать нестандартные формы - учтите этот момент заранее, при разработке «серверных форм». Ну или не спешите увольнять «лишних» дизайнеров и фронт-разработчиков! Помните, что уволить легко, а найти хорошего человека трудно )

Больше технических подробностей

Мы начали с создания avro-схемы. Если коротко, то это возможность описать формат json-файла и провалидировать его на соответствие формату. Моё любимое определение: avro-схема для json-файла - это ровно то же самое, что xsd-схема для xml-файла.

Наша avro-схема полностью описывает процесс взаимодействие межу бэк-ом и фронтом. То есть любые данные, передаваемые в post-запросе или ответе на него, должны удовлетворять этой схеме. По этой схеме, на бэке, будут автоматически сгенерированы ДТО (java-объекты для создания/парсинга json-а, передаваемого/получаемого на фронт. А на фронте мы напишем интерфейсы согласно нашей avro-схеме, опять же, для создания/парсинга json-а, получаемого с бэка или отправляемого на бэк в теле post-запроса).

Хорошо, начинаем мы с создания avro-схемы. Но с чего начать создание самой схемы?

Первый шаг - вы должны определиться с ограничениями на ваш механизм server side form. Понятно, что чем более сложные и разнообразные формы вы хотите генерировать, тем сложнее будет сам механизм генерации. Поэтому список разумных ограничений на вид и структуру формы просто необходим.

Как показывает опыт, для реализации довольно сложной логики вполне достаточно зависимостей вида «Если поле А пусто/не пусто, то установить видимость текущего поля» или «Если поле А имеет значение, равное константе, то установить видимость текущего поля». Помимо всего прочего, необходимо иметь возможность объединять эти условия через «и» или же через «или», или отрицать через "не".

Звучит сложно?

Альтернативой может быть парсинг формулы, определяющей видимость данного поля в зависимости от значений других полей секции, а вы сами понимаете, насколько трудозатратнее будет подобный алгоритм. Тем более, что выполняется он на стороне фронта: в бразуере или мобильном приложении.

Если бэк у нас сценарист, то фронт — это актёрская группа. Но кто режиссёр в этой аналогии? А режиссёром, то есть тем, чья работа заключается в том, чтобы актёры (фронт) играли так, как написано в сценарии (набор инструкций, получаемых с бэка), как раз и будет наш модуль.

Ещё раз напомню, что один раз вложившись в создание, вам не придётся в дальнейшем как-либо его изменять или дорабатывать, если вы захотите создать новую форму или модифицировать существующую. Аналогично и для мобильной разработки.

При условии использования Angular-а на фронте, для динамического создания компонентов, динамического уничтожения и динамического формирования подписки на события (сплошная динамика, да) достаточно использовать следующую функциональность

  1. ComponentFactoryResolver - как фабрику компонентов

  2. Renderer2 - как способ включения созданных компонентов в DOM, для установки им требуемых свойств и подписки на внутренние события динамически созданных компонент

  3. ChangeDetectorRef - как возможность вручную пересчитать события в нужно время и нужное место, тем самым избавляясь от знакомой всем фронтёрам, работающим с ангуляром ошибки ExpressonChangedAfterItHasBeenCheckedException

Совсем немного философии

Относительно server side form мне нравится аналогия с театром. Где бэк выступает в роли сценариста, фронт — это актёрская группа, а режиссёр - модуль на фронте, реализующий всю логику создания динамических компонент формы и обработку событий.

Но имеет право на жизнь и другая аналогия, где передаваемые с бэк-а инструкции можно представить как программу, а модуль на фронте как компилятор. Фактически, у нас получилось что-то очень похожее на скриптовый язык управления веб-формами.

Разумеется, это сугубо моё мнение, но оно звучит так: ваше фронт/бэк приложение не может считаться серьёзным, если вы не используете «построитель форм» (FormBuilder) в том или ином виде. Это может быть предложенная выше реализация server-side-form или чисто фронтовая разработка, позволяющая размещать компоненты по секциям, управлять их видимостью и автоматически обеспечивать унификацию веб-форм. На худой конец, у вас просто может быть набор готовых компонент, из которых вы вручную собираете новую форму и для которых каждый раз вручную пишите соответствующие обработчики.

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

Код вместо тысячи слов
схема
мапа (map) - полей
{
	"name": "fields",
	"doc":"Мапа полей. Ключ - уид поля, значение - само поле",
	"namespace": "ru.company.wizard.field",
	"default": null,
	"type": ["null",{
		"type":"map",
		"name":"item",
		"values":{
			"type":"record",
			"name":"Field",
			"doc" : "Поле",
			"namespace": "ru.company.wizard.field",
			"fields": [
				{
					"name": "id",
					"type": "string",
					"doc" : "УИД"
				},									
				{
					"name": "options",
					"namespace": "ru.company.wizard.map",
					"type": ["null",{
							"type":"map",
							"name":"item",
							"values":"string"
					}],
					"doc":"Мапа опций для поля. Ключ - название опции. Ключ - значение опции",
					"default": null
				},
				{
					"name": "component",
					"doc": "компонент на котором основана работа поля",
					"type": 
						{									
							"type": "enum",
							"name":"ComponentEnum",
							"namespace": "ru.company.wizard.enum",
							"doc": "компонент",
							"symbols":["INPUT","SELECT","CHECKBOX","LABEL", "DATA", "INPUT_BYTE", "INPUT_AREA"]
						}
				},										
				{
					"name": "value",
					"doc": "заранее заданное значение поля",
					"type": ["null","string"],
					"default": null
				},										
				{
					"name": "visible",
					"doc": "видимо ли поле по умолчанию",
					"type": "boolean",
					"default":false
				},
				{
					"name": "required",
					"doc": "обязательно ли для заполнения поле по умолчанию",
					"type": "boolean",
					"default":false
				},										
				{
					"name": "dependence",
					"doc": "зависимости данного поля",
					"namespace": "ru.company.wizard.dependence",
					...
				}
			],
			"default": null
		}
	}]
}		
Описание зависимостей
{
	"name": "dependence",
	"doc": "зависимости данного поля",
	"namespace": "ru.company.wizard.dependence",
	"type": ["null",
		{
			"name": "DependenceType",
			"namespace": "ru.company.wizard.dependence",
			"type": "record",
			"fields": [
				{
					"name": "andRequired",
					"doc": "и сделать поле обязательным по умолчанию",
					"type": "boolean",
					"default":false
				},												
				{
					"name": "type",
					"doc": "тип сопоставления условий",
					"type": 
						{									
							"type": "enum",
							"name":"TypeDependenceEnum",
							"namespace": "ru.company.wizard.enum",
							"doc": "компонент",
							"symbols":["AND","OR"]
						},
					"default": "AND"
				},												
				{
					"name": "dependences",
					"doc": "При выборе значений в каких полях (по уид-ам) нужно делать данное поле видимым. Элемент становится видимым при выполнении всех условий и невидимым при выполнении хотя бы одного. Примеры одиночного условия: УИД, УИД=КОНСТАНТА, NOT:УИД, NOT:УИД=КОНСТАНТА",
					"namespace": "ru.company.wizard.dependence",
					"type": [
								"null",
								{
									"type": "array",
									"items": {
										"name": "item",
										"type": "string"
									}
								}
					],
					"default": null
				}
			]
	}],
	"default": null
}

бэк
createField("COMPLETED_PROJECT_NUMBER", ComponentEnum.SELECT, createOptions(
                        createOption(OptionEnum.LABEL, messageHelper.message("ru.company.cit.wizard.project.replace.sections.MAIN.fields.COMPLETED_PROJECT_NUMBER")),
                        createOption(OptionEnum.SERVICE_URL, "/v1/tool/projects/additional/certification/company/{REPLACE1}"),
                        createOption(OptionEnum.SERVICE_URL_REPLACE1, "COMPANY"),
                        createOption(OptionEnum.SERVICE_METHOD, "POST")
                ), null, false, false, createDependence(true, TypeDependenceEnum.AND,
                                "TYPE_PROJECT=REPLACE_ADDITION_PARAMS","COMPANY"))

Данное поле будет видимо, только если поле с УИД-ом TYPE_PROJECT (это список) имеет значение REPLACE_ADDITION_PARAMS и если поле с УИД-ом COMPANY имеет любое значение.
Как видите, всё достаточно удобно, человекочитаемо и, следовательно, просто.

/**
     * Создать список полей для секции ADDITIONAL
     *
     * @return - список полей для секции
     */
    @Override
    protected Map<String, Field> createFieldForSectionADDITIONAL(){
        return createFieldsMap(
                createField("ACQUIRING_INSTITUTION_IDENTIFICATION_CODE", ComponentEnum.INPUT, createOptions(
                        createOption(OptionEnum.LABEL, messageHelper.message("ru.company.cit.wizard.project.contact.sections.ADDITIONAL.fields.ACQUIRING_INSTITUTION_IDENTIFICATION_CODE")),
                        createOption(OptionEnum.TEXT_NUMBER_MIN, "0"),
                        createOption(OptionEnum.TEXT_LENGTH_MAX, "11"),
                        createOption(OptionEnum.TEXT_PATTERN, "[0-9]{0,11}")
                ), null, true, false, null),
                createField("CURRENCY", ComponentEnum.SELECT, createOptions(
                        createOption(OptionEnum.LABEL, messageHelper.message("ru.company.cit.wizard.project.contact.sections.ADDITIONAL.fields.CURRENCY")),
                        createOption(OptionEnum.SERVICE_URL, "/v1/references/currency/items"),
                        createOption(OptionEnum.SERVICE_METHOD, "GET")
                ), null, true, true, null),
                createField("MAIN_PROJECT_NUMBER", ComponentEnum.SELECT, createOptions(
                        createOption(OptionEnum.LABEL, messageHelper.message("ru.company.cit.wizard.project.contact.sections.ADDITIONAL.fields.MAIN_PROJECT_NUMBER")),
                        createOption(OptionEnum.SERVICE_URL, "/v1/tool/projects/completed/{REPLACE1}"),
                        createOption(OptionEnum.SERVICE_URL_REPLACE1, "FIIC_PL033"),
                        createOption(OptionEnum.SERVICE_METHOD, "GET")
                ), null, true, false, null),
                createField("FINISHED", ComponentEnum.CHECKBOX, createOptions(
                        createOption(OptionEnum.LABEL, messageHelper.message("ru.company.cit.wizard.project.contact.sections.ADDITIONAL.fields.FINISHED")),
                        createOption(OptionEnum.STYLE_PLACEHOLDER, "attention"),
                        createOption(OptionEnum.PLACEHOLDER, messageHelper.message("ru.company.cit.wizard.project.contact.sections.ADDITIONAL.fields.FINISHED.PLACEHOLDER"))
                ), null, true, false, null)        );
    }

фронт
@ViewChild("conteinerSection", { read: ViewContainerRef }) conteinerSection;
@ViewChild("conteinerField", { read: ViewContainerRef }) conteinerField;
public formGroup: FormGroup = new FormGroup({}); 

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

constructor(
…
        	private componentFactoryResolver: ComponentFactoryResolver,
private renderer: Renderer2,
        	private cdRef: ChangeDetectorRef) {
}
//cоздание полей
const componentClass: any = (() => {
	switch (field.component) {
		case ComponentTypeEnum.SELECT: { return WizardFieldSelectComponent; }
		case ComponentTypeEnum.CHECKBOX: { return WizardFieldCheckBoxComponent; }
		case ComponentTypeEnum.INPUT: { return WizardFieldInputComponent; }
		case ComponentTypeEnum.INPUT_BYTE: { return WizardFieldInputByteComponent; }
		case ComponentTypeEnum.DATA: { return WizardFieldDataComponent; }
		case ComponentTypeEnum.LABEL: { return WizardFieldLabelComponent; }
		case ComponentTypeEnum.INPUT_AREA: { return WizardFieldInputAreaComponent; }
		default: {
			throw "неизвестный тип компонента: " + field.component;
		}
	}
})();
const factory = this.componentFactoryResolver.resolveComponentFactory(componentClass);
let compFieldRef: ComponentRef<WizardFieldCommonComponent> = this.conteinerField.createComponent(factory);
compFieldRef.instance.id = field.id;
compFieldRef.instance.options = field.options;
compFieldRef.instance.formGroup = this.formGroup;

compFieldRef.instance.value = field.value;
compFieldRef.instance.visible = field.visible;
compFieldRef.instance.required = field.required;

compFieldRef.instance.fields = this.fields;
this.renderer.setStyle(compFieldRef.location.nativeElement, 'display', 'none'); //с самого начала делаем все компоненты невидимыми
field.ref = compFieldRef;

//После создания всех полей, имеет смысл вручную запустить цикл обнаружения изменений
this.cdRef.detectChanges();
//создадим подписку для обработки клика на секцию
this.arraySubscriptsOnEvent.push(this.renderer.listen(compSectionRef.location.nativeElement, 'click', (event) => {
	this.onSectionClick(compSectionRef.instance.index);
}));            
section.ref = compSectionRef;

...
//удаление секций и подписок на их события
this.conteinerSection.clear(); //чистим контейнер
for (const fun of this.arraySubscriptsOnEvent) fun(); //так удаляем подписки на события. Неочевидный момент
for (let s of this.sections) {  //удаляем сами компоненты секций
   (s as ISection).ref.destroy();
}
//обнаружение изменений
this.lastFormValue = this.formGroup.value; //предыдущее состояние формы

this.subscribeOnFormValuesChanges = this.formGroup.valueChanges.subscribe((v) => {
	let changedFieldName: string=null;
	for (const name of Object.keys(v))
		if (v[name] != this.lastFormValue[name]) {
			loging('Изменилось значение поля ' + name + ' с ' + this.lastFormValue[name] + ' на ' + v[name]);
			changedFieldName = name;
			break;
		}
	this.lastFormValue = v;
	if (!changedFieldName) return; //изменений не было, выходим
//не забывайте в дестроере (или при пересоздании формы) отписаться от созданных подписок через метод
.unsubscribe();

//или же использовать механизм автоматической отписки при уничтожении компонента
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
…
@UntilDestroy()
@Component({
…
this.getFormControl.valueChanges.pipe(untilDestroyed(this)).subscribe((v) => {}

Теги:
Хабы:
+14
Комментарии12

Публикации

Информация

Сайт
mir-platform.ru
Дата регистрации
Дата основания
Численность
501–1 000 человек
Местоположение
Россия
Представитель
nspk