Jinja2 Lanjutan #
Sebagian besar pengguna Ansible menguasai dasar Jinja2 — variabel {{ var }}, kondisi {% if %}, loop {% for %}. Tapi Jinja2 jauh lebih ekspresif dari itu. Macro yang bisa dipanggil ulang, namespace untuk mutasi variabel dalam loop, filter chaining yang elegan — teknik-teknik ini mengubah template yang kompleks menjadi kode yang mudah dibaca dan di-maintain. Artikel ini membahas fitur Jinja2 yang sering diabaikan tapi sangat berguna di lingkungan produksi, terutama saat kita berurusan dengan data konfigurasi yang tidak seragam. Sebelum membaca artikel ini, pastikan kita sudah nyaman dengan loop, filter, dan kondisional dasar — semua ini dibangun di atas fondasi yang sudah kita bahas di section sebelumnya.
Template Rendering Pipeline #
Untuk memahami mengapa teknik-teknik lanjutan ini penting, ada baiknya kita mengetahui apa yang terjadi ketika Ansible memproses sebuah template. Jinja2 bukan hanya “penyisip variabel” — ia adalah mesin template lengkap dengan lexer, parser, compiler, dan runtime. Memahami pipeline ini membantu menjelaskan beberapa perilaku yang awalnya terlihat aneh, seperti mengapa set di dalam loop tidak berfungsi seperti yang kita harapkan.
flowchart LR
A["Template .j2"] --> B["Lexer"]
B --> C["Parser"]
C --> D["AST"]
D --> E["Compiler"]
E --> F["Python code"]
F --> G["Execution context"]
G --> H["Final string output"]
Var["Variables Ansible"] --> G
Filt["Filter plugins"] --> G
Test["Test plugins"] --> G
Yang perlu kita perhatikan dari diagram ini: Jinja2 kompilasi template ke kode Python, lalu mengeksekusi kode itu. Ini menjelaskan dua hal penting. Pertama, variabel yang di-set di dalam loop sebenarnya membuat variabel baru di scope lokal loop, bukan memodifikasi variabel di scope luar — karena di Python, variabel yang didefinisikan dalam for loop tidak terlihat di luar loop. Kedua, setiap kali Ansible me-render template yang sama, ia akan compile ulang (kecuali di-cache) — template yang sangat besar bisa menambah latensi yang tidak perlu.
Whitespace control dengan-: Jinja2 menghasilkan output persis seperti yang kita tulis, termasuk spasi dan baris baru. Untuk konfigurasi yang sensitif terhadap whitespace (nginx, systemd unit, YAML itu sendiri), gunakan{{- expr }}(hapus whitespace di sebelah kiri) atau{%- block -%}(hapus di kedua sisi). Trik ini sangat berguna tapi mudah dilewatkan sampai output pertama kita penuh dengan baris kosong yang aneh.
Macro: Fungsi yang Bisa Dipanggil Ulang #
Macro adalah cara mendefinisikan blok template yang bisa dipanggil berulang kali seperti fungsi. Tanpa macro, satu-satunya cara untuk mengulang blok template adalah dengan menyalin-tempel — dan kita tahu betapa rapuhnya pendekatan itu saat konfigurasi perlu diubah. Macro memperkenalkan abstraksi yang sangat dibutuhkan.
{# templates/nginx.conf.j2 #}
{# Definisikan macro untuk server block #}
{% macro server_block(server_name, port, root, ssl=false) %}
server {
listen {{ port }}{% if ssl %} ssl{% endif %};
server_name {{ server_name }};
root {{ root }};
{% if ssl %}
ssl_certificate /etc/ssl/certs/{{ server_name }}.crt;
ssl_certificate_key /etc/ssl/private/{{ server_name }}.key;
{% endif %}
location / {
try_files $uri $uri/ =404;
}
}
{% endmacro %}
{# Gunakan macro untuk setiap vhost #}
{% for vhost in nginx_vhosts %}
{{ server_block(vhost.name, vhost.port | default(80), vhost.root) }}
{% if vhost.ssl | default(false) %}
{{ server_block(vhost.name, 443, vhost.root, ssl=true) }}
{% endif %}
{% endfor %}
Yang membuat macro kuat adalah parameter dengan nilai default (ssl=false), dan kemampuan untuk mem-pass filter ke parameter (vhost.port | default(80)). Saat data vhost kita bertambah — misal ada tambahan proxy_pass, client_max_body_size, atau add_header — kita cukup menambah parameter ke macro, dan semua panggilan secara otomatis mendapat parameter baru dengan default yang masuk akal. Tidak perlu copy-paste.
ANTI-PATTERN vs BENAR: Macro vs Copy-Paste #
{# ANTI-PATTERN: copy-paste blok server block untuk setiap vhost #}
{% for vhost in nginx_vhosts %}
server {
listen {{ vhost.port | default(80) }};
server_name {{ vhost.name }};
root {{ vhost.root }};
location / {
try_files $uri $uri/ =404;
}
}
{% if vhost.ssl | default(false) %}
server {
listen 443 ssl;
server_name {{ vhost.name }};
root {{ vhost.root }};
ssl_certificate /etc/ssl/certs/{{ vhost.name }}.crt;
ssl_certificate_key /etc/ssl/private/{{ vhost.name }}.key;
location / {
try_files $uri $uri/ =404;
}
}
{% endif %}
{% endfor %}
{# BENAR: definisikan macro sekali, panggil dengan parameter #}
{% macro server_block(server_name, port, root, ssl=false) %}
server {
listen {{ port }}{% if ssl %} ssl{% endif %};
...
}
{% endmacro %}
{% for vhost in nginx_vhosts %}
{{ server_block(vhost.name, vhost.port | default(80), vhost.root) }}
{% if vhost.ssl | default(false) %}
{{ server_block(vhost.name, 443, vhost.root, ssl=true) }}
{% endif %}
{% endfor %}
Skenario dunia nyata di mana macro sangat bernilai: template HAProxy dengan puluhan backend, template Prometheus alert rules dengan puluhan alert, template systemd unit dengan variasi Environment. Setiap kali kita menemukan diri kita menulis blok yang sama lebih dari dua kali, macro adalah jawabannya.
Namespace: Variabel yang Bisa Dimutasi dalam Loop #
Masalah paling membuat frustrasi bagi pemula Jinja2: kita memerlukan akumulator dalam loop, tapi set biasa di dalam loop tidak bisa diakses di luar loop. namespace menyelesaikan ini. Tapi sebelum masuk ke solusi, mari kita pahami dulu mengapa masalah ini ada. Jinja2 mewarisi perilaku scope dari Python — variabel yang didefinisikan dalam blok for tidak ikut keluar dari blok tersebut. Ini fitur keamanan bahasa, tapi terasa aneh bagi kita yang datang dari bahasa dengan variabel global.
{# ANTI-PATTERN: variabel dalam loop tidak bisa diubah dan dibaca di luar #}
{% set found = false %}
{% for server in servers %}
{% if server.role == 'primary' %}
{% set found = true %} {# Ini TIDAK mengubah 'found' di luar loop! #}
{% endif %}
{% endfor %}
{{ found }} {# Masih false! #}
{# BENAR: gunakan namespace #}
{% set ns = namespace(found=false, primary_server='') %}
{% for server in servers %}
{% if server.role == 'primary' %}
{% set ns.found = true %}
{% set ns.primary_server = server.hostname %}
{% endif %}
{% endfor %}
{# Sekarang ns.found dan ns.primary_server berisi nilai yang benar #}
Primary server: {{ ns.primary_server }}
Found: {{ ns.found }}
Objek namespace adalah wadah yang bisa dimutasi. Berbeda dengan set biasa yang membuat variabel baru di scope lokal, ns.found = true memodifikasi atribut dari objek yang sama yang dideklarasikan di scope luar. Karena objeknya sama, perubahan di dalam loop terlihat di luar loop. Trik ini juga berfungsi untuk scope yang lebih dalam — namespace dalam for di dalam if di dalam for semuanya mengakses objek yang sama.
Contoh praktis yang lebih relevan — generate konfigurasi upstream nginx dengan weight total:
{# templates/upstream.conf.j2 #}
{% set ns = namespace(total_weight=0) %}
{% for server in upstream_servers %}
{% set ns.total_weight = ns.total_weight + server.weight | default(1) %}
{% endfor %}
# Total weight: {{ ns.total_weight }}
upstream {{ upstream_name }} {
{% for server in upstream_servers %}
server {{ server.host }}:{{ server.port }} weight={{ server.weight | default(1) }};
{% endfor %}
}
Sequence Diagram Lookup Variabel #
Untuk memahami mengapa namespace bekerja tetapi set biasa tidak, lihat bagaimana Jinja2 mencari variabel:
sequenceDiagram
participant T as "Template"
participant L as "Local Scope (for loop)"
participant E as "Enclosing Scope"
participant G as "Global Scope"
T->>L: Cari variabel 'found'
L->>L: Ada 'found' lokal? (untuk set di dalam loop, YA tapi di-scope lokal)
L-->>T: Return nilai lokal
Note over T,L: Tapi setelah loop selesai, scope lokal dibuang
T->>E: Cari variabel 'ns'
E->>G: Cari di parent scope
G-->>E: Ada 'ns' di sini (objek namespace)
E-->>T: Return objek ns
T->>T: ns.found = true → mutasi objek
Note over T,G: Mutasi pada objek yang sama, terlihat di semua scope
Visualisasi ini menjelaskan satu hal penting: set membuat variabel baru di scope, sedangkan ns.attr = value memodifikasi atribut dari objek yang sudah ada. Karena objek ns adalah reference (bukan copy), modifikasi terlihat di semua scope yang memegang reference yang sama.
Filter Chaining yang Ekspresif #
Filter adalah senjata utama Jinja2 untuk transformasi data. Ansible menambahkan banyak filter di atas filter bawaan Jinja2 — to_yaml, to_json, b64encode, combine, dict2items, dan puluhan lainnya. Kekuatan sebenarnya datang dari kemampuan untuk me-chain filter — output satu filter menjadi input filter berikutnya.
vars:
servers:
- {name: web-01, role: webserver, env: production, ip: 10.0.1.1}
- {name: web-02, role: webserver, env: production, ip: 10.0.1.2}
- {name: db-01, role: database, env: production, ip: 10.0.2.1}
- {name: web-03, role: webserver, env: staging, ip: 10.1.1.1}
tasks:
# Ambil hanya production webservers, ekstrak IP, sort
- debug:
msg: >-
{{ servers
| selectattr('env', 'equalto', 'production')
| selectattr('role', 'equalto', 'webserver')
| map(attribute='ip')
| sort
| list }}
# Output: ['10.0.1.1', '10.0.1.2']
# Group by role, hitung per role
- debug:
msg: "{{ servers | groupby('role') | map('first') | list }}"
# Buat dictionary dari list: hostname → ip
- debug:
msg: >-
{{ servers
| items2dict(key_name='name', value_name='ip') }}
# Output: {web-01: 10.0.1.1, web-02: 10.0.1.2, ...}
# Flatten dan deduplikasi
- debug:
msg: >-
{{ servers
| map(attribute='role')
| unique
| sort
| list }}
# Output: ['database', 'webserver']
Tabel Filter yang Paling Berguna #
| Filter | Tujuan | Contoh |
|---|---|---|
selectattr |
Filter item list berdasarkan atribut | servers | selectattr('env', 'equalto', 'prod') |
rejectattr |
Kebalikan selectattr | servers | rejectattr('disabled', 'defined') |
map |
Ekstrak atribut atau apply fungsi | servers | map(attribute='ip') | list |
groupby |
Group item berdasarkan atribut | servers | groupby('role') |
unique |
Hapus duplikat | [1,2,2,3] | unique |
sort |
Sort list | names | sort |
items2dict |
List of dict → dict | list | items2dict('key_name', 'value_name') |
dict2items |
Dict → list of dict | {'a': 1} | dict2items |
combine |
Merge beberapa dict | defaults | combine(overrides) |
to_yaml |
Render sebagai YAML | var | to_yaml |
to_nice_yaml |
YAML dengan indent rapi | var | to_nice_yaml(indent=2) |
b64decode / b64encode |
Base64 | secret | b64encode |
regex_replace |
Replace dengan regex | name | regex_replace('^web-', 'http-') |
default |
Nilai default jika undefined | var | default('kosong') |
mandatory |
Raise error jika undefined | var | mandatory |
ANTI-PATTERN vs BENAR: Filter Chain vs Loop Manual #
# ANTI-PATTERN: tulis loop manual untuk transformasi yang bisa satu baris
tasks:
- name: Ambil production webserver IPs
set_fact:
prod_web_ips: "{{ prod_web_ips | default([]) + [item.ip] }}"
loop: "{{ servers }}"
when:
- item.env == 'production'
- item.role == 'webserver'
- name: Sort hasil
set_fact:
prod_web_ips: "{{ prod_web_ips | sort }}"
# BENAR: satu filter chain, lebih mudah dibaca dan lebih cepat
tasks:
- name: Ambil production webserver IPs
debug:
msg: >-
{{ servers
| selectattr('env', 'equalto', 'production')
| selectattr('role', 'equalto', 'webserver')
| map(attribute='ip')
| sort
| list }}
Bukan hanya masalah estetika — filter chain dieksekusi dalam satu pass di sisi Ansible controller, sementara loop dengan set_fact membuat variabel baru di setiap iterasi yang di-write ke fact cache. Untuk list kecil perbedaannya tidak terasa, tapi untuk inventory besar dengan ratusan host dan list yang kompleks, filter chain bisa 10x lebih cepat.
Hati-hati denganmandatorydi environment yang berbeda. Filtermandatoryakan memunculkan error jika variabel undefined — berguna untuk mencegah bug dengan konfigurasi yang hilang. Tapi jangan pakaimandatorypada nilai yang kita harapkan akan kosong di beberapa environment (misaloptional_feature: ""). Pakaidefault('')ataudefault(none)untuk kasus itu.
State Diagram Filter Chain #
Filter chain sebenarnya adalah pipeline — setiap filter menerima input, transform, dan kirim ke filter berikutnya. Visualisasi ini membantu saat kita melakukan debug “kenapa output filter ini aneh” — kita bisa mengisolasi filter mana yang memperkenalkan masalah:
stateDiagram-v2
[*] --> InputList: "list servers"
InputList --> AfterSelect1: "selectattr env=production"
AfterSelect1 --> AfterSelect2: "selectattr role=webserver"
AfterSelect2 --> AfterMap: "map attribute=ip"
AfterMap --> AfterSort: "sort"
AfterSort --> FinalList: "list"
FinalList --> [*]: "['10.0.1.1', '10.0.1.2']"
note right of AfterSelect1
3 dari 4 server lolos
end note
note right of AfterMap
List of dict → list of string
end note
Saat melakukan debug, sisipkan filter length di tengah untuk melihat berapa item yang lolos di setiap tahap. {{ servers | selectattr('env', 'equalto', 'production') | length }} akan menampilkan angka 3 (jika 3 dari 4 server adalah production). Dengan begini kita bisa melakukan pinpoint di filter mana transformasi yang diharapkan tidak terjadi.
Custom Test: Logika Boolean yang Reusable #
Selain filter, Jinja2 mendukung test — fungsi boolean yang digunakan dengan is. Ini berbeda dari filter: filter transformasi data, test hanya mengembalikan True/False. Test dipakai dengan is, sedangkan filter dipakai dengan |. Pemisahan ini bukan hanya kosmetik — test bisa lebih cepat dieksekusi karena bisa short-circuit ({% if X is test %} tidak perlu memproses seluruh data).
# filter_plugins/custom_tests.py
# Ansible mencari test di plugins/test/ dalam collection, atau di filter_plugins/
import ipaddress
import re
def is_private_ip(ip):
"""Test apakah IP adalah private address."""
try:
addr = ipaddress.ip_address(ip)
return addr.is_private
except (ValueError, TypeError):
return False
def is_valid_hostname(hostname):
"""Test apakah hostname valid (huruf, angka, dan tanda hubung)."""
if not isinstance(hostname, str):
return False
pattern = r'^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$'
return bool(re.match(pattern, hostname))
def is_in_maintenance_window(current_hour, start, end):
"""Test apakah jam saat ini berada dalam window maintenance."""
if start <= end:
return start <= current_hour < end
else:
# Window yang melintasi tengah malam
return current_hour >= start or current_hour < end
class FilterModule:
def filters(self):
return {}
def tests(self):
return {
'private_ip': is_private_ip,
'valid_hostname': is_valid_hostname,
'in_maintenance_window': is_in_maintenance_window,
}
{# Gunakan custom test dalam template #}
{% for server in servers %}
{% if server.ip is private_ip %}
# Server internal: {{ server.name }}
{% endif %}
{% endfor %}
{# Dalam task Ansible #}
- name: Validasi hostname
assert:
that:
- inventory_hostname is valid_hostname
fail_msg: "Hostname '{{ inventory_hostname }}' tidak valid!"
- name: Skip deployment jika di maintenance window
debug:
msg: "Sedang maintenance, skip batch"
when:
- ansible_date_time.hour is in_maintenance_window(2, 4)
ANTI-PATTERN vs BENAR: Test vs Filter atau Hardcode #
{# ANTI-PATTERN: hardcode logika kompleks di template, tidak reusable #}
{% if server.ip.startswith('10.') or server.ip.startswith('192.168.') or server.ip.startswith('172.') %}
# Server internal
{% endif %}
{# BENAR: gunakan test yang didefinisikan sekali dan reusable #}
{% if server.ip is private_ip %}
# Server internal
{% endif %}
Versi ANTI-PATTERN rapuh: tidak mencakup semua range private IP (ada 172.16.0.0/12, ada IPv6 ULA, ada CGNAT 100.64.0.0/10), dan jika logikanya perlu diubah (misal tambah dukungan IPv6 ULA), kita harus mengubah di setiap template yang menggunakan. Versi BENAR — logika IP private di-handle oleh modul ipaddress Python yang komprehensif dan selalu up-to-date.
Menangani Data Hierarkis #
Template untuk konfigurasi dengan struktur data nested yang kompleks adalah salah satu use case paling menantang Jinja2. Di satu sisi kita ingin template tetap mudah dibaca. Di sisi lain, data bisa kehilangan field (memerlukan default), memiliki level yang bervariasi, atau salah. Berikut contoh template HAProxy untuk multi-service dengan backend list dinamis:
{# templates/haproxy.cfg.j2 #}
{# Data input yang diharapkan:
services:
- name: api
port: 80
backends:
- host: 10.0.1.1
port: 8080
weight: 2
- host: 10.0.1.2
port: 8080
weight: 1
health_check:
path: /health
interval: 5s
#}
{% for service in services %}
frontend {{ service.name }}_front
bind *:{{ service.port | default(80) }}
default_backend {{ service.name }}_back
backend {{ service.name }}_back
balance {{ service.balance | default('roundrobin') }}
{% if service.health_check is defined %}
option httpchk GET {{ service.health_check.path | default('/health') }}
{% endif %}
{% for backend in service.backends %}
server {{ service.name }}_{{ loop.index }}
{{- ' ' + backend.host + ':' + backend.port | string }}
{{- ' weight=' + backend.weight | default(1) | string }}
{%- if service.health_check is defined %}
{{- ' check inter ' + service.health_check.interval | default('10s') }}
{%- endif %}
{% endfor %}
{% endfor %}
Yang perlu diperhatikan di template ini: ada tiga kali penggunaan is defined untuk cek apakah key ada sebelum mengaksesnya. Ini penting karena Ansible tidak akan error saat template merender key yang hilang di data — tapi ia akan menulis string kosong, yang biasanya lebih buruk dari pada error eksplisit. Pola “cek dulu, baru pakai” membuat template robust terhadap data yang tidak lengkap.
Tabel Pattern untuk Data Hierarkis #
| Pattern | Tujuan | Contoh |
|---|---|---|
is defined |
Cek apakah key ada | {% if var.x is defined %} |
| default(val) |
Nilai default jika undefined atau falsy | {{ port | default(80) }} |
| default(val, true) |
Default hanya untuk undefined, bukan untuk false | {{ ssl | default(false, true) }} |
dict.items() |
Iterasi dict | {% for k, v in d.items() %} |
loop.index |
Nomor iterasi (1-based) | {{ loop.index }} |
loop.first / loop.last |
Cek iterasi pertama/terakhir | {% if loop.last %} |
recursive loop |
Loop yang rekursif untuk tree | {% for item in items recursive %} |
Recursive loop untuk data tree: Jinja2 mendukung loop rekursif dengan modifierrecursive, berguna untuk struktur tree seperti konfigurasi recursive DNS zones atau ACL group yang nested. Pola{% for item in items recursive %}{{ loop( item.children ) }}{% endfor %}sangat berguna tapi jarang dipakai — pelajari saat menemukan kasusnya.
Jinja2 dalam Variabel (Tidak Hanya Template) #
Jinja2 tidak hanya untuk file template — bisa juga dalam nilai variabel. Ini sangat berguna untuk membangun string dinamis dari beberapa variabel, atau untuk membuat nilai kondisional tanpa harus menulis task terpisah.
# group_vars/all.yml
app_log_dir: "/var/log/{{ app_name }}"
db_url: "postgresql://{{ db_user }}:{{ vault_db_password }}@{{ db_host }}:{{ db_port }}/{{ db_name }}"
backup_filename: "backup-{{ inventory_hostname }}-{{ ansible_date_time.date }}.tar.gz"
# Ekspresi kondisional dalam variabel
nginx_worker_processes: "{{ ansible_processor_vcpus * 2 }}"
max_open_files: >-
{{ '65536' if ansible_memtotal_mb > 8192 else '32768' }}
# Daftar dinamis berdasarkan inventory
monitoring_endpoints: >-
{{ groups['appservers']
| map('extract', hostvars, 'ansible_host')
| map('regex_replace', '^', 'http://')
| map('regex_replace', '$', ':9090/metrics')
| list }}
Keunggulan utama dari penggunaan Jinja2 di variabel adalah kita bisa men-defer evaluasi ke saat variabel tersebut digunakan. Variabel yang didefinisikan lebih awal dalam file yang sama bisa saling merujuk karena semua variabel di-evaluasi ulang setiap kali Ansible me-load variabel file.
ANTI-PATTERN vs BENAR: Jinja2 di Variabel vs Task #
# ANTI-PATTERN: bikin task untuk variabel yang bisa langsung ditulis
tasks:
- name: Hitung log directory
set_fact:
app_log_dir: "/var/log/{{ app_name }}"
- name: Set DB URL
set_fact:
db_url: "postgresql://{{ db_user }}:{{ vault_db_password }}@{{ db_host }}:{{ db_port }}/{{ db_name }}"
# BENAR: langsung tulis Jinja2 di variabel, tidak perlu task tambahan
vars:
app_log_dir: "/var/log/{{ app_name }}"
db_url: "postgresql://{{ db_user }}:{{ vault_db_password }}@{{ db_host }}:{{ db_port }}/{{ db_name }}"
Versi ANTI-PATTERN menambahkan dua task yang harus dijalankan hanya untuk menghitung string. Ini memperlambat playbook, menambah noise di log, dan membuat fakta-fakta baru yang sebenarnya hanya turunan dari variabel yang sudah ada. Versi BENAR langsung menulis variabel sebagai ekspresi Jinja2 — dievaluasi hanya saat variabel itu diakses, dan tidak menambah task.
Ringkasan #
- Macro untuk blok template yang berulang — definisikan sekali, panggil berkali-kali dengan parameter berbeda, persis seperti fungsi. Dukungan parameter dengan default value membuat macro fleksibel untuk konfigurasi dengan variasi.
namespaceuntuk akumulator dalam loop — variabel biasa yang di-setdi dalam loop tidak bisa diakses setelah loop berakhir karena scope Python.set ns = namespace(...)laluns.attr = valuememodifikasi objek yang terlihat di semua scope.- Filter chaining dengan
selectattr,map,groupby,unique,sort— transformasi list yang kompleks bisa ditulis dalam satu ekspresi yang ekspresif. Lebih cepat dari loop manual denganset_factdi setiap iterasi.- Custom test (
is private_ip,is valid_hostname) untuk logika boolean yang reusable di template dan taskassert. Test berbeda dari filter: test untuk boolean, filter untuk transformasi, danisvs|.- Gunakan
-(dash) untuk menghapus whitespace di Jinja2:{{- expr }}atau{%- block -%}— menghasilkan output yang lebih bersih terutama untuk konfigurasi yang sensitif terhadap whitespace seperti nginx dan systemd.- Jinja2 bisa digunakan dalam nilai variabel, bukan hanya file template — sangat berguna untuk membangun string dinamis dari beberapa variabel, atau untuk nilai kondisional seperti
{{ '65536' if memtotal > 8192 else '32768' }}.is defineduntuk cek apakah key ada,| default(val)untuk nilai default, dan| default(val, true)untuk default hanya untuk undefined (bukan untuk false) — tiga pattern ini menutupi 90% kasus data yang tidak lengkap.- Pipeline filter itu debugable: sisipkan
| lengthdi tengah untuk melihat berapa item yang lolos di setiap tahap, sehingga kita bisa melakukan pinpoint di filter mana transformasi yang diharapkan tidak terjadi.