Старый добрый серверный рендеринг

от автора

Пожалуй, большинству читателей будет не очень интересна предыстория того как я пришел к этому решению.

Скрытый текст

Было это уже больше 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *