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

46c85bacb8bb563612990264174b87d4

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

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

Было это уже больше 20 лет назад, тогда я был разработчиком сайта damochka.ru. Делали мы его на FreeBSD+Apache+PHP (3, а потом 4ой версии)+MySQL и у нас на тот момент была бешеная нагрузка — порой к нам на сайт заходило до 50 тысяч уников ежедневно. К сожалению, владельцу проект был интересен лишь как рекламная крутилка для его же интернет-магазинов и по этому ресурсов на разработку и инфраструктуру выделяли нам крайне мало. У нас было всего 4 сервера в стойке: 2 под mysql, 2 под apache+php, и конечно в часы пик наш сайт частенько лежал с двузначными load-avg серверов. Тогда мне пришла в голову мысль перенести часть рендеринга на сторону браузера — и я разработал html-шаблонизатор который компилировал шаблоны частично в PHP, а частично в JS код (через document.write (»»)) — и после частичного перевода сервиса на такой подход нам удалось выкроить немного серверных ресурсов и протянуть чуть дольше -, но в итоге (имхо, из-за недальновидности владельцев) сервис всё равно пришел в упадок.

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

Желаю всем устойчивого кода и комфортной работы.

© Habrahabr.ru