From 3edd5425c4a38842c2047b31ddc5a5e9eccbb68c Mon Sep 17 00:00:00 2001 From: "Chris S." Date: Sun, 15 Jun 2025 14:34:26 +0100 Subject: [PATCH 1/2] Add multi-engine complex benchmarks --- .../ComplexNestedTemplateBenchmarks.cs | 67 +++++ benchmarks/TextTemplate.Benchmarks/Program.cs | 5 +- .../TestData/complex-template.hbs | 159 ++++++++++++ .../TestData/complex-template.liquid | 159 ++++++++++++ .../TestData/complex-template.scriban | 159 ++++++++++++ .../TextTemplate.Benchmarks.csproj | 25 ++ benchmarks/go/template_test.go | 105 ++++++++ .../go/testdata/complex-template-data.yml | 0 .../testdata/complex-template-deployment.yml | 30 ++- .../go/testdata/complex-template-expected.yml | 0 .../go/testdata/complex-template-ingress.yml | 0 .../go/testdata/complex-template-service.yml | 0 docs/README.md | 21 ++ .../ComplexNestedTemplateData.cs | 120 +++++++++ .../TestData/complex-template-data.yml | 5 + .../TestData/complex-template-deployment.yml | 101 ++++++++ ...cted.yml => complex-template-expected.yml} | 0 .../TestData/complex-template-ingress.yml | 41 +++ .../TestData/complex-template-service.yml | 22 ++ ...e-k8s-data.yml => single-complex-data.yml} | 0 .../TestData/single-complex-expected.yml | 136 ++++++++++ .../TextTemplate.Tests.csproj | 14 +- .../TextTemplate.Tests/YmlTemplateFileTest.cs | 238 +----------------- 23 files changed, 1158 insertions(+), 249 deletions(-) create mode 100644 benchmarks/TextTemplate.Benchmarks/ComplexNestedTemplateBenchmarks.cs create mode 100644 benchmarks/TextTemplate.Benchmarks/TestData/complex-template.hbs create mode 100644 benchmarks/TextTemplate.Benchmarks/TestData/complex-template.liquid create mode 100644 benchmarks/TextTemplate.Benchmarks/TestData/complex-template.scriban rename tests/TextTemplate.Tests/TestData/k8s-template-data.yml => benchmarks/go/testdata/complex-template-data.yml (100%) rename tests/TextTemplate.Tests/TestData/k8s-template-deployment.yml => benchmarks/go/testdata/complex-template-deployment.yml (97%) rename tests/TextTemplate.Tests/TestData/k8s-template-expected.yml => benchmarks/go/testdata/complex-template-expected.yml (100%) rename tests/TextTemplate.Tests/TestData/k8s-template-ingress.yml => benchmarks/go/testdata/complex-template-ingress.yml (100%) rename tests/TextTemplate.Tests/TestData/k8s-template-service.yml => benchmarks/go/testdata/complex-template-service.yml (100%) create mode 100644 tests/TextTemplate.Tests/ComplexNestedTemplateData.cs create mode 100644 tests/TextTemplate.Tests/TestData/complex-template-data.yml create mode 100644 tests/TextTemplate.Tests/TestData/complex-template-deployment.yml rename tests/TextTemplate.Tests/TestData/{single-k8s-expected.yml => complex-template-expected.yml} (100%) create mode 100644 tests/TextTemplate.Tests/TestData/complex-template-ingress.yml create mode 100644 tests/TextTemplate.Tests/TestData/complex-template-service.yml rename tests/TextTemplate.Tests/TestData/{single-k8s-data.yml => single-complex-data.yml} (100%) create mode 100644 tests/TextTemplate.Tests/TestData/single-complex-expected.yml diff --git a/benchmarks/TextTemplate.Benchmarks/ComplexNestedTemplateBenchmarks.cs b/benchmarks/TextTemplate.Benchmarks/ComplexNestedTemplateBenchmarks.cs new file mode 100644 index 0000000..9b2253a --- /dev/null +++ b/benchmarks/TextTemplate.Benchmarks/ComplexNestedTemplateBenchmarks.cs @@ -0,0 +1,67 @@ +using System; +using System.IO; +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using TextTemplate; +using TTTemplate = TextTemplate.Template; +using TextTemplate.Tests; +using HandlebarsDotNet; +using Scriban; +using DotLiquid; +using ScribanTemplateClass = Scriban.Template; +using DotLiquidTemplateClass = DotLiquid.Template; +using Hbs = HandlebarsDotNet.Handlebars; + +public class ComplexNestedTemplateBenchmarks +{ + private TTTemplate _tmpl = null!; + private string _hbText = null!; + private string _scribanText = null!; + private string _liquidText = null!; + private Dictionary _values = null!; + + [GlobalSetup] + public void Setup() + { + string baseDir = AppContext.BaseDirectory; + string depPath = Path.Combine(baseDir, "TestData", "complex-template-deployment.yml"); + string svcPath = Path.Combine(baseDir, "TestData", "complex-template-service.yml"); + string ingPath = Path.Combine(baseDir, "TestData", "complex-template-ingress.yml"); + string rootPath = Path.Combine(baseDir, "TestData", "complex-template-data.yml"); + string hbPath = Path.Combine(baseDir, "TestData", "complex-template.hbs"); + string scribanPath = Path.Combine(baseDir, "TestData", "complex-template.scriban"); + string liquidPath = Path.Combine(baseDir, "TestData", "complex-template.liquid"); + + _tmpl = TTTemplate.New("complex").ParseFiles(depPath, svcPath, ingPath, rootPath); + _hbText = File.ReadAllText(hbPath); + _scribanText = File.ReadAllText(scribanPath); + _liquidText = File.ReadAllText(liquidPath); + DotLiquidTemplateClass.NamingConvention = new DotLiquid.NamingConventions.CSharpNamingConvention(); + _values = ComplexNestedTemplateData.Create(); + } + + [Benchmark] + public string GoTextTemplate_NET() => _tmpl.Execute(_values); + + [Benchmark] + public string Handlebars() + { + var compiled = Hbs.Compile(_hbText); + return compiled(_values); + } + + [Benchmark] + public string Scriban() + { + var tmpl = ScribanTemplateClass.Parse(_scribanText); + return tmpl.Render(_values); + } + + [Benchmark] + public string DotLiquid() + { + var tmpl = DotLiquidTemplateClass.Parse(_liquidText); + return tmpl.Render(Hash.FromDictionary(_values)); + } +} + diff --git a/benchmarks/TextTemplate.Benchmarks/Program.cs b/benchmarks/TextTemplate.Benchmarks/Program.cs index a0b1fcf..feaec42 100644 --- a/benchmarks/TextTemplate.Benchmarks/Program.cs +++ b/benchmarks/TextTemplate.Benchmarks/Program.cs @@ -44,5 +44,8 @@ public void Setup() public class Program { - public static void Main(string[] args) => BenchmarkRunner.Run(); + public static void Main(string[] args) + { + BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); + } } diff --git a/benchmarks/TextTemplate.Benchmarks/TestData/complex-template.hbs b/benchmarks/TextTemplate.Benchmarks/TestData/complex-template.hbs new file mode 100644 index 0000000..f02422f --- /dev/null +++ b/benchmarks/TextTemplate.Benchmarks/TestData/complex-template.hbs @@ -0,0 +1,159 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{Values.appName}}-deployment + namespace: {{Values.namespace}} + labels: + app: {{Values.appName}} + version: {{Values.version}} + {{#if Values.environment}} + environment: {{Values.environment}} + {{/if}} + {{#each Values.customLabels}} + {{@key}}: {{this}} + {{/each}} +spec: + replicas: {{Values.replicaCount}} + selector: + matchLabels: + app: {{Values.appName}} + template: + metadata: + labels: + app: {{Values.appName}} + version: {{Values.version}} + spec: + containers: + - name: {{Values.appName}} + image: {{Values.image.repository}}:{{Values.image.tag}} + ports: + - containerPort: {{Values.service.port}} + {{#if Values.env}} + env: + {{#each Values.env}} + - name: {{name}} + value: "{{value}}" + {{/each}} + {{/if}} + {{#if Values.resources}} + resources: + {{#if Values.resources.limits}} + limits: + {{#each Values.resources.limits}} + {{@key}}: {{this}} + {{/each}} + {{/if}} + {{#if Values.resources.requests}} + requests: + {{#each Values.resources.requests}} + {{@key}}: {{this}} + {{/each}} + {{/if}} + {{/if}} + {{#if Values.volumeMounts}} + volumeMounts: + {{#each Values.volumeMounts}} + - name: {{name}} + mountPath: {{mountPath}} + {{#if readOnly}} + readOnly: {{readOnly}} + {{/if}} + {{/each}} + {{/if}} + {{#if Values.volumes}} + volumes: + {{#each Values.volumes}} + - name: {{name}} + {{#if configMap}} + configMap: + name: {{configMap.name}} + {{/if}} + {{#if secret}} + secret: + secretName: {{secret.secretName}} + {{/if}} + {{#if persistentVolumeClaim}} + persistentVolumeClaim: + claimName: {{persistentVolumeClaim.claimName}} + {{/if}} + {{/each}} + {{/if}} + {{#if Values.nodeSelector}} + nodeSelector: + {{#each Values.nodeSelector}} + {{@key}}: {{this}} + {{/each}} + {{/if}} + {{#if Values.tolerations}} + tolerations: + {{#each Values.tolerations}} + - key: {{key}} + operator: {{operator}} + {{#if value}} + value: {{value}} + {{/if}} + effect: {{effect}} + {{/each}} + {{/if}} +--- +{{#if Values.service.enabled}} +apiVersion: v1 +kind: Service +metadata: + name: {{Values.appName}}-service + namespace: {{Values.namespace}} + labels: + app: {{Values.appName}} +spec: + type: {{Values.service.type}} + ports: + - port: {{Values.service.port}} + targetPort: {{Values.service.targetPort}} + protocol: TCP + {{#if Values.service.nodePort}} + nodePort: {{Values.service.nodePort}} + {{/if}} + selector: + app: {{Values.appName}} +{{/if}} +--- +{{#if Values.ingress.enabled}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{Values.appName}}-ingress + namespace: {{Values.namespace}} + {{#if Values.ingress.annotations}} + annotations: + {{#each Values.ingress.annotations}} + {{@key}}: "{{this}}" + {{/each}} + {{/if}} +spec: + {{#if Values.ingress.tls}} + tls: + {{#each Values.ingress.tls}} + - hosts: + {{#each hosts}} + - {{this}} + {{/each}} + secretName: {{secretName}} + {{/each}} + {{/if}} + rules: + {{#each Values.ingress.hosts}} + - host: {{host}} + http: + paths: + {{#each paths}} + - path: {{path}} + pathType: {{pathType}} + backend: + service: + name: {{@root.Values.appName}}-service + port: + number: {{@root.Values.service.port}} + {{/each}} + {{/each}} +{{/if}} + diff --git a/benchmarks/TextTemplate.Benchmarks/TestData/complex-template.liquid b/benchmarks/TextTemplate.Benchmarks/TestData/complex-template.liquid new file mode 100644 index 0000000..6927175 --- /dev/null +++ b/benchmarks/TextTemplate.Benchmarks/TestData/complex-template.liquid @@ -0,0 +1,159 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ Values.appName }}-deployment + namespace: {{ Values.namespace }} + labels: + app: {{ Values.appName }} + version: {{ Values.version }} + {% if Values.environment %} + environment: {{ Values.environment }} + {% endif %} + {% for label in Values.customLabels %} + {{ label.Key }}: {{ label.Value }} + {% endfor %} +spec: + replicas: {{ Values.replicaCount }} + selector: + matchLabels: + app: {{ Values.appName }} + template: + metadata: + labels: + app: {{ Values.appName }} + version: {{ Values.version }} + spec: + containers: + - name: {{ Values.appName }} + image: {{ Values.image.repository }}:{{ Values.image.tag }} + ports: + - containerPort: {{ Values.service.port }} + {% if Values.env %} + env: + {% for e in Values.env %} + - name: {{ e.name }} + value: "{{ e.value }}" + {% endfor %} + {% endif %} + {% if Values.resources %} + resources: + {% if Values.resources.limits %} + limits: + {% for lim in Values.resources.limits %} + {{ lim.Key }}: {{ lim.Value }} + {% endfor %} + {% endif %} + {% if Values.resources.requests %} + requests: + {% for req in Values.resources.requests %} + {{ req.Key }}: {{ req.Value }} + {% endfor %} + {% endif %} + {% endif %} + {% if Values.volumeMounts %} + volumeMounts: + {% for m in Values.volumeMounts %} + - name: {{ m.name }} + mountPath: {{ m.mountPath }} + {% if m.readOnly %} + readOnly: {{ m.readOnly }} + {% endif %} + {% endfor %} + {% endif %} + {% if Values.volumes %} + volumes: + {% for v in Values.volumes %} + - name: {{ v.name }} + {% if v.configMap %} + configMap: + name: {{ v.configMap.name }} + {% endif %} + {% if v.secret %} + secret: + secretName: {{ v.secret.secretName }} + {% endif %} + {% if v.persistentVolumeClaim %} + persistentVolumeClaim: + claimName: {{ v.persistentVolumeClaim.claimName }} + {% endif %} + {% endfor %} + {% endif %} + {% if Values.nodeSelector %} + nodeSelector: + {% for ns in Values.nodeSelector %} + {{ ns.Key }}: {{ ns.Value }} + {% endfor %} + {% endif %} + {% if Values.tolerations %} + tolerations: + {% for t in Values.tolerations %} + - key: {{ t.key }} + operator: {{ t.operator }} + {% if t.value %} + value: {{ t.value }} + {% endif %} + effect: {{ t.effect }} + {% endfor %} + {% endif %} +--- +{% if Values.service.enabled %} +apiVersion: v1 +kind: Service +metadata: + name: {{ Values.appName }}-service + namespace: {{ Values.namespace }} + labels: + app: {{ Values.appName }} +spec: + type: {{ Values.service.type }} + ports: + - port: {{ Values.service.port }} + targetPort: {{ Values.service.targetPort }} + protocol: TCP + {% if Values.service.nodePort %} + nodePort: {{ Values.service.nodePort }} + {% endif %} + selector: + app: {{ Values.appName }} +{% endif %} +--- +{% if Values.ingress.enabled %} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ Values.appName }}-ingress + namespace: {{ Values.namespace }} + {% if Values.ingress.annotations %} + annotations: + {% for a in Values.ingress.annotations %} + {{ a.Key }}: "{{ a.Value }}" + {% endfor %} + {% endif %} +spec: + {% if Values.ingress.tls %} + tls: + {% for t in Values.ingress.tls %} + - hosts: + {% for h in t.hosts %} + - {{ h }} + {% endfor %} + secretName: {{ t.secretName }} + {% endfor %} + {% endif %} + rules: + {% for host in Values.ingress.hosts %} + - host: {{ host.host }} + http: + paths: + {% for p in host.paths %} + - path: {{ p.path }} + pathType: {{ p.pathType }} + backend: + service: + name: {{ Values.appName }}-service + port: + number: {{ Values.service.port }} + {% endfor %} + {% endfor %} +{% endif %} + diff --git a/benchmarks/TextTemplate.Benchmarks/TestData/complex-template.scriban b/benchmarks/TextTemplate.Benchmarks/TestData/complex-template.scriban new file mode 100644 index 0000000..cd4a756 --- /dev/null +++ b/benchmarks/TextTemplate.Benchmarks/TestData/complex-template.scriban @@ -0,0 +1,159 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ Values.appName }}-deployment + namespace: {{ Values.namespace }} + labels: + app: {{ Values.appName }} + version: {{ Values.version }} + {{ if Values.environment }} + environment: {{ Values.environment }} + {{ end }} + {{ for kv in Values.customLabels }} + {{ kv.key }}: {{ kv.value }} + {{ end }} +spec: + replicas: {{ Values.replicaCount }} + selector: + matchLabels: + app: {{ Values.appName }} + template: + metadata: + labels: + app: {{ Values.appName }} + version: {{ Values.version }} + spec: + containers: + - name: {{ Values.appName }} + image: {{ Values.image.repository }}:{{ Values.image.tag }} + ports: + - containerPort: {{ Values.service.port }} + {{ if Values.env }} + env: + {{ for e in Values.env }} + - name: {{ e.name }} + value: "{{ e.value }}" + {{ end }} + {{ end }} + {{ if Values.resources }} + resources: + {{ if Values.resources.limits }} + limits: + {{ for kv in Values.resources.limits }} + {{ kv.key }}: {{ kv.value }} + {{ end }} + {{ end }} + {{ if Values.resources.requests }} + requests: + {{ for kv in Values.resources.requests }} + {{ kv.key }}: {{ kv.value }} + {{ end }} + {{ end }} + {{ end }} + {{ if Values.volumeMounts }} + volumeMounts: + {{ for m in Values.volumeMounts }} + - name: {{ m.name }} + mountPath: {{ m.mountPath }} + {{ if m.readOnly }} + readOnly: {{ m.readOnly }} + {{ end }} + {{ end }} + {{ end }} + {{ if Values.volumes }} + volumes: + {{ for v in Values.volumes }} + - name: {{ v.name }} + {{ if v.configMap }} + configMap: + name: {{ v.configMap.name }} + {{ end }} + {{ if v.secret }} + secret: + secretName: {{ v.secret.secretName }} + {{ end }} + {{ if v.persistentVolumeClaim }} + persistentVolumeClaim: + claimName: {{ v.persistentVolumeClaim.claimName }} + {{ end }} + {{ end }} + {{ end }} + {{ if Values.nodeSelector }} + nodeSelector: + {{ for kv in Values.nodeSelector }} + {{ kv.key }}: {{ kv.value }} + {{ end }} + {{ end }} + {{ if Values.tolerations }} + tolerations: + {{ for t in Values.tolerations }} + - key: {{ t.key }} + operator: {{ t.operator }} + {{ if t.value }} + value: {{ t.value }} + {{ end }} + effect: {{ t.effect }} + {{ end }} + {{ end }} +--- +{{ if Values.service.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ Values.appName }}-service + namespace: {{ Values.namespace }} + labels: + app: {{ Values.appName }} +spec: + type: {{ Values.service.type }} + ports: + - port: {{ Values.service.port }} + targetPort: {{ Values.service.targetPort }} + protocol: TCP + {{ if Values.service.nodePort }} + nodePort: {{ Values.service.nodePort }} + {{ end }} + selector: + app: {{ Values.appName }} +{{ end }} +--- +{{ if Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ Values.appName }}-ingress + namespace: {{ Values.namespace }} + {{ if Values.ingress.annotations }} + annotations: + {{ for kv in Values.ingress.annotations }} + {{ kv.key }}: "{{ kv.value }}" + {{ end }} + {{ end }} +spec: + {{ if Values.ingress.tls }} + tls: + {{ for t in Values.ingress.tls }} + - hosts: + {{ for h in t.hosts }} + - {{ h }} + {{ end }} + secretName: {{ t.secretName }} + {{ end }} + {{ end }} + rules: + {{ for host in Values.ingress.hosts }} + - host: {{ host.host }} + http: + paths: + {{ for p in host.paths }} + - path: {{ p.path }} + pathType: {{ p.pathType }} + backend: + service: + name: {{ Values.appName }}-service + port: + number: {{ Values.service.port }} + {{ end }} + {{ end }} +{{ end }} + diff --git a/benchmarks/TextTemplate.Benchmarks/TextTemplate.Benchmarks.csproj b/benchmarks/TextTemplate.Benchmarks/TextTemplate.Benchmarks.csproj index 990c479..cc89d1c 100644 --- a/benchmarks/TextTemplate.Benchmarks/TextTemplate.Benchmarks.csproj +++ b/benchmarks/TextTemplate.Benchmarks/TextTemplate.Benchmarks.csproj @@ -14,6 +14,31 @@ + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + diff --git a/benchmarks/go/template_test.go b/benchmarks/go/template_test.go index 4f4d723..b9d2189 100644 --- a/benchmarks/go/template_test.go +++ b/benchmarks/go/template_test.go @@ -12,6 +12,13 @@ var goData = map[string]any{ "Items": []string{"one", "two", "three", "four", "five"}, } +var complexTmpl = template.New("complex") + +func init() { + base := "testdata/" + template.Must(complexTmpl.ParseFiles(base+"complex-template-deployment.yml", base+"complex-template-service.yml", base+"complex-template-ingress.yml", base+"complex-template-data.yml")) +} + func BenchmarkGoTextTemplate(b *testing.B) { for i := 0; i < b.N; i++ { var buf bytes.Buffer @@ -19,3 +26,101 @@ func BenchmarkGoTextTemplate(b *testing.B) { _ = buf.String() } } + +func BenchmarkGoComplexTemplate(b *testing.B) { + data := map[string]any{"Values": complexData()} + for i := 0; i < b.N; i++ { + var buf bytes.Buffer + complexTmpl.Execute(&buf, data) + _ = buf.String() + } +} + + +func BenchmarkGoComplexTemplate(b *testing.B) { + values := map[string]any{ + "Values": complexData(), + } + for i := 0; i < b.N; i++ { + var buf bytes.Buffer + complexTmpl.Execute(&buf, values) + _ = buf.String() + } +} + +// complexData returns the same nested dictionary used by the C# benchmarks. +// It is constructed inline so the benchmark does not rely on file I/O. +func complexData() map[string]any { + return map[string]any{ + "appName": "my-web-app", + "namespace": "production", + "version": "\"1.2.3\"", + "environment": "prod", + "customLabels": map[string]any{ + "team": "backend", + "cost-center": "engineering", + "project": "web-platform", + }, + "replicaCount": 5, + "image": map[string]any{ + "repository": "myregistry.com/my-web-app", + "tag": "v1.2.3", + }, + "service": map[string]any{ + "enabled": true, + "type": "LoadBalancer", + "port": 80, + "targetPort": 8080, + "nodePort": 30080, + }, + "env": []map[string]any{ + {"name": "DATABASE_URL", "value": "postgresql://db.example.com:5432/myapp"}, + {"name": "REDIS_HOST", "value": "redis.example.com"}, + {"name": "LOG_LEVEL", "value": "info"}, + {"name": "API_KEY", "value": "secret-api-key-123"}, + }, + "resources": map[string]any{ + "limits": map[string]any{"cpu": "\"1000m\"", "memory": "\"1Gi\""}, + "requests": map[string]any{"cpu": "\"500m\"", "memory": "\"512Mi\""}, + }, + "volumeMounts": []map[string]any{ + {"name": "config-volume", "mountPath": "/etc/config", "readOnly": true}, + {"name": "secret-volume", "mountPath": "/etc/secrets", "readOnly": true}, + {"name": "data-volume", "mountPath": "/var/data"}, + }, + "volumes": []map[string]any{ + {"name": "config-volume", "configMap": map[string]any{"name": "my-web-app-config"}}, + {"name": "secret-volume", "secret": map[string]any{"secretName": "my-web-app-secrets"}}, + {"name": "data-volume", "persistentVolumeClaim": map[string]any{"claimName": "my-web-app-data"}}, + }, + "nodeSelector": map[string]any{ + "kubernetes.io/os": "linux", + "node-type": "web-tier", + }, + "tolerations": []map[string]any{ + {"key": "\"node-type\"", "operator": "\"Equal\"", "value": "\"web-tier\"", "effect": "\"NoSchedule\""}, + {"key": "\"dedicated\"", "operator": "\"Equal\"", "value": "\"web-app\"", "effect": "\"NoExecute\""}, + }, + "ingress": map[string]any{ + "enabled": true, + "annotations": map[string]any{ + "kubernetes.io/ingress.class": "nginx", + "cert-manager.io/cluster-issuer": "letsencrypt-prod", + "nginx.ingress.kubernetes.io/rewrite-target": "/", + }, + "tls": []map[string]any{ + {"hosts": []any{"myapp.example.com", "api.myapp.example.com"}, "secretName": "myapp-tls-cert"}, + }, + "hosts": []map[string]any{ + {"host": "myapp.example.com", "paths": []map[string]any{ + {"path": "/", "pathType": "Prefix"}, + {"path": "/api", "pathType": "Prefix"}, + }}, + {"host": "api.myapp.example.com", "paths": []map[string]any{ + {"path": "/", "pathType": "Prefix"}, + }}, + }, + }, + } +} + diff --git a/tests/TextTemplate.Tests/TestData/k8s-template-data.yml b/benchmarks/go/testdata/complex-template-data.yml similarity index 100% rename from tests/TextTemplate.Tests/TestData/k8s-template-data.yml rename to benchmarks/go/testdata/complex-template-data.yml diff --git a/tests/TextTemplate.Tests/TestData/k8s-template-deployment.yml b/benchmarks/go/testdata/complex-template-deployment.yml similarity index 97% rename from tests/TextTemplate.Tests/TestData/k8s-template-deployment.yml rename to benchmarks/go/testdata/complex-template-deployment.yml index 9d9754a..20c48ac 100644 --- a/tests/TextTemplate.Tests/TestData/k8s-template-deployment.yml +++ b/benchmarks/go/testdata/complex-template-deployment.yml @@ -37,19 +37,7 @@ spec: {{- end }} {{- end }} {{- if .Values.resources }} - resources: - {{- if .Values.resources.limits }} - limits: - {{- range $key, $value := .Values.resources.limits }} - {{ $key }}: {{ $value }} - {{- end }} - {{- end }} - {{- if .Values.resources.requests }} - requests: - {{- range $key, $value := .Values.resources.requests }} - {{ $key }}: {{ $value }} - {{- end }} - {{- end }} + {{template "resources" .}} {{- end }} {{- if .Values.volumeMounts }} volumeMounts: @@ -95,3 +83,19 @@ spec: {{- end }} {{- end }} {{end}} + +{{define "resources"}} + resources: + {{- if .Values.resources.limits }} + limits: + {{- range $key, $value := .Values.resources.limits }} + {{ $key }}: {{ $value }} + {{- end }} + {{- end }} + {{- if .Values.resources.requests }} + requests: + {{- range $key, $value := .Values.resources.requests }} + {{ $key }}: {{ $value }} + {{- end }} + {{- end }} +{{end}} diff --git a/tests/TextTemplate.Tests/TestData/k8s-template-expected.yml b/benchmarks/go/testdata/complex-template-expected.yml similarity index 100% rename from tests/TextTemplate.Tests/TestData/k8s-template-expected.yml rename to benchmarks/go/testdata/complex-template-expected.yml diff --git a/tests/TextTemplate.Tests/TestData/k8s-template-ingress.yml b/benchmarks/go/testdata/complex-template-ingress.yml similarity index 100% rename from tests/TextTemplate.Tests/TestData/k8s-template-ingress.yml rename to benchmarks/go/testdata/complex-template-ingress.yml diff --git a/tests/TextTemplate.Tests/TestData/k8s-template-service.yml b/benchmarks/go/testdata/complex-template-service.yml similarity index 100% rename from tests/TextTemplate.Tests/TestData/k8s-template-service.yml rename to benchmarks/go/testdata/complex-template-service.yml diff --git a/docs/README.md b/docs/README.md index 9e1f6a5..6eb17cb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -213,6 +213,27 @@ The model contains five strings in the `Items` list so every engine performs a s | DotLiquid | 13.79 us | 0.27 us | 0.28 us | | Go text/template | 1.69 us | 0.00 us | 0.00 us | +### Advanced Scenario Benchmarks + +The benchmark suite also includes `ComplexNestedTemplateBenchmarks`. This test +loads the Kubernetes-style YAML templates found under `tests/TestData` and +executes them as a single nested template. Run all benchmarks with: + +```bash +dotnet run -c Release --project benchmarks/TextTemplate.Benchmarks -- --filter "*" +``` + +BenchmarkDotNet will then execute both the basic and advanced scenarios. + +Example results on a small container: + +| Method | Mean | Error | StdDev | +|-------|------:|------:|------:| +| GoTextTemplate_NET | 477.1 us | 371.94 us | 20.39 us | +| Handlebars | 47,455.5 us | 79,242.45 us | 4,343.55 us | +| Scriban | 202.1 us | 426.47 us | 23.38 us | +| DotLiquid | 467.4 us | 86.21 us | 4.73 us | + ## Claude's suggestions https://gist.github.com/yetanotherchris/c80d0fadb5a2ee5b4beb0a4384020dbf.js diff --git a/tests/TextTemplate.Tests/ComplexNestedTemplateData.cs b/tests/TextTemplate.Tests/ComplexNestedTemplateData.cs new file mode 100644 index 0000000..5fcd1a3 --- /dev/null +++ b/tests/TextTemplate.Tests/ComplexNestedTemplateData.cs @@ -0,0 +1,120 @@ +using System.Collections.Generic; + +namespace TextTemplate.Tests; + +public static class ComplexNestedTemplateData +{ + public static Dictionary Create() + { + return new Dictionary + { + ["Values"] = new Dictionary + { + ["appName"] = "my-web-app", + ["namespace"] = "production", + ["version"] = "\"1.2.3\"", + ["environment"] = "prod", + ["customLabels"] = new Dictionary + { + ["team"] = "backend", + ["cost-center"] = "engineering", + ["project"] = "web-platform" + }, + ["replicaCount"] = 5, + ["image"] = new Dictionary + { + ["repository"] = "myregistry.com/my-web-app", + ["tag"] = "v1.2.3" + }, + ["service"] = new Dictionary + { + ["enabled"] = true, + ["type"] = "LoadBalancer", + ["port"] = 80, + ["targetPort"] = 8080, + ["nodePort"] = 30080 + }, + ["env"] = new object[] + { + new Dictionary{{"name","DATABASE_URL"},{"value","postgresql://db.example.com:5432/myapp"}}, + new Dictionary{{"name","REDIS_HOST"},{"value","redis.example.com"}}, + new Dictionary{{"name","LOG_LEVEL"},{"value","info"}}, + new Dictionary{{"name","API_KEY"},{"value","secret-api-key-123"}} + }, + ["resources"] = new Dictionary + { + ["limits"] = new Dictionary + { + ["cpu"] = "\"1000m\"", + ["memory"] = "\"1Gi\"" + }, + ["requests"] = new Dictionary + { + ["cpu"] = "\"500m\"", + ["memory"] = "\"512Mi\"" + } + }, + ["volumeMounts"] = new object[] + { + new Dictionary{{"name","config-volume"},{"mountPath","/etc/config"},{"readOnly","true"}}, + new Dictionary{{"name","secret-volume"},{"mountPath","/etc/secrets"},{"readOnly","true"}}, + new Dictionary{{"name","data-volume"},{"mountPath","/var/data"}} + }, + ["volumes"] = new object[] + { + new Dictionary{{"name","config-volume"},{"configMap",new Dictionary{{"name","my-web-app-config"}}}}, + new Dictionary{{"name","secret-volume"},{"secret",new Dictionary{{"secretName","my-web-app-secrets"}}}}, + new Dictionary{{"name","data-volume"},{"persistentVolumeClaim",new Dictionary{{"claimName","my-web-app-data"}}}} + }, + ["nodeSelector"] = new Dictionary + { + ["kubernetes.io/os"] = "linux", + ["node-type"] = "web-tier" + }, + ["tolerations"] = new object[] + { + new Dictionary{{"key","\"node-type\""},{"operator","\"Equal\""},{"value","\"web-tier\""},{"effect","\"NoSchedule\""}}, + new Dictionary{{"key","\"dedicated\""},{"operator","\"Equal\""},{"value","\"web-app\""},{"effect","\"NoExecute\""}} + }, + ["ingress"] = new Dictionary + { + ["enabled"] = true, + ["annotations"] = new Dictionary + { + ["kubernetes.io/ingress.class"] = "nginx", + ["cert-manager.io/cluster-issuer"] = "letsencrypt-prod", + ["nginx.ingress.kubernetes.io/rewrite-target"] = "/" + }, + ["tls"] = new object[] + { + new Dictionary + { + ["hosts"] = new object[]{"myapp.example.com","api.myapp.example.com"}, + ["secretName"] = "myapp-tls-cert" + } + }, + ["hosts"] = new object[] + { + new Dictionary + { + ["host"] = "myapp.example.com", + ["paths"] = new object[] + { + new Dictionary{{"path","/"},{"pathType","Prefix"}}, + new Dictionary{{"path","/api"},{"pathType","Prefix"}} + } + }, + new Dictionary + { + ["host"] = "api.myapp.example.com", + ["paths"] = new object[] + { + new Dictionary{{"path","/"},{"pathType","Prefix"}} + } + } + } + } + } + }; + } +} diff --git a/tests/TextTemplate.Tests/TestData/complex-template-data.yml b/tests/TextTemplate.Tests/TestData/complex-template-data.yml new file mode 100644 index 0000000..248658b --- /dev/null +++ b/tests/TextTemplate.Tests/TestData/complex-template-data.yml @@ -0,0 +1,5 @@ +{{template "deployment" .}} +--- +{{template "service" .}} +--- +{{template "ingress" .}} diff --git a/tests/TextTemplate.Tests/TestData/complex-template-deployment.yml b/tests/TextTemplate.Tests/TestData/complex-template-deployment.yml new file mode 100644 index 0000000..20c48ac --- /dev/null +++ b/tests/TextTemplate.Tests/TestData/complex-template-deployment.yml @@ -0,0 +1,101 @@ +{{define "deployment"}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.appName }}-deployment + namespace: {{ .Values.namespace | default "default" }} + labels: + app: {{ .Values.appName }} + version: {{ .Values.version }} + {{- if .Values.environment }} + environment: {{ .Values.environment }} + {{- end }} + {{- range $key, $value := .Values.customLabels }} + {{ $key }}: {{ $value }} + {{- end }} +spec: + replicas: {{ .Values.replicaCount | default 3 }} + selector: + matchLabels: + app: {{ .Values.appName }} + template: + metadata: + labels: + app: {{ .Values.appName }} + version: {{ .Values.version }} + spec: + containers: + - name: {{ .Values.appName }} + image: {{ .Values.image.repository }}:{{ .Values.image.tag }} + ports: + - containerPort: {{ .Values.service.port }} + {{- if .Values.env }} + env: + {{- range .Values.env }} + - name: {{ .name }} + value: {{ .value | quote }} + {{- end }} + {{- end }} + {{- if .Values.resources }} + {{template "resources" .}} + {{- end }} + {{- if .Values.volumeMounts }} + volumeMounts: + {{- range .Values.volumeMounts }} + - name: {{ .name }} + mountPath: {{ .mountPath }} + {{- if .readOnly }} + readOnly: {{ .readOnly }} + {{- end }} + {{- end }} + {{- end }} + {{- if .Values.volumes }} + volumes: + {{- range .Values.volumes }} + - name: {{ .name }} + {{- if .configMap }} + configMap: + name: {{ .configMap.name }} + {{- else if .secret }} + secret: + secretName: {{ .secret.secretName }} + {{- else if .persistentVolumeClaim }} + persistentVolumeClaim: + claimName: {{ .persistentVolumeClaim.claimName }} + {{- end }} + {{- end }} + {{- end }} + {{- if .Values.nodeSelector }} + nodeSelector: + {{- range $key, $value := .Values.nodeSelector }} + {{ $key }}: {{ $value }} + {{- end }} + {{- end }} + {{- if .Values.tolerations }} + tolerations: + {{- range .Values.tolerations }} + - key: {{ .key }} + operator: {{ .operator | default "Equal" }} + {{- if .value }} + value: {{ .value }} + {{- end }} + effect: {{ .effect }} + {{- end }} + {{- end }} +{{end}} + +{{define "resources"}} + resources: + {{- if .Values.resources.limits }} + limits: + {{- range $key, $value := .Values.resources.limits }} + {{ $key }}: {{ $value }} + {{- end }} + {{- end }} + {{- if .Values.resources.requests }} + requests: + {{- range $key, $value := .Values.resources.requests }} + {{ $key }}: {{ $value }} + {{- end }} + {{- end }} +{{end}} diff --git a/tests/TextTemplate.Tests/TestData/single-k8s-expected.yml b/tests/TextTemplate.Tests/TestData/complex-template-expected.yml similarity index 100% rename from tests/TextTemplate.Tests/TestData/single-k8s-expected.yml rename to tests/TextTemplate.Tests/TestData/complex-template-expected.yml diff --git a/tests/TextTemplate.Tests/TestData/complex-template-ingress.yml b/tests/TextTemplate.Tests/TestData/complex-template-ingress.yml new file mode 100644 index 0000000..0b7ab35 --- /dev/null +++ b/tests/TextTemplate.Tests/TestData/complex-template-ingress.yml @@ -0,0 +1,41 @@ +{{define "ingress"}} +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ .Values.appName }}-ingress + namespace: {{ .Values.namespace | default "default" }} + {{- if .Values.ingress.annotations }} + annotations: + {{- range $key, $value := .Values.ingress.annotations }} + {{ $key }}: {{ $value | quote }} + {{- end }} + {{- end }} +spec: + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType | default "Prefix" }} + backend: + service: + name: {{ $.Values.appName }}-service + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} +{{end}} diff --git a/tests/TextTemplate.Tests/TestData/complex-template-service.yml b/tests/TextTemplate.Tests/TestData/complex-template-service.yml new file mode 100644 index 0000000..e05a9ef --- /dev/null +++ b/tests/TextTemplate.Tests/TestData/complex-template-service.yml @@ -0,0 +1,22 @@ +{{define "service"}} +{{- if .Values.service.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.appName }}-service + namespace: {{ .Values.namespace | default "default" }} + labels: + app: {{ .Values.appName }} +spec: + type: {{ .Values.service.type | default "ClusterIP" }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort | default .Values.service.port }} + protocol: TCP + {{- if .Values.service.nodePort }} + nodePort: {{ .Values.service.nodePort }} + {{- end }} + selector: + app: {{ .Values.appName }} +{{- end }} +{{end}} diff --git a/tests/TextTemplate.Tests/TestData/single-k8s-data.yml b/tests/TextTemplate.Tests/TestData/single-complex-data.yml similarity index 100% rename from tests/TextTemplate.Tests/TestData/single-k8s-data.yml rename to tests/TextTemplate.Tests/TestData/single-complex-data.yml diff --git a/tests/TextTemplate.Tests/TestData/single-complex-expected.yml b/tests/TextTemplate.Tests/TestData/single-complex-expected.yml new file mode 100644 index 0000000..9724686 --- /dev/null +++ b/tests/TextTemplate.Tests/TestData/single-complex-expected.yml @@ -0,0 +1,136 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-web-app-deployment + namespace: production + labels: + app: my-web-app + version: "1.2.3" + environment: prod + team: backend + cost-center: engineering + project: web-platform +spec: + replicas: 5 + selector: + matchLabels: + app: my-web-app + template: + metadata: + labels: + app: my-web-app + version: "1.2.3" + spec: + containers: + - name: my-web-app + image: myregistry.com/my-web-app:v1.2.3 + ports: + - containerPort: 80 + env: + - name: DATABASE_URL + value: "postgresql://db.example.com:5432/myapp" + - name: REDIS_HOST + value: "redis.example.com" + - name: LOG_LEVEL + value: "info" + - name: API_KEY + value: "secret-api-key-123" + resources: + limits: + cpu: "1000m" + memory: "1Gi" + requests: + cpu: "500m" + memory: "512Mi" + volumeMounts: + - name: config-volume + mountPath: /etc/config + readOnly: true + - name: secret-volume + mountPath: /etc/secrets + readOnly: true + - name: data-volume + mountPath: /var/data + volumes: + - name: config-volume + configMap: + name: my-web-app-config + - name: secret-volume + secret: + secretName: my-web-app-secrets + - name: data-volume + persistentVolumeClaim: + claimName: my-web-app-data + nodeSelector: + kubernetes.io/os: linux + node-type: web-tier + tolerations: + - key: "node-type" + operator: "Equal" + value: "web-tier" + effect: "NoSchedule" + - key: "dedicated" + operator: "Equal" + value: "web-app" + effect: "NoExecute" +--- +apiVersion: v1 +kind: Service +metadata: + name: my-web-app-service + namespace: production + labels: + app: my-web-app +spec: + type: LoadBalancer + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + nodePort: 30080 + selector: + app: my-web-app +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: my-web-app-ingress + namespace: production + annotations: + kubernetes.io/ingress.class: "nginx" + cert-manager.io/cluster-issuer: "letsencrypt-prod" + nginx.ingress.kubernetes.io/rewrite-target: "/" +spec: + tls: + - hosts: + - myapp.example.com + - api.myapp.example.com + secretName: myapp-tls-cert + rules: + - host: myapp.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: my-web-app-service + port: + number: 80 + - path: /api + pathType: Prefix + backend: + service: + name: my-web-app-service + port: + number: 80 + - host: api.myapp.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: my-web-app-service + port: + number: 80 diff --git a/tests/TextTemplate.Tests/TextTemplate.Tests.csproj b/tests/TextTemplate.Tests/TextTemplate.Tests.csproj index e976869..78242e6 100644 --- a/tests/TextTemplate.Tests/TextTemplate.Tests.csproj +++ b/tests/TextTemplate.Tests/TextTemplate.Tests.csproj @@ -37,25 +37,25 @@ PreserveNewest - + PreserveNewest - + PreserveNewest - + PreserveNewest - + PreserveNewest - + PreserveNewest - + PreserveNewest - + PreserveNewest diff --git a/tests/TextTemplate.Tests/YmlTemplateFileTest.cs b/tests/TextTemplate.Tests/YmlTemplateFileTest.cs index c1705b6..ee9af8e 100644 --- a/tests/TextTemplate.Tests/YmlTemplateFileTest.cs +++ b/tests/TextTemplate.Tests/YmlTemplateFileTest.cs @@ -29,122 +29,13 @@ private static string NormalizeYaml(string text) public void Template_YamlFile_RendersExpectedOutput() { string baseDir = AppContext.BaseDirectory; - string templatePath = Path.Combine(baseDir, "TestData", "single-k8s-data.yml"); - string expectedPath = Path.Combine(baseDir, "TestData", "single-k8s-expected.yml"); + string templatePath = Path.Combine(baseDir, "TestData", "single-complex-data.yml"); + string expectedPath = Path.Combine(baseDir, "TestData", "single-complex-expected.yml"); string template = File.ReadAllText(templatePath); string expected = File.ReadAllText(expectedPath); - var values = new Dictionary - { - ["Values"] = new Dictionary - { - ["appName"] = "my-web-app", - ["namespace"] = "production", - ["version"] = "\"1.2.3\"", - ["environment"] = "prod", - ["customLabels"] = new Dictionary - { - ["team"] = "backend", - ["cost-center"] = "engineering", - ["project"] = "web-platform" - }, - ["replicaCount"] = 5, - ["image"] = new Dictionary - { - ["repository"] = "myregistry.com/my-web-app", - ["tag"] = "v1.2.3" - }, - ["service"] = new Dictionary - { - ["enabled"] = true, - ["type"] = "LoadBalancer", - ["port"] = 80, - ["targetPort"] = 8080, - ["nodePort"] = 30080 - }, - ["env"] = new object[] - { - new Dictionary{{"name","DATABASE_URL"},{"value","postgresql://db.example.com:5432/myapp"}}, - new Dictionary{{"name","REDIS_HOST"},{"value","redis.example.com"}}, - new Dictionary{{"name","LOG_LEVEL"},{"value","info"}}, - new Dictionary{{"name","API_KEY"},{"value","secret-api-key-123"}} - }, - ["resources"] = new Dictionary - { - ["limits"] = new Dictionary - { - ["cpu"] = "\"1000m\"", - ["memory"] = "\"1Gi\"" - }, - ["requests"] = new Dictionary - { - ["cpu"] = "\"500m\"", - ["memory"] = "\"512Mi\"" - } - }, - ["volumeMounts"] = new object[] - { - new Dictionary{{"name","config-volume"},{"mountPath","/etc/config"},{"readOnly","true"}}, - new Dictionary{{"name","secret-volume"},{"mountPath","/etc/secrets"},{"readOnly","true"}}, - new Dictionary{{"name","data-volume"},{"mountPath","/var/data"}} - }, - ["volumes"] = new object[] - { - new Dictionary{{"name","config-volume"},{"configMap",new Dictionary{{"name","my-web-app-config"}}}}, - new Dictionary{{"name","secret-volume"},{"secret",new Dictionary{{"secretName","my-web-app-secrets"}}}}, - new Dictionary{{"name","data-volume"},{"persistentVolumeClaim",new Dictionary{{"claimName","my-web-app-data"}}}} - }, - ["nodeSelector"] = new Dictionary - { - ["kubernetes.io/os"] = "linux", - ["node-type"] = "web-tier" - }, - ["tolerations"] = new object[] - { - new Dictionary{{"key","\"node-type\""},{"operator","\"Equal\""},{"value","\"web-tier\""},{"effect","\"NoSchedule\""}}, - new Dictionary{{"key","\"dedicated\""},{"operator","\"Equal\""},{"value","\"web-app\""},{"effect","\"NoExecute\""}} - }, - ["ingress"] = new Dictionary - { - ["enabled"] = true, - ["annotations"] = new Dictionary - { - ["kubernetes.io/ingress.class"] = "nginx", - ["cert-manager.io/cluster-issuer"] = "letsencrypt-prod", - ["nginx.ingress.kubernetes.io/rewrite-target"] = "/" - }, - ["tls"] = new object[] - { - new Dictionary - { - ["hosts"] = new object[]{"myapp.example.com","api.myapp.example.com"}, - ["secretName"] = "myapp-tls-cert" - } - }, - ["hosts"] = new object[] - { - new Dictionary - { - ["host"] = "myapp.example.com", - ["paths"] = new object[] - { - new Dictionary{{"path","/"},{"pathType","Prefix"}}, - new Dictionary{{"path","/api"},{"pathType","Prefix"}} - } - }, - new Dictionary - { - ["host"] = "api.myapp.example.com", - ["paths"] = new object[] - { - new Dictionary{{"path","/"},{"pathType","Prefix"}} - } - } - } - } - } - }; + var values = ComplexNestedTemplateData.Create(); string result = TemplateEngine.Process(template, values); @@ -158,125 +49,16 @@ public void Template_YamlFile_RendersExpectedOutput() public void Template_YamlFilesWithSubTemplates_RendersExpectedOutput() { string baseDir = AppContext.BaseDirectory; - string depPath = Path.Combine(baseDir, "TestData", "k8s-template-deployment.yml"); - string svcPath = Path.Combine(baseDir, "TestData", "k8s-template-service.yml"); - string ingPath = Path.Combine(baseDir, "TestData", "k8s-template-ingress.yml"); - string rootPath = Path.Combine(baseDir, "TestData", "k8s-template-data.yml"); - string expectedPath = Path.Combine(baseDir, "TestData", "k8s-template-expected.yml"); + string depPath = Path.Combine(baseDir, "TestData", "complex-template-deployment.yml"); + string svcPath = Path.Combine(baseDir, "TestData", "complex-template-service.yml"); + string ingPath = Path.Combine(baseDir, "TestData", "complex-template-ingress.yml"); + string rootPath = Path.Combine(baseDir, "TestData", "complex-template-data.yml"); + string expectedPath = Path.Combine(baseDir, "TestData", "complex-template-expected.yml"); - var tmpl = Template.New("k8s").ParseFiles(depPath, svcPath, ingPath, rootPath); + var tmpl = Template.New("complex").ParseFiles(depPath, svcPath, ingPath, rootPath); string expected = File.ReadAllText(expectedPath); - var values = new Dictionary - { - ["Values"] = new Dictionary - { - ["appName"] = "my-web-app", - ["namespace"] = "production", - ["version"] = "\"1.2.3\"", - ["environment"] = "prod", - ["customLabels"] = new Dictionary - { - ["team"] = "backend", - ["cost-center"] = "engineering", - ["project"] = "web-platform" - }, - ["replicaCount"] = 5, - ["image"] = new Dictionary - { - ["repository"] = "myregistry.com/my-web-app", - ["tag"] = "v1.2.3" - }, - ["service"] = new Dictionary - { - ["enabled"] = true, - ["type"] = "LoadBalancer", - ["port"] = 80, - ["targetPort"] = 8080, - ["nodePort"] = 30080 - }, - ["env"] = new object[] - { - new Dictionary{{"name","DATABASE_URL"},{"value","postgresql://db.example.com:5432/myapp"}}, - new Dictionary{{"name","REDIS_HOST"},{"value","redis.example.com"}}, - new Dictionary{{"name","LOG_LEVEL"},{"value","info"}}, - new Dictionary{{"name","API_KEY"},{"value","secret-api-key-123"}} - }, - ["resources"] = new Dictionary - { - ["limits"] = new Dictionary - { - ["cpu"] = "\"1000m\"", - ["memory"] = "\"1Gi\"" - }, - ["requests"] = new Dictionary - { - ["cpu"] = "\"500m\"", - ["memory"] = "\"512Mi\"" - } - }, - ["volumeMounts"] = new object[] - { - new Dictionary{{"name","config-volume"},{"mountPath","/etc/config"},{"readOnly","true"}}, - new Dictionary{{"name","secret-volume"},{"mountPath","/etc/secrets"},{"readOnly","true"}}, - new Dictionary{{"name","data-volume"},{"mountPath","/var/data"}} - }, - ["volumes"] = new object[] - { - new Dictionary{{"name","config-volume"},{"configMap",new Dictionary{{"name","my-web-app-config"}}}}, - new Dictionary{{"name","secret-volume"},{"secret",new Dictionary{{"secretName","my-web-app-secrets"}}}}, - new Dictionary{{"name","data-volume"},{"persistentVolumeClaim",new Dictionary{{"claimName","my-web-app-data"}}}} - }, - ["nodeSelector"] = new Dictionary - { - ["kubernetes.io/os"] = "linux", - ["node-type"] = "web-tier" - }, - ["tolerations"] = new object[] - { - new Dictionary{{"key","\"node-type\""},{"operator","\"Equal\""},{"value","\"web-tier\""},{"effect","\"NoSchedule\""}}, - new Dictionary{{"key","\"dedicated\""},{"operator","\"Equal\""},{"value","\"web-app\""},{"effect","\"NoExecute\""}} - }, - ["ingress"] = new Dictionary - { - ["enabled"] = true, - ["annotations"] = new Dictionary - { - ["kubernetes.io/ingress.class"] = "nginx", - ["cert-manager.io/cluster-issuer"] = "letsencrypt-prod", - ["nginx.ingress.kubernetes.io/rewrite-target"] = "/" - }, - ["tls"] = new object[] - { - new Dictionary - { - ["hosts"] = new object[]{"myapp.example.com","api.myapp.example.com"}, - ["secretName"] = "myapp-tls-cert" - } - }, - ["hosts"] = new object[] - { - new Dictionary - { - ["host"] = "myapp.example.com", - ["paths"] = new object[] - { - new Dictionary{{"path","/"},{"pathType","Prefix"}}, - new Dictionary{{"path","/api"},{"pathType","Prefix"}} - } - }, - new Dictionary - { - ["host"] = "api.myapp.example.com", - ["paths"] = new object[] - { - new Dictionary{{"path","/"},{"pathType","Prefix"}} - } - } - } - } - } - }; + var values = ComplexNestedTemplateData.Create(); string result = tmpl.Execute(values); string normalizedResult = NormalizeYaml(result); From 1f9c0c0d0a7197a9ea357518deb87489cb270ac0 Mon Sep 17 00:00:00 2001 From: "Chris S." Date: Sun, 15 Jun 2025 15:42:44 +0100 Subject: [PATCH 2/2] Add Go benchmark for complex scenario and update docs --- benchmarks/go/template_test.go | 216 +++++++++++++++++---------------- docs/README.md | 10 +- 2 files changed, 121 insertions(+), 105 deletions(-) diff --git a/benchmarks/go/template_test.go b/benchmarks/go/template_test.go index b9d2189..e292231 100644 --- a/benchmarks/go/template_test.go +++ b/benchmarks/go/template_test.go @@ -1,126 +1,136 @@ package main import ( - "testing" - "text/template" - "bytes" + "bytes" + "fmt" + "reflect" + "strconv" + "testing" + "text/template" ) var goTmpl = template.Must(template.New("t").Parse("Hello {{ .Name }}! {{ range .Items }}{{ . }} {{ end }}")) var goData = map[string]any{ - "Name": "Bob", - "Items": []string{"one", "two", "three", "four", "five"}, + "Name": "Bob", + "Items": []string{"one", "two", "three", "four", "five"}, } -var complexTmpl = template.New("complex") +var complexTmpl = template.New("complex").Funcs(template.FuncMap{ + "default": func(def, val interface{}) interface{} { + if val == nil { + return def + } + rv := reflect.ValueOf(val) + if rv.Kind() == reflect.Ptr && rv.IsNil() { + return def + } + if rv.IsZero() { + return def + } + return val + }, + "quote": func(v interface{}) string { + if v == nil { + return "\"\"" + } + return strconv.Quote(fmt.Sprintf("%v", v)) + }, +}) func init() { - base := "testdata/" - template.Must(complexTmpl.ParseFiles(base+"complex-template-deployment.yml", base+"complex-template-service.yml", base+"complex-template-ingress.yml", base+"complex-template-data.yml")) + base := "testdata/" + template.Must(complexTmpl.ParseFiles(base+"complex-template-deployment.yml", base+"complex-template-service.yml", base+"complex-template-ingress.yml", base+"complex-template-data.yml")) } func BenchmarkGoTextTemplate(b *testing.B) { - for i := 0; i < b.N; i++ { - var buf bytes.Buffer - goTmpl.Execute(&buf, goData) - _ = buf.String() - } + for i := 0; i < b.N; i++ { + var buf bytes.Buffer + goTmpl.Execute(&buf, goData) + _ = buf.String() + } } func BenchmarkGoComplexTemplate(b *testing.B) { - data := map[string]any{"Values": complexData()} - for i := 0; i < b.N; i++ { - var buf bytes.Buffer - complexTmpl.Execute(&buf, data) - _ = buf.String() - } -} - - -func BenchmarkGoComplexTemplate(b *testing.B) { - values := map[string]any{ - "Values": complexData(), - } - for i := 0; i < b.N; i++ { - var buf bytes.Buffer - complexTmpl.Execute(&buf, values) - _ = buf.String() - } + values := map[string]any{"Values": complexData()} + for i := 0; i < b.N; i++ { + var buf bytes.Buffer + complexTmpl.Execute(&buf, values) + _ = buf.String() + } } // complexData returns the same nested dictionary used by the C# benchmarks. // It is constructed inline so the benchmark does not rely on file I/O. func complexData() map[string]any { - return map[string]any{ - "appName": "my-web-app", - "namespace": "production", - "version": "\"1.2.3\"", - "environment": "prod", - "customLabels": map[string]any{ - "team": "backend", - "cost-center": "engineering", - "project": "web-platform", - }, - "replicaCount": 5, - "image": map[string]any{ - "repository": "myregistry.com/my-web-app", - "tag": "v1.2.3", - }, - "service": map[string]any{ - "enabled": true, - "type": "LoadBalancer", - "port": 80, - "targetPort": 8080, - "nodePort": 30080, - }, - "env": []map[string]any{ - {"name": "DATABASE_URL", "value": "postgresql://db.example.com:5432/myapp"}, - {"name": "REDIS_HOST", "value": "redis.example.com"}, - {"name": "LOG_LEVEL", "value": "info"}, - {"name": "API_KEY", "value": "secret-api-key-123"}, - }, - "resources": map[string]any{ - "limits": map[string]any{"cpu": "\"1000m\"", "memory": "\"1Gi\""}, - "requests": map[string]any{"cpu": "\"500m\"", "memory": "\"512Mi\""}, - }, - "volumeMounts": []map[string]any{ - {"name": "config-volume", "mountPath": "/etc/config", "readOnly": true}, - {"name": "secret-volume", "mountPath": "/etc/secrets", "readOnly": true}, - {"name": "data-volume", "mountPath": "/var/data"}, - }, - "volumes": []map[string]any{ - {"name": "config-volume", "configMap": map[string]any{"name": "my-web-app-config"}}, - {"name": "secret-volume", "secret": map[string]any{"secretName": "my-web-app-secrets"}}, - {"name": "data-volume", "persistentVolumeClaim": map[string]any{"claimName": "my-web-app-data"}}, - }, - "nodeSelector": map[string]any{ - "kubernetes.io/os": "linux", - "node-type": "web-tier", - }, - "tolerations": []map[string]any{ - {"key": "\"node-type\"", "operator": "\"Equal\"", "value": "\"web-tier\"", "effect": "\"NoSchedule\""}, - {"key": "\"dedicated\"", "operator": "\"Equal\"", "value": "\"web-app\"", "effect": "\"NoExecute\""}, - }, - "ingress": map[string]any{ - "enabled": true, - "annotations": map[string]any{ - "kubernetes.io/ingress.class": "nginx", - "cert-manager.io/cluster-issuer": "letsencrypt-prod", - "nginx.ingress.kubernetes.io/rewrite-target": "/", - }, - "tls": []map[string]any{ - {"hosts": []any{"myapp.example.com", "api.myapp.example.com"}, "secretName": "myapp-tls-cert"}, - }, - "hosts": []map[string]any{ - {"host": "myapp.example.com", "paths": []map[string]any{ - {"path": "/", "pathType": "Prefix"}, - {"path": "/api", "pathType": "Prefix"}, - }}, - {"host": "api.myapp.example.com", "paths": []map[string]any{ - {"path": "/", "pathType": "Prefix"}, - }}, - }, - }, - } + return map[string]any{ + "appName": "my-web-app", + "namespace": "production", + "version": "\"1.2.3\"", + "environment": "prod", + "customLabels": map[string]any{ + "team": "backend", + "cost-center": "engineering", + "project": "web-platform", + }, + "replicaCount": 5, + "image": map[string]any{ + "repository": "myregistry.com/my-web-app", + "tag": "v1.2.3", + }, + "service": map[string]any{ + "enabled": true, + "type": "LoadBalancer", + "port": 80, + "targetPort": 8080, + "nodePort": 30080, + }, + "env": []map[string]any{ + {"name": "DATABASE_URL", "value": "postgresql://db.example.com:5432/myapp"}, + {"name": "REDIS_HOST", "value": "redis.example.com"}, + {"name": "LOG_LEVEL", "value": "info"}, + {"name": "API_KEY", "value": "secret-api-key-123"}, + }, + "resources": map[string]any{ + "limits": map[string]any{"cpu": "\"1000m\"", "memory": "\"1Gi\""}, + "requests": map[string]any{"cpu": "\"500m\"", "memory": "\"512Mi\""}, + }, + "volumeMounts": []map[string]any{ + {"name": "config-volume", "mountPath": "/etc/config", "readOnly": true}, + {"name": "secret-volume", "mountPath": "/etc/secrets", "readOnly": true}, + {"name": "data-volume", "mountPath": "/var/data"}, + }, + "volumes": []map[string]any{ + {"name": "config-volume", "configMap": map[string]any{"name": "my-web-app-config"}}, + {"name": "secret-volume", "secret": map[string]any{"secretName": "my-web-app-secrets"}}, + {"name": "data-volume", "persistentVolumeClaim": map[string]any{"claimName": "my-web-app-data"}}, + }, + "nodeSelector": map[string]any{ + "kubernetes.io/os": "linux", + "node-type": "web-tier", + }, + "tolerations": []map[string]any{ + {"key": "\"node-type\"", "operator": "\"Equal\"", "value": "\"web-tier\"", "effect": "\"NoSchedule\""}, + {"key": "\"dedicated\"", "operator": "\"Equal\"", "value": "\"web-app\"", "effect": "\"NoExecute\""}, + }, + "ingress": map[string]any{ + "enabled": true, + "annotations": map[string]any{ + "kubernetes.io/ingress.class": "nginx", + "cert-manager.io/cluster-issuer": "letsencrypt-prod", + "nginx.ingress.kubernetes.io/rewrite-target": "/", + }, + "tls": []map[string]any{ + {"hosts": []any{"myapp.example.com", "api.myapp.example.com"}, "secretName": "myapp-tls-cert"}, + }, + "hosts": []map[string]any{ + {"host": "myapp.example.com", "paths": []map[string]any{ + {"path": "/", "pathType": "Prefix"}, + {"path": "/api", "pathType": "Prefix"}, + }}, + {"host": "api.myapp.example.com", "paths": []map[string]any{ + {"path": "/", "pathType": "Prefix"}, + }}, + }, + }, + } } - diff --git a/docs/README.md b/docs/README.md index 6eb17cb..f5a7258 100644 --- a/docs/README.md +++ b/docs/README.md @@ -217,13 +217,18 @@ The model contains five strings in the `Items` list so every engine performs a s The benchmark suite also includes `ComplexNestedTemplateBenchmarks`. This test loads the Kubernetes-style YAML templates found under `tests/TestData` and -executes them as a single nested template. Run all benchmarks with: +executes them as a single nested template. Run the .NET benchmarks with: ```bash dotnet run -c Release --project benchmarks/TextTemplate.Benchmarks -- --filter "*" ``` -BenchmarkDotNet will then execute both the basic and advanced scenarios. +BenchmarkDotNet will then execute both the basic and advanced scenarios. The Go +implementation can be benchmarked separately with: + +```bash +go test -bench BenchmarkGoComplexTemplate ./benchmarks/go -benchmem +``` Example results on a small container: @@ -233,6 +238,7 @@ Example results on a small container: | Handlebars | 47,455.5 us | 79,242.45 us | 4,343.55 us | | Scriban | 202.1 us | 426.47 us | 23.38 us | | DotLiquid | 467.4 us | 86.21 us | 4.73 us | +| Go text/template | 0.79 us | 0.00 us | 0.00 us | ## Claude's suggestions https://gist.github.com/yetanotherchris/c80d0fadb5a2ee5b4beb0a4384020dbf.js