С одной стороны, предмет действительно был квадратным. C другой стороны он был круглым. Но с третьей стороны, с которой должен быть треугольник, предмет вышел кривой и косой.


— Алешенька идет на совещанку? — в дверь просунулась Леночкина заинтересованная физиономия.
— Алешенька на совещанку не идет. Алешенька пишет статью.
— О кубиках?
— Каких еще кубиках? — я опустил глаза, в руках и правда был злосчастный кубик. То есть шарик. То есть ромбик.
— Не о кубиках! И не о шариках. О шаблонах.
— Я им так и скажу! Шаблон, ах. — Леночка уже бежала дальше по коридору.


"О шаблонах. Даже о трех разных шаблонах". Точнее, о трех причинах использовать шаблоны в серверном коде. И ни одна из этих причин не будет про HTML.


В примерах я использовал синтаксис Mustache, в силу лаконичного синтаксиса и наличия реализаций для всего, что движется. Mustache практически не позволяет себе вольностей в отличии от, например .Net Razor, который позволяет кодировать внутри шаблона, подавая тем самым плохой пример некрепким духом разработчикам.


Примеры кода будут на python. Реализация Mustache под пайтон называется pystache.


Итак, три причины впустить шаблоны в свою жизнь свой код.


Текстовые артефакты


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


Артефактом может быть, например, JSON или plain text файл, вложение, HTTP-респонс. Главное — артефакт по сути своей результат применения функции от некоторой относительно компактной части данных вашей системы. И артефакт имеет свой собственный синтаксис.


Артефактом может быть выписка из банковского счета в текстовом формате для загрузки в legacy систему. Артефактом может быть выгрузка электронного чека в виде .json файла, который будет отправлен как вложение клиенту по почте.


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


Что такое шаблонизатор? Это библиотека, которая примет объектную модель (контекст), возьмет шаблон, применит одно к другому — и выдаст результат. Объектную модель и шаблон готовит программист. Итоговый результат готовит шаблонизатор.


Пример: попробуем создать текстовое сообщение о заказе.


Подготовим сначала объектную модель:


def add_comma(list):
    for a in list[:-1]:
        a["comma"] = True

def get_model():
    res = {
        "documentId": 3323223,
        "checkDate": "01.02.2019 22:20",
        "posId": 34399,
        "posAddr": "Urupinsk, 1 Maya 1",
        "lines": [
            {
                "no": 1,
                "itemtext": "Hello Kitty",
                "amount": 3,
                "sumRub": "55.20"
            },
            {
                "no": 2,
                "itemtext": "Paper pokemons",
                "amount": 1,
                "sumRub": "1230.00"
            },
            {
                "no": 2,
                "itemtext": "Book of Mustache",
                "amount": 1,
                "sumRub": "1000.00"
            }
        ],
        "total": {
            "amount": "3285.20"
        }
    }
    add_comma(res["lines"])
    res["posInUrupinsk"] = res["posId"] > 34000
    return res

Код сугубо заглушечный. В реальном коде может быть запрос базы данных, какая-то логика расчета значений (например, значение total.amount вычисляем на основании позиций заказа).


