Пожалуй, большинству читателей будет не очень интересна предыстория того как я пришел к этому решению.
Скрытый текст
Было это уже больше 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 рендеринг. Для каждой задачи должны быть свои инструменты, наиболее оптимальным образом её решающие. Могу лишь сказать что подобный поход серверного рендеринга имеет ряд существенных плюсов, особенно в случае очень больших проектов и ограничения на ресурс разработчиков.
Желаю всем устойчивого кода и комфортной работы.
Автор: Hoota