Преобразование YAML в XML и properties в шаблонах Jinja
В работе с системами управления конфигурациями (SCM) одной из частых задач является шаблонизация (конфигурационных) файлов. К примеру, для этого Ansible и SaltStack поддерживают синтаксис шаблонов Jinja и описание конфигураций на языке YAML. В ПО, для настройки которого используют JSON, TOML или INI, YAML обычно сериализуют либо в DSL (file.serialize
) или преобразуют явно в шаблоне с помощью Jinja Filters (to_json
).
Однако выразительности YAML иногда бывает недостаточно. Например, конфигурации в XML или Properties в Java не (де-)сериализовать ни средствами SCM, ни самим шаблонизатором.
Когда столкнулся с этой проблемой впервые, то решил её влоб и шаблонизировал документ целиком. Получалось примерно так, как во фрагменте ERB ниже.
<?xml version="1.0"?>
<yandex>
<logger>
<level><%= @config['logger']['level'] %></level>
<log><%= @log_path %>/server.log</log>
<errorlog><%= @log_path %>/server.err.log</errorlog>
<size><%= @config['logger']['size'] %></size>
<count><%= @config['logger']['count'] %></count>
</logger>
<% if @config['trace_log']['enable'] %>
<trace_log>
<database><%= @config['trace_log']['database'] %></database>
<table><%= @config['trace_log']['table'] %></table>
<partition_by><%= @config['trace_log']['partition_by'] %></partition_by>
<flush_interval_milliseconds><%= @config['trace_log']['flush_interval_milliseconds'] %></flush_interval_milliseconds>
</trace_log>
<% end %>
</yandex>
Или так.
log.dirs=<%= @config['log']['dirs'] %>
log.flush.interval.messages=<%= @config['log']['flush']['interval']['messages'] %>
log.flush.interval.ms=<%= @config['log']['flush']['interval']['ms'] %>
log.retention.hours=<%= @config['log']['retention']['hours'] %>
log.segment.bytes=<%= @config['log']['segment']['bytes'] %>
log.retention.check.interval.ms=<%= @config['log']['retention']['check']['interval']['ms'] %>
log.cleaner.enable=<%= @config['log']['cleaner']['enable'] %>
Это хорошо работало в боевой эксплуатации несколько лет до тех пор, пока шаблон не стал таким большим, что его стало сложно поддерживать. При таком подходе быстро потерялась семантика исходной конфигурации: в итоге системному администратору сначала приходилось разбираться в том, как настроить ПО без SCM, а после – как перевести это в YAML.
Ещё приходилось выпускать новую версию рецепта всякий раз при добавлении новой опции из-за сильной связанности рецепта и его параметров.
Облегчить процесс могла бы поддержка преобразований непосредственно в SCM, но это не кажется простым решением. При управлении конфигурациями чаще всего не нужна десериализация строки в объект. Обычно есть объект и его нужно представить в определённом формате, но не наоборот.
Кроме того, каждый SCM предоставляет собственный механизм расширения функциональности (Ansible Filters, SaltStack Serializer Modules), что делает решение непереносимым между разными управляющими узлами.
В поисках одновременно простого и лёгкого решения, остановился на шаблонизации с использованием макросов Jinja.
YAML в XML
В XML узлы поддерживают атрибуты и множественные элементы, в то время как YAML – нет. Решение этой проблемы лежит в выделении атрибутов родительского узла в отдельный ключ xmlattributes
.
{%- macro insert_spaces(num=0) -%}
{%- for space in range(1,num) if num != 0 -%}
{{ ' ' }}
{%- endfor -%}
{%- endmacro -%}
{%- macro line_block(key, value='', params={}, spaces=0) %}
{{ insert_spaces(spaces) }}<{{ key }}{{ params|xmlattr }}>{{ value }}</{{ key }}>
{%- endmacro -%}
{%- macro dict_block(key, value, params={}, spaces=0) -%}
{%- if value.get('xmlattributes') -%}
{%- set params = value.get('xmlattributes') -%}
{%- endif -%}
{%- if value.get('value') and value.get('xmlattributes') -%}
{{ block(key=key,value=value.get('value'),params=params,spaces=spaces) }}
{%- else %}
{{ insert_spaces(spaces) }}<{{ key }}{{ params|xmlattr }}>
{%- for k,v in value.items() if k != 'xmlattributes' -%}
{{ insert_spaces(spaces) }}{{ block(key=k,value=v,spaces=spaces+4) }}
{%- endfor %}
{{ insert_spaces(spaces) }}</{{ key }}>
{%- endif -%}
{%- endmacro -%}
{%- macro block(key, value, params={}, spaces=0) -%}
{%- if value is mapping -%}
{{ dict_block(key=key, value=value, params=params, spaces=spaces) }}
{%- elif value is string or value is number -%}
{{ line_block(key=key, value=value, params=params, spaces=spaces) }}
{%- elif value is sequence -%}
{%- for element in value -%}
{{ block(key=key, value=element, params=params, spaces=spaces) }}
{%- endfor -%}
{%- endif -%}
{%- endmacro -%}
Для рендера XML вызовите макрос block
, сообщив ему имя родительского элемента, объект и число пробельных символов в отступе.
{{ block(key=key, value=value, spaces=4) }}
Пример.
zookeeper:
node:
- xmlattributes:
index: 1
host: zk-node1.example.tld
port: 2181
- xmlattributes:
index: 2
host: zk-node2.example.tld
port: 2181
- xmlattributes:
index: 3
host: zk-node3.example.tld
port: 2181
Результат.
<zookeeper>
<node index="1">
<host>zk-node1.example.tld</host>
<port>2181</port>
</node>
<node index="2">
<host>zk-node2.example.tld</host>
<port>2181</port>
</node>
<node index="3">
<host>zk-node3.example.tld</host>
<port>2181</port>
</node>
</zookeeper>
YAML в Properties
В properties-файле в качестве разделителей используются точки, но иногда для обозначения пары ключ-значение используется colon (:
). Здесь использовал символ underscore (_
) в начале имени ключа.
{%- macro block(parent, dict) -%}
{%- for k, v in dict.items() -%}
{%- if v is mapping -%}
{%- if parent == "" -%}
{{ block(k, v) }}
{%- else -%}
{{ block(parent + "." + k, v) }}
{%- endif -%}
{%- else -%}
{%- if parent == "" -%}
{{ k }} = {{ v }}
{%- elif k.startswith("_") -%}
{{ parent }}:{{ k[1:] }} = {{ v }}
{% else -%}
{{ parent }}.{{ k }} = {{ v }}
{% endif -%}
{%- endif -%}
{%- endfor -%}
{%- endmacro -%}
Для рендера properties вызовите макрос block
, сообщив ему имя родительского элемента и объект.
{{ block("", dict=value) }}
Пример.
log:
dirs: "/var/lib/kafka"
flush:
interval:
messages: 10000
ms: 1000
segment:
bytes: 104857600
retention:
hours: 48
check:
interval:
ms: 300000
cleaner:
enable: true
Результат.
log.dirs=/var/lib/kafka
log.flush.interval.messages=10000
log.flush.interval.ms=1000
log.retention.hours=48
log.segment.bytes=104857600
log.retention.check.interval.ms=300000
log.cleaner.enable=true
Просто и легко!