Обратите внимание на несколько вещей:


  • Это НЕ объектная модель заказа, это нечто более простое, подготовленное для применения в шаблоне. Значения "sumRub" и "total.amount" в реальной бизнес-модели не должны быть текстовыми, значению "comma" у массива lines в объектной модели не место, оно нужно только для упрощения рендеринга (pystache не может сам понять, что элемент списка последний и после него не надо ставить запятую.
  • Тип поля "amount" — текст и этот текст отформатирован под вывод в шаблоне. Если ваш шаблонный движок поддерживает форматтеры ( что-то типа "… {someValue|asMoney}" ) то форматировать прямо в модели не надо.
  • Текст в шаблоне у нас должен выглядеть несколько по другому для клиентов из Урюпинска (менеджер в последний момент прибежал и попросил добавить — бизнес очень просил, у них нежданно-негаданно стартанула маркетинговая акция для города). Поэтому мы добавили в модель булевское значение "posInUrupinsk" и использовали его в шаблоне.
  • Повторно модель от шаблона лучше не использовать, разве что для рендеринга других шаблонов

Текст шаблона mustache выглядит так:


{{#posInUrupinsk}}
ДОРОГОЙ КЛИЕНТ ИЗ УРЮПИНСКА! ОТПРАВЬТЕ НОМЕР СВОЕГО ЧЕКА НА
КОРОТКИЙ НОМЕР 100 И ПОЛУЧИТЕ КОФЕВАРКУ.
{{/posInUrupinsk}}
{{^posInUrupinsk}}
ИНФОРМАЦИЯ О ВАШИХ ПОКУПКАХ:
{{/posInUrupinsk}}

{{#lines}}
#{{no}} ... {{itemtext}}: {{sumRub}} руб{{#comma}};{{/comma}}{{^comma}}.{{/comma}}
{{/lines}}

ИТОГО: {{total.amount}}
---------------------------
N документа: {{documentId}} от {{checkDate}}

Мы видим в шаблоне, что шапка документа для заказов в Урюпинске отличается от других городов. Мы также видим, что в конце последней строки с товарной позицией стоит точка, а во всех ранних позициях — точка с запятой. За это отвечает атрибут "comma" и метод "add_comma" в генераторе моделей.


Код, который применяет контекст к шаблону, тривиален:


model = get_model()

with open(os.path.join("templates", "check.txt.mustache"), 'r') as f:
    template = f.read()

check_text = pystache.render(template, model)
print(check_text)

Результат:


ДОРОГОЙ КЛИЕНТ ИЗ УРЮПИНСКА! ОТПРАВЬТЕ НОМЕР СВОЕГО ЧЕКА НА
КОРОТКИЙ НОМЕР 100 И ПОЛУЧИТЕ КОФЕВАРКУ.

#1 ... Hello Kitty: 55.20 руб;
#2 ... Paper pokemons: 1230.00 руб;
#2 ... Book of Mustache: 1000.00 руб.

ИТОГО: 3285.20
---------------------------
N документа: 3323223 от 01.02.2019 22:20

Еще совет: если задача позволяет, сохраняйте вместе с зарендеренным шаблоном саму модель (например, в JSON формата). Это поможет при отладке и траблшутинге.




Принтер трижды пискнул, выдавая новую модельку. Треугольная сторона была теперь идеальным треугольником. Две остальные стороны были квадратными. Нейронная сеть жила своей жизнью и отказывалась выдавать примитивную по всем понятиям 3д модельку.


"Подарю Леночке кубик." — подумал я. Пусть радуется.


Кодогенерация


Вам может потребоваться создавать JavaScript в рантайме изнутри backend-а. Зачем? Для создания отчетов на стороне браузера, например. Или получить скрипт на F# изнутри программы на Go. Или Kotlin-овский код изнутри ReactJS (не могу представить, зачем это может понадобится, но вдруг у вас такие специфические наклонности).


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


var report = CreateChart({ title: "За второе полугодие - сводный" }, type: "lineChart", sourceUrl: "/reports/data/0" );
report.addLine({ dataIndex:0, title: "Доходы", color: "green" });
report.addLine({ dataIndex:1, title: "Убытки", color: "red" });

report.render($("#reportPlaceholder1"));

var report = CreateChart({ title: "За второе полугодие - по продуктам" }, type: "lineChart", sourceUrl: "/reports/data/1");
report.addLine({ dataIndex:0, title: "Hello Kitty", color: "#000" });
report.addLine({ dataIndex:1, title: "PokemonGo", color: "#222" });
report.addLine({ dataIndex:2, title: "Mustache", color: "#333" });

report.render($("#reportPlaceholder2"));

Здесь мы видим, что у нас на странице есть от одного до N графиков lineChart, у каждого из которого свой источник данных, заголовок и список показателей. Моделька:


def get_model():
    return {
        "charts": [
            {
              "divId": "#reportPlaceholder1",
              "title": "За второе полугодие - сводный",
                "sourceUrl": "/reports/data/0",
                "series": [
                    {"dataIndex": 0, "title": "Доходы", "color": "green"},
                    {"dataIndex": 1, "title": "Расходы", "color": "red"},
                ]
            },
            {
                "divId": "#reportPlaceholder2",
                "title": "За второе полугодие - по продуктам",
                "sourceUrl": "/reports/data/1",
                "series": [
                    {"dataIndex": 0, "title": "Hello Kitty", "color": "#000"},
                    {"dataIndex": 1, "title": "PokemonGo", "color": "#111"},
                    {"dataIndex": 2, "title": "Mustache", "color": "#222"},
                ]
            }

        ]
    }

ну и шаблон:


{{#charts}}

var report = CreateChart({ title: "{{title}}" }, type: "lineChart", sourceUrl: "{{sourceUrl}}" );
{{#series}}
report.addLine({ dataIndex:{{dataIndex}}, title: "{{title}}", color: "{{color}}" });
{{/series}}
report.render($("{{divId}}"));
{{/charts}}

Обратите внимание: такой "в лоб" подход к шаблонизации требует отдельного усилия по экранированию значений в модельке. если в series[0].title у нас прокрадется запятая или кавычка — "Hello Kitty\"" — синтаксиc результирующего файла с треском развалится. Поэтому пишите функции экранирования и используйте их при создании моделей. Используйте форматтеры, если шаблонизатор это умеет.




Третий кубик полетел в дверь и со стуком отскочил. Тоже никуда не годится. Интересно. а можно кинуть кубик так, чтобы он проскочил под дверью и долетел до конца коридора? Можно ли 3д-печатать резиной? Или лучше сделать его наполненным маленькими такими икосаэдрами, наполненными воздухом?...


SQL-запросы


Придирчивый читатель скажет, что это тоже кодогенерация, трансляция концепций из одного языка программирования в другой. На что мы ответим придирчивому читателю, что работа с SQL или с любым другим языком запросов к БД — это немного отдельная область программирования и не всем очевидно, что если шаблонами можно генерить js скрипты, то и SQL тоже можно. Поэтому вынесем запросы в отдельный кейс.


И чтобы придирчивый читатель не скучал, оставим в статье только примеры нескольких запросов. Можете сами прикинуть, какая модель и какой шаблон тут лучше подойдут. В примерах на гитхабе можете посмотреть, что у меня получилось.


SELECT * FROM hosts WHERE firmware_id=1 AND bmc_login='admin' ORDER BY ip DESC;
SELECT * FROM hosts  ORDER BY name LIMIT 3;
SELECT host_type_id, COUNT(*) FROM hosts GROUP BY host_type_id;

Шаблоны (в том числе и для SQL) и примеры кода можно найти на гитхабе.

Комментарии (9)


  1. gnomeby
    02.06.2019 18:13

    Почему я должен предпочесть ваш способ по сравнению с jinja?
    Ну а если надо играться с SQL, то точно следует предпочесть SQLAlchemy, он гарантирует безопасность и валидность результата.


    1. SHLAKBAUM
      02.06.2019 12:49

      Во-первых, ни jinja, ни SQLAlchemy не подойдут, если заменить Python на любой другой язык программирования. Во-вторых, всё описанное в статье можно делать и с jinja, просто для примера взят mustache. Такое ощущение, что вы не читали статью.


  1. danilovmy
    02.06.2019 00:26

    не совсем понятна причина использования мусташи, если logic-less. Это же могут быть текстовые файлы, которые далее sodergimoe_faila.format(**kwargs)

    В моем проекте я использую модельку шаблонов, для хранения их в базе, и тогда заготовки для будущих «артефактов» удобно править онлайн.


    1. Yenox Автор
      02.06.2019 13:14

      Я не пропагандирую какой-то конкретный шаблонизатор, но да, склоняюсь к минимизации логики внутри шаблонов (форматтеры и предикаты по спискам — ОК, выполнение произвольного кода — не ОК). Шаблонизирование через format хорошо, если в модели нет массивов и условий (то есть для ряда случаев — вполне себе метод).


      Ну и скорее статья дискутирует с методом, когда подобные артефакты создаются путем конкатенации строчек внутри кода. Такой подход я встречал неоднократно (особенно почему-то любят так мучать SQL). Результат — очень мутный код.


      Для сравнения — добавил в гитхабе в template_3.py класс, который формирует SQL путем конкатенации строк по той же модельке. Довольно мутная штука в плане наглядности:


      class SqlAntipattern(object):
          def render(self, m):
              if m["typeList"]:
                  return self.render_typelist(m)
              if m["typeCountGroupBy"]:
                  return self.render_groupby(m)
      
              raise Exception("Unknown query type")
      
          def render_where(self, m):
              sql = "WHERE "
      
              def eqtion(w):
                  return "{}={}{}{}".format(
                      w["field"],
                      "'" if "quot" in w else "",
                      w["eq_val"],
                      "'" if "quot" in w else "")
      
              wheres = [eqtion(w) for w in m["where"]]
              sql += ", ".join(wheres)
              return sql + " "
      
          def render_orderby(self, m):
              return "ORDER BY " + m["orderBy"]
      
          def render_typelist(self, m):
              sql = "SELECT * FROM " + m["tableName"] + " "
              if "hasWhere" in m:
                  sql += self.render_where(m)
      
              if "orderBy" in m:
                  sql += self.render_orderby(m)
      
              if "limit" in m:
                  sql += " LIMIT " + m["limit"]
      
              return sql
      
          def render_groupby(self, m):
              sql = "SELECT " + m["groupColumn"]
              sql += ", COUNT(*) FROM " + m["tableName"] + " "
              sql += " GROUP BY " + m["groupColumn"]
      
              return sql


      1. danilovmy
        03.06.2019 11:56

        хмм. Код выглядит очень странно.
        например зачем так сложно, можно же без конкатенаций строк сделать:
        render_where

        def render_where(self, m):
                returned_template = 'WHERE {my_where} '.format
                whered_template = "{field}={separator}{eq_val}{separator}".format
                wheres = ", ".join(whered_template(separator="'" if 'quot' in w else '', **w) for w in m["where"]))
                return returned_template(my_where=wheres)
        

        render_groupby
        def render_groupby(self, m):
            returned_template = 'SELECT {groupColumn}, COUNT(*) FROM {tableName} GROUP BY {groupColumn}'.format
            return returned_template(**m)


        Хотя это все от лукавого. Наличие подобного кода может сообщать об ошибке в архитектуре приложения.

        P.s. Я люблю нагружать текстовыми обработками запросы к базе. На это у меня есть личные причины. Результат — очень прозрачный код.


  1. antirek
    02.06.2019 16:54

    Как пример текстовых артефактов: конфиги SIP-телефонов. У меня сервер провижна так работает — из общего набора данных генерится конфиг по шаблону с заполненными данными habr.com/ru/post/445350 имхо, вполне очевидная идея ))


  1. nrgian
    02.06.2019 17:26
    +1

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


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

    В шаблоне должно быть что то вроде:
    «Если есть дополнтельное примечание, то вот тут его выводим»

    А что это конкретно за примечание и для кого оно Урюпинска или для Сызрани там — не шаблон должен это решать.


    1. Yenox Автор
      02.06.2019 19:33

      Согласен про бизнес-логику, но в этом месте статьи я изящно (смайлик) показал способ быстро захардкодить странное. А за странным бизнес иногда прибегает в пятницу вечером со словами что "все забыли, сорян, но в субботу оно должно работать, потому что Урюпинск с утра будет на федеральных каналах". Дописывать ядро бизнес-логики и выкатывать на прод в пятницу не всегда хочется, поэтому можно например вот так, по-простому. С одновременным постом в трекер задачи на техдолг.


  1. Yenox Автор
    02.06.2019 19:32

    (здесь был ответ на комментарий про бизнес-логику, который потом перенес в правильную ветку)