Пожалуй, большинству читателей будет не очень интересна предыстория того как я пришел к этому решению.
Скрытый текст
Было это уже больше 20 лет назад, тогда я был разработчиком сайта damochka.ru. Делали мы его на FreeBSD+Apache+PHP(3 а потом 4ой версии)+MySQL и у нас на тот момент была бешеная нагрузка — порой к нам на сайт заходило до 50 тысяч уников ежедневно. К сожалению, владельцу проект был интересен лишь как рекламная крутилка для его же интернет-магазинов и по этому ресурсов на разработку и инфраструктуру выделяли нам крайне мало. У нас было всего 4 сервера в стойке: 2 под mysql, 2 под apache+php, и конечно в часы пик наш сайт частенько лежал с двузначными load-avg серверов. Тогда мне пришла в голову мысль перенести часть рендеринга на сторону браузера — и я разработал html-шаблонизатор который компилировал шаблоны частично в PHP а частично в JS код (через document.write(“<html>”)) — и после частичного перевода сервиса на такой подход нам удалось выкроить немного серверных ресурсов и протянуть чуть дольше — но в итоге (имхо, из-за недальновидности владельцев) сервис всё равно пришел в упадок.
После Дамочки мой путь программиста перешел в мир Java и на одном из проектов я продолжил свои устремления в сторону client-side rendering и создал аналог Google Web Toolkit — компилятор из Java в JavaScript. Уж больно приятно было писать клиентский код в строго-типизированной среде. Именно на тех проектах я окончательно утвердился в том, что строгая типизация — это ключ к разработке долгоживущих проектов. Конечно, зачастую начать проект бывает гораздо быстрее на PHP или JavaScript, но чем больше у вас кода, тем больше дивидендов вы получается от строгих языков программирования.
Сейчас я работаю в небольшой американской компании, в которой самым ценным ресурсом в разработке является человеческое время. И если раньше мы оптимизировали работу серверов, то теперь у нас акцент на оптимизацию использования времени программистов. Наш backend начинался как Java+Spring+Postgrе, постепенно наполняясь вставками на Kotlin. В качестве шаблонизатора для приложения backoffice (его используют только сотрудники компании для управления клиентской базой) мы использовали Thymeleaf — довольно простой и удобный инструмент для серверного рендеринга. Но со временем приложения бэкофиса разрасталось, естественно то и дело возникала необходимость в рефакторинге, который часто ломал серверный рендеринг. Это стало настоящей головной болью, поскольку выделить full-time тестировщика на бэкофис у нас не было никакой возможности, а писать собственные UI авто тесты отнимало очень много времени. В ситуацию особенно добавляло драматизма то что вот рядом в соседних папках с html шаблонами лежал код на Kotlin — удобный, строго-типизированный, null-safe, устойчивый к рефакторингу, компилируемый — но ошибки всплывали внутри html шаблонов и мы ничего не могли с этим поделать.
И вот однажды ночью, после того как меня в очередной раз разбудили и попросили исправить критикал в html шаблоне бэкофиса — я решил что надо что-то делать. Мне пришла в голову простая мысль — шаблоны должен быть на Kotlin, и работать он должен с той же моделью данных, которой оперирует само приложение, и весь MVC должен быть единым монолитным конструктором. Сказано сделано и уже в шесть утра у меня были готовы первые страницы на том что я потом назвал GOSSR for Kotlin — Good Old Server-Side Renderer for Kotlin.
Сейчас наш проект бэкофиса содержит около сотни “страниц” — это большие разделы сайта с выделенным функционалом, многие из которых с дополнительными табами, около 50 отчётов, 70 модальных “всплывашек” и несколько десятков “виджетов” — переиспользуемых блоков интерфейса. Весь этот UI генерит html на сервере, оперирует теми же дата-классами что и контроллеры, абсолютно устойчив к рефакторингу и конечно как и любой другой Kotlin код умеет работать в дебаг-режиме и JVM-hot-reload.
Далее я приведу несколько простых примеров использования разработанной мною библиотеки для серверного рендеринга GOSSR for Kotlin и немного деталей её реализации.
Шаблонизатор состоит из двух модулей:
-
Собственно сам шаблонизатор, который умеет “рендерить” html теги и атрибуты, умеет форматировать дату/время и числа. Этот модуль не имеет зависимостей — только kotlin-stdlib & reflect что позволяет его очень легко прикрутить к любому фреймворку.
-
Обвязка для Spring — реализация
org.springframework.web.servlet.View
и поддержка строго-типизированных “маршрутов” (routes) для удобного составления href-ссылок и html-форм. Об этом чуть ниже.
Вот самый простой MVC HelloWorld:
@Controller class GossrExamplesController { @GetMapping("hello-world") fun helloWorldPage() = HelloWorldPage() } ... class HelloWorldPage : GossRenderer(), GossrSpringView { // точка входа отрисовки страницы override fun draw() { DIV("any-class") { +"Hello World" } } }
Данный пример отдаёт браузеру DIV тэг с текстом Hello World внутри. HelloWorldPage класс реализует (через GossrSpringView) спринговый интерфейс View и собственно через него происходит рендеринг. Вот чуть более сложный пример:
// контроллер @GetMapping("users") fun usersPage(): View { val usersList = getUsersFromDatabase() return UsersListPage(usersList) }
abstract class DemoGossrRenderer : GossRenderer(), GossrSpringView // View class UsersListPage( val users: List<UserInfo> ) : DemoGossrRenderer() { // точка входа отрисовки страницы override fun draw() { TABLE { classes("table-class") usersListTableHead() TBODY { users .sortedBy { it.birthDay } // почему бы не отсортировать на стороне View? .forEach { userRow(it) } } } } // метод отрисовки отдельной строки таблицы с информацией о пользователе private fun userRow(u: UserInfo) { TR { TD { +u.firstName } TD { +u.lastName } TD { +formatDate(u.birthDay) } TD { +u.email } } } // метод отрисовки заголовка таблицы private fun usersListTableHead() { THEAD { TR { TH { +"First Name" } TH { +"Last Name" } TH { +"Birth Date" } TH { +"Email" } } } } }
Думаю идея понятна:
-
Теги рисуем большими буквами
-
атрибуты — кэмл-кейс
-
вывод текста через оператор +
-
Модель/данные в параметрах View класса
Из неочевидных особенностей:
-
Большинство функций-тегов объявлены как inline, отчего во время рендеринга не создаются лишние экземпляры колбеков которые рисуют тело тега
-
открытие нового тега обвязано в try..catch что не позволяет одному виджету случайно сломать рендеринг всей страницы
-
поддержка разных вариантов форматирования даты и денег — так что добавить выбиралку формата для пользователя не составляет труда
Не хочу перегружать читателя примерами использования, но просто представьте всю силу Kotlin — работу со списками, null-safe операции, рефакторинг, быстрый поиск и прыжки по функциям и бессчётное количество других киллер-фич — и всё это доступно в html-шаблонизаторе.
Строгая типизация ендпоинтов
Другой, хотя и связанной с рендеригом головной болью любого full-stack разработчика является слежение за ссылками, параметрами в ендпоинтах и формами. К сожалению, эта часть кода во многих случаях продолжает работать на строках (название параметров, сами URI), что несомненно увеличивает время на поддержку, вероятность ошибок во время внесения обновлений и сильно осложняет навигацию и поиск of usages.
Для упрощения работы с html-ссылками и формами модуль GOSSR-Spring предлагает концепцию routes. Как это работает:
-
Любой endpoint — это класс, наследующий интерфейс Route
-
На данный момент есть два типа роутов — GetRoute, PostRoute и MultipartPostRoute
-
Параметры ендпоинта — это объявленные переменные данного класса
-
Учитывая тот факт, что Spring по-умолчанию поддерживает такие названия параметров как например
list[index].field
илиmap[key]
, а так же если один параметр передаётся несколько раз — Spring умеет составлять из начений List или массив — всё это позволяет создавать довольно сложные многоуровневые классы-роуты например для сложных форм
В качестве примера приведу работу с обычными ссылками:
// класс-route, определяющий GET-endpoint с одним параметром - ID пользователя data class UsersInfoRoute(val userId: Long) : GetRoute .... // код Контроллера: @RouteHandler // метод обработки запроса fun userInfoPage(route: UsersInfoRoute) = UserInfoPage( getUserById(route.userId) )
// тот же пример страницы со списком пользователей: TD { A { // URI будет автоматически составлен из названия класса // в данном случае будет что-то типа: // /users/info?userId=123 href(UsersInfoRoute(u.id)) +u.email } }
С таким подходом у вас больше не окажется подвисших ссылок, или неверных параметров, или отсутствие обязательных параметров. Вы всегда сможете найти из каких мест у вас есть переход по ссылке, всегда сможете легко что-то отрефакторить, добавить, поменять. Это невероятно удобно. Из коробки поддержка дат, enum, чисел, строк, bool, параметров являющихся часть uri-path и многое другое.
Пример формы:
// определяем endpoint для сохранения данных о пользователе data class UserSaveRoute( // параметры формы val userId: Long, val firstName: String, val lastName: String, val email: String, ): PostRoute // это будет Post запрос ... // контроллер: @RouteHandler fun saveUser(route: UserSaveRoute): String { // сам endpoint // все данные из формы доступны в переменной route saveUserToDatabase(route.userId, route.firstName, route.lastName, route.email) // используем другой route для редиректа на список пользователей return redirect(UsersListRoute()) }
// страница-форма с пользовательскими данными class UserInfoPage( // модель данных, параметр страницы val user: UserInfo, ) : DemoGossrRenderer() { override fun draw() { // создаём route с исходными данными // на основе его будет отрисованна html-форма, примерно такая: // <form method="POST" action="/user/save">... FORM(GossrExamplesController.UserSaveRoute( userId = user.id, firstName = user.firstName, lastName = user.lastName, email = user.email )) { route -> // скрытый параметр формы - ID пользователя // рендерер сам поймёт какое название и значение должно быть у параметра HIDDEN_LONG(route::userId) DIV { +"Имя:" INPUT { classes("form-control") nameValueString(route::firstName) } } DIV { +"Фамилия:" INPUT { classes("form-control") nameValueString(route::lastName) } } DIV { +"Email:" INPUT { classes("form-control") nameValueString(route::email) } } SUBMIT("btn btn-primary", "Сохранить") } } }
Как видите в данном примере мне не пришлось придумывать URI для ендпоинта или использовать строковые названия для параметров. Всё это шаблонизатор генерирует самостоятельно. Единственное о чём мне приходится заботится — о передаче всех параметров роута через форму. Как на этапе компиляции проследить за этой полнотой — я не придумал. Но в целом это значительно проще и удобнее чем ручные строки URI и названия параметров.
Если вдруг этот пост дойдёт до публикации, я бы хотел заранее избежать холивара в комментариях относительного того что лучше: server or client -side рендеринг. Для каждой задачи должны быть свои инструменты, наиболее оптимальным образом её решающие. Могу лишь сказать что подобный поход серверного рендеринга имеет ряд существенных плюсов, особенно в случае очень больших проектов и ограничения на ресурс разработчиков.
Желаю всем устойчивого кода и комфортной работы.
ссылка на оригинал статьи https://habr.com/ru/articles/869844/
Добавить комментарий