template
الوثائق الرسمية: template package - text/template - Go Packages
في العادة نستخدم غالباً دالة fmt.Sprintf لتنسيق السلاسل، لكنها مناسبة فقط للتعامل مع سلاسل صغيرة، وتحتاج لاستخدام أفعال التنسيق لتحديد النوع، ولا يمكن تسمية المعاملات، ولا تدعم التعامل مع الحالات المعقدة، وهذا هو ما يحل محرك القوالب. على سبيل المثال، صفحات HTML الثابتة المربوطة مباشرة بالخادم تحتاج لمحرك قوالب. توجد في المجتمع العديد من مكتبات محركات القوالب الخارجية الممتازة، مثل pongo2، sprig، jet، لكن ما سنتحدث عنه هنا هو محرك القوالب المدمج في Go text/template، وفي التطوير الفعلي يُستخدم عادةً html/template، الأخير مبني على الأول وقام بالكثير من المعالجات الأمنية المتعلقة بـ HTML، يكفي استخدام الأول في الحالات العامة، أما إذا كان الأمر يتعلق بمعالجة قوالب HTML فيُنصح باستخدام الأخير ليكون أكثر أماناً.
البدء السريع
لنلقِ نظرة على مثال بسيط لاستخدام محرك القوالب، كالتالي
package main
import (
"fmt"
"os"
"text/template"
)
func main() {
tmpl := `This is the first template string, {{ .message }}`
te, err := template.New("texTmpl").Parse(tmpl)
if err != nil {
fmt.Println(err)
return
}
data := map[string]any{
"message": "hello world!",
}
execErr := te.Execute(os.Stdout, data)
if execErr != nil {
fmt.Println(err)
}
}إخراج الكود أعلاه هو
This is the first template string, hello world!في الكود المثال، tmpl هو سلسلة قالب، و{{ .message }} في السلسلة هو معامل القالب في محرك القوالب. أولاً من خلال الطريقة *Template.Parse لتحليل سلسلة القالب،
func (t *Template) Parse(text string) (*Template, error)بعد نجاح التحليل، من خلال الطريقة *Template.Execute لتطبيق البيانات data على القالب، وأخيراً الإخراج إلى Writer المُمرر وهو os.Stdout.
func (t *Template) Execute(wr io.Writer, data any) errorفي استخدام محرك القوالب مستقبلاً، الأساس هو هذه الخطوات الثلاث:
- الحصول على القالب
- تحليل القالب
- تطبيق البيانات على القالب
يُرى أن استخدام محرك القوالب بسيط جداً، الأكثر تعقيداً قليلاً هو بناء جملة القالب لمحرك القوالب، وهذا هو المحتوى الرئيسي الذي سيتم شرحه في هذا المقال.
بناء جملة القالب
المعاملات
من خلال زوجين من الأقواس المتعرجة {{ }}، للإشارة في القالب إلى أن هذا معامل قالب، ومن خلال . للإشارة إلى الكائن الجذر، والكائن الجذر هو data المُمرر. مثل الوصول لمتغير عضو في نوع، من خلال الرمز . واسم المتغير للوصول للقيمة المقابلة في القالب، مثلاً
{{ .data }}بشرط وجود متغير عضو بنفس الاسم، وإلا سيحدث خطأ. بالنسبة لـ data المُمرر، عادة هي هيكل أو map، ويمكن أن يكون أيضاً نوع أساسي، مثل الأرقام والسلاسل، وهنا . يمثل الكائن الجذر وهو نفسه. داخل الأقواس، ليس بالضرورة الوصول للكائن الجذر للحصول على القيم، بل يمكن أن يكون أيضاً قيمة حرفية من النوع الأساسي، مثلاً
{{ 1 }}
{{ 3.14 }}
{{ "jack" }}مهما كان النوع، في النهاية ستُمرر جميعاً عبر fmt.Sprintf("%s", val) للحصول على شكلها النصي، انظر المثال التالي.
func main() {
out := os.Stdout
tmpl := "data-> {{ . }}\n"
datas := []any{
"hello world!",
6379,
3.1415926,
[]any{1, "2*2", 3.6},
map[string]any{"data": "hello world!"},
struct {
Data string
}{Data: "hello world!"},
}
for _, data := range datas {
err := ExecTmpl(out, tmpl, data)
if err != nil {
panic(err)
}
}
}
func ExecTmpl(writer io.Writer, tmpl string, data any) error {
parsedTmpl, err := template.New("template").Parse(tmpl)
if err != nil {
return err
}
return parsedTmpl.Execute(writer, data)
}الإخراج كالتالي
data-> hello world!
data-> 6379
data-> 3.1415926
data-> [1 2*2 3.6]
data-> map[data:hello world!]
data-> {hello world!}يُرى أن شكل الإخراج مطابق لاستخدام fmt.Sprintf مباشرة. بالنسبة للهياكل و map، يمكن الوصول لقيمها من خلال اسم الحقل، كالتالي
func main() {
out := os.Stdout
tmpl := "data-> {{ .Data }}\n"
datas := []any{
map[string]any{"Data": "hello world!"},
struct {
Data string
}{Data: "hello world!"},
}
for _, data := range datas {
err := ExecTmpl(out, tmpl, data)
if err != nil {
panic(err)
}
}
}الإخراج كالتالي
data-> hello world!
data-> hello world!بالنسبة للشرائح و map، رغم أنه لم يُقدم بناء خاص للوصول لقيمة فهرس معين، لكن يمكن تحقيق ذلك من خلال استدعاء دالة، كالتالي
func main() {
out := os.Stdout
tmpl := "data-> {{ index . 1}}\n"
datas := []any{
[]any{"first", "second"},
map[int]any{1: "first"},
}
for _, data := range datas {
err := ExecTmpl(out, tmpl, data)
if err != nil {
panic(err)
}
}
}الإخراج
data-> second
data-> firstإذا كانت شريحة متعددة الأبعاد، يمكن الوصول لقيمة الفهرس المقابل بالطريقة التالية، تكافئ s[i][j][k]
{{ index . i j k }}بالنسبة للهياكل أو map المتداخلة، يمكن استخدام .k1.k2.k3 للوصول، مثلاً
{{ .person.father.name }}عند استخدام معاملات القالب، يمكن إضافة رمز - قبل المعامل وبعده لإزالة الفراغ قبله وبعده، انظر المثال
func main() {
out := os.Stdout
tmpl := `{{ .x }} {{ - .op - }} {{ .y }}`
datas := []any{
map[string]any{"x": "10", "op": ">", "y": "2"},
}
for _, data := range datas {
err := ExecTmpl(out, tmpl, data)
if err != nil {
panic(err)
}
}
}بشكل طبيعي يجب أن تكون نتيجة الإخراج 10 > 2، لكن بسبب إضافة رمز - قبل وبعد معامل op، سيُزال الفراغ قبله وبعده، فالإخراج الفعلي هو
10>2يجب الانتباه، داخل الأقواس، يجب أن يُفصل رمز - عن المعامل بمسافة واحدة، أي يجب أن يكون بالشكل {{- . -}}، في المثال السبب في إضافة مسافة إضافية على الجانبين وكتابته بالشكل {{ - . - }} هو رغبتي الشخصية في أنه يبدو أجمل، في الواقع لا يوجد هذا القيد البنائي.
التعليقات
يدعم بناء القالب التعليقات، التعليقات لا تُنشأ في القالب النهائي، وبناؤها كالتالي
{{/* this is a comment */}}رموز التعليق /* و */ يجب أن تكون ملاصقة للأقواس المتعرجة، ولا يمكن أن يكون بينها أي أحرف أخرى، وإلا لن يتم التحليل بشكل صحيح. هناك حالة واحدة فقط تُستثنى، وهي عند إزالة الفراغ
{{- /* this is a comment */ -}}المتغيرات
يمكن أيضاً تعريف متغيرات في القالب، من خلال رمز $ للإشارة إلى أن هذا متغير، ومن خلال := للإسناد، تماماً مثل كود Go، المثال كالتالي.
{{ $name := .Name }}
{{ $val := index . 1 }}
{{ $val := index .dict key }}
// إسناد عدد صحيح
{{ $numer := 1 }}
// إسناد عدد فاصلة عائمة
{{ $float := 1.234}}
// إسناد سلسلة
{{ $name := "jack" }}عند الاستخدام لاحقاً، من خلال $ واسم المتغير للوصول لقيمة هذا المتغير، مثلاً
func main() {
out := os.Stdout
tmpl := `{{ $name := .name }} {{- $name }}`
datas := []any{
map[string]any{"name": "jack"},
}
for _, data := range datas {
err := ExecTmpl(out, tmpl, data)
if err != nil {
panic(err)
}
}
}الإخراج
jackيجب تعريف المتغير أولاً قبل استخدامه، وإلا سيُشير إلى undefined variable، ويجب أن يكون أيضاً داخل النطاق ليتم استخدامه.
الدوال
بناء القالب نفسه ليس كثيراً، ومعظم الوظائف تتحقق من خلال الدوال، شكل استدعاء الدالة هو اسم الدالة متبوعاً بقائمة المعاملات، بمسافة كفاصل، كالتالي
{{ funcname arg1 arg2 arg3 ... }}مثلاً الدالة index المستخدمة سابقاً
{{ index .s 1 }}الدالة eq للمقارنة بين المساواة
{{ eq 1 2 }}كل *Template لديه FuncsMap، لتسجيل تعيين الدوال
type FuncMap map[string]anyعند إنشاء القالب يُحصل على تعيين الدوال الافتراضي من text/template.builtins، فيما يلي جميع الدوال المدمجة
| اسم الدالة | الوظيفة | المثال |
|---|---|---|
and | عملية AND | {{ and true false }} |
or | عملية OR | {{ or true false }} |
not | عملية NOT | {{ not true }} |
eq | هل يساوي | {{ eq 1 2 }} |
ne | هل لا يساوي | {{ ne 1 2 }} |
lt | أقل من | {{ lt 1 2 }} |
le | أقل من أو يساوي | {{ le 1 2 }} |
gt | أكبر من | {{ gt 1 2 }} |
ge | أكبر من أو يساوي | {{ ge 1 2 }} |
len | إرجاع الطول | {{ len .slice }} |
index | الحصول على العنصر بالفهرس المحدد للهدف | {{ index . 0 }} |
slice | شريحة، تكافئ s[1:2:3] | {{ slice . 1 2 3 }} |
html | تحويل HTML | {{ html .name }} |
js | تحويل js | {{ js .name }} |
print | fmt.Sprint | {{ print . }} |
printf | fmt.Sprintf | {{ printf "%s" .}} |
println | fmt.Sprintln | {{ println . }} |
urlquery | تحويل url query | {{ urlquery .query }} |
بالإضافة إلى هذه، توجد أيضاً دالة مدمجة خاصة call، تُستخدم لاستدعاء الدوال مباشرة من خلال data المُمرر أثناء Execute، مثلاً القالب التالي
{{ call .string 1024 }}البيانات المُمررة كالتالي
map[string]any{
"string": func(val any) string { return fmt.Sprintf("%v: 2048", val) },
}عندئذ في القالب سيُنشأ
1024: 2048هذه إحدى طرق تعريف الدوال المخصصة، لكن يُنصح عادةً باستخدام الطريقة *Template.Funcs لإضافة دوال مخصصة، لأن الأخيرة يمكن أن تعمل بشكل عام، ولا تحتاج للربط بالكائن الجذر.
func (t *Template) Funcs(funcMap FuncMap) *Templateعادة ما يكون للدالة المخصصة قيمتان عائدة، الأولى هي القيمة المطلوب استخدامها، والثانية هي error. مثلاً الدالة المخصصة التالية
template.FuncMap{
"add": func(val any) (string, error) { return fmt.Sprintf("%v+1", val), nil },
}ثم استخدامها مباشرة في القالب
{{ add 1024 }}نتيجتها
1024 + 1الأنبوب
هذا الأنبوب مختلف عن chan، في الوثائق الرسمية يُسمى pipeline، أي عملية يمكن أن تُنتج بيانات تُسمى pipeline. العمليات القالبية التالية كلها عمليات أنبوب
{{ 1 }}
{{ eq 1 2 }}
{{ $name }}
{{ .name }}من يعرف Linux يعرف عامل الأنبوب |، القالب يدعم أيضاً هذه الكتابة. عملية الأنبوب تظهر كثيراً في القوالب، مثلاً
{{ $name := 1 }}{{ $name | print | printf "%s+1=?" }}نتيجتها
1+1=?في with و if و range التالية ستُستخدم أيضاً كثيراً.
with
من خلال جملة with يمكن التحكم في نطاق المتغيرات والكائن الجذر، الشكل كالتالي
{{ with pipeline }}
text
{{ end }}سيتحقق with من القيمة التي تُرجعها عملية الأنبوب، إذا كانت القيمة فارغة فلن يُنشأ القالب text الأوسط. إذا كنت تريد التعامل مع حالة الفراغ، يمكنك استخدام with else، الشكل كالتالي
{{ with pipeline }}
text1
{{ else }}
text2
{{ end }}إذا كانت القيمة التي تُرجعها عملية الأنبوب فارغة، فسيتم تنفيذ المنطق في else. المتغيرات المُعرّفة داخل جملة with، نطاقها محدود داخل جملة with فقط، انظر المثال التالي
{{ $name := "mike" }}
{{ with $name := "jack" }}
{{- $name -}}
{{ end }}
{{- $name -}}إخراجه كالتالي، وبوضوح هذا بسبب اختلاف النطاق، فهما متغيران مختلفان.
jackmikeمن خلال جملة with يمكن أيضاً إعادة كتابة الكائن الجذر داخل النطاق، كالتالي
{{ with .name }}
name: {{- .second }}-{{ .first -}}
{{ end }}
age: {{ .age }}
address: {{ .address }}تمرير البيانات التالية
map[string]any{
"name": map[string]any{
"first": "jack",
"second": "bob",
},
"age": 1,
"address": "usa",
}إخراجه
name:bob-jack
age: 1
address: usaيُرى أن داخل جملة with، الكائن الجذر . قد أصبح .name.
الشرط
شكل جملة الشرط كالتالي
{{ if pipeline }}
text1
{{ else if pipeline }}
text2
{{ else }}
text3
{{ end }}تماماً مثل كتابة كود عادي، سهل الفهم جداً. لنرَ أمثلة بسيطة،
{{ if eq .lang "en" }}
{{- .content.en -}}
{{ else if eq .lang "zh" }}
{{- .content.zh -}}
{{ else }}
{{- .content.fallback -}}
{{ end }}البيانات المُمررة
map[string]any{
"lang": "zh",
"content": map[string]any{
"en": "hello, world!",
"zh": "你好,世界!",
"fallback": "hello, world!",
},
}القالب في المثال يقرر طريقة عرض المحتوى حسب اللغة المُمررة lang، نتيجة الإخراج
你好,世界!التكرار
شكل جملة التكرار كالتالي، يجب أن يكون pipeline الذي يدعمه range مصفوفة، أو شريحة، أو map، أو قناة.
{{ range pipeline }}
loop body
{{ end }}بالاقتران مع else، عندما يكون الطول 0، سيتم تنفيذ محتوى else.
{{ range pipeline }}
loop body
{{ else }}
fallback
{{ end }}بالإضافة إلى ذلك، يدعم أيضاً عمليات مثل break، continue، مثلاً
{{ range pipeline }}
{{ if pipeline }}
{{ break }}
{{ end }}
{{ if pipeline }}
{{ continue }}
{{ end }}
loop body
{{ end }}لنرَ مثالاً على التكرار.
{{ range $index, $val := . }}
{{- if eq $index 0 }}
{{- continue -}}
{{ end -}}
{{- $index}}: {{ $val }}
{{ end }}تمرير البيانات
[]any{1, "2", 3.14},الإخراج
1: 2
2: 3.14تكرار map مماثل أيضاً.
التداخل
يمكن تعريف عدة قوالب في قالب واحد، مثلاً
{{ define "t1" }} t1 {{ end }}
{{ define "t2" }} t2 {{ end }}هذه القوالب المُعرّفة لن تُنشأ في القالب النهائي، إلا إذا تم تحديد الاسم عند التحميل أو من خلال جملة template يدوياً.
func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) errorمثلاً المثال التالي
{{ define "t1" }}
{{- with .t1 }}
{{- .data -}}
{{ end -}}
{{ end }}
{{ define "t2" }}
{{- with .t2 }}
{{- .data -}}
{{ end}}
{{ end -}}تمرير البيانات التالية
map[string]any{
"t1": map[string]any{"data": "template body 1"},
"t2": map[string]any{"data": "template body 2"},
}الكود
func main() {
out := os.Stdout
tmpl :=
`{{ define "t1" }}
{{- with .t1 }}
{{- .data -}}
{{ end -}}
{{ end }}
{{ define "t2" }}
{{- with .t2 }}
{{- .data -}}
{{ end}}
{{ end -}}`
datas := []any{
map[string]any{
"t1": map[string]any{"data": "template body 1"},
"t2": map[string]any{"data": "template body 2"},
},
}
name := "t1"
for _, data := range datas {
err := ExecTmpl(out, tmpl, name, data)
if err != nil {
panic(err)
}
}
}
func ExecTmpl(writer io.Writer, tmpl string, name string, data any) error {
t := template.New("template")
parsedTmpl, err := t.Parse(tmpl)
if err != nil {
return err
}
return parsedTmpl.ExecuteTemplate(writer, name, data)
}الإخراج
template body 1أو يمكن أيضاً تحديد القالب يدوياً
{{ define "t1" }}
{{- with .t1 }}
{{- .data -}}
{{ end -}}
{{ end }}
{{ define "t2" }}
{{- with .t2 }}
{{- .data -}}
{{ end}}
{{ end -}}
{{ template "t2" .}}عندئذ عند التحليل سواء تم تحديد اسم القالب أم لا، سيتم تحميل t2.
الربط
القالب الفرعي هو مجرد تعريف عدة قوالب مسماة داخل قالب واحد، أما الربط فهو ربط عدة *Template مسماة خارجية. ثم من خلال جملة template للإشارة للقالب المحدد.
{{ tempalte "templateName" pipeline}}يمكن لـ pipeline تحديد كائن جذر القالب المربوط حسب حاجتك، أو يمكن أيضاً تمرير كائن جذر القالب الحالي مباشرة. لنرَ مثال الكود التالي
func main() {
tmpl1 := `name: {{ .name }}`
tmpl2 := `age: {{ .age }}`
tmpl3 := `Person Info
{{template "t1" .}}
{{template "t2" .}}`
t1, err := template.New("t1").Parse(tmpl1)
if err != nil {
panic(err)
}
t2, err := template.New("t2").Parse(tmpl2)
if err != nil {
panic(err)
}
t3, err := template.New("t3").Parse(tmpl3)
if err != nil {
panic(err)
}
if err := associate(t3, t1, t2); err != nil {
panic(err)
}
err = t3.Execute(os.Stdout, map[string]any{
"name": "jack",
"age": 18,
})
if err != nil {
panic(err)
}
}
func associate(t *template.Template, ts ...*template.Template) error {
for _, tt := range ts {
_, err := t.AddParseTree(tt.Name(), tt.Tree)
if err != nil {
return err
}
}
return nil
}في الكود أعلاه، t3 مربوط بـ t1 و t2، باستخدام الطريقة *Template.AddParseTree للربط
func (t *Template) AddParseTree(name string, tree *parse.Tree) (*Template, error)نتيجة إنشاء القالب النهائي هي
Person Info
name: jack
age: 18الفتحة
من خلال جملة block، يمكن تحقيق تأثير مشابه لفتحة Vue، والهدف هو إعادة استخدام قالب معين. لنرَ حالة استخدام لنعرف كيفية الاستخدام، في القالب t1 نُعرّف الفتحة
Basic Person Info
name: {{ .name }}
age: {{ .age }}
address: {{ .address }}
{{ block "slot" . }} default content body {{ end }}يمكن لجملة block أن تحتوي على محتوى افتراضي للفتحة، عند استخدام القوالب الأخرى للفتحة لاحقاً، سيُستبدل المحتوى الافتراضي. في القالب t2 نشير للقالب t1، ونستخدم define لتعريف المحتوى المُدرج
{{ template "person.txt" . }}
{{ define "slot" }}
school: {{ .school }}
{{ end }}بعد ربط القالبين، نمرر البيانات التالية
map[string]any{
"name": "jack",
"age": 18,
"address": "usa",
"company": "google",
"school": "mit",
}الإخراج النهائي هو
Basic Person Info
name: jack
age: 18
address: usa
school: mitملفات القوالب
في أمثلة بناء القالب، استخدمنا قيماً حرفية للسلاسل كقوالب، وفي حالات الاستخدام الفعلية معظم القوالب توضع في ملفات.
func ParseFS(fsys fs.FS, patterns ...string) (*Template, error)مثلاً template.ParseFs تحمّل القوالب التي تطابق pattern من نظام الملفات المحدد. المثال التالي يستخدم embed.FS كنظام ملفات، نُعدّ ثلاثة ملفات
# person.txt
Basic Person Info
name: {{ .name }}
age: {{ .age }}
address: {{ .address }}
{{ block "slot" . }} {{ end }}
# student.txt
{{ template "person.txt" . }}
{{ define "slot" }}
school: {{ .school }}
{{ end }}
# employee.txt
{{ template "person.txt" . }}
{{ define "slot" }}
company: {{ .company }}
{{ end }}الكود كالتالي
import (
"embed"
"os"
"text/template"
)
//go:embed *.txt
var fs embed.FS
func main() {
data := map[string]any{
"name": "jack",
"age": 18,
"address": "usa",
"company": "google",
"school": "mit",
}
t1, err := template.ParseFS(fs, "person.txt", "student.txt")
if err != nil {
panic(err)
}
t1.Execute(os.Stdout, data)
t2, err := template.ParseFS(fs, "person.txt", "employee.txt")
if err != nil {
panic(err)
}
t2.Execute(os.Stdout, data)
}الإخراج هو
Basic Person Info
name: jack
age: 18
address: usa
school: mit
Basic Person Info
name: jack
age: 18
address: usa
company: googleهذه حالة استخدام بسيطة لملفات القوالب، person.txt كملف فتحة، والملفان الآخران يعيدان استخدام محتواه ويُدرجان محتوى جديد مخصص. يمكن أيضاً استخدام الدالتين التاليتين
func ParseGlob(pattern string) (*Template, error)
func ParseFiles(filenames ...string) (*Template, error)ParseGlob تعتمد على مطابقة النمط، وParseFiles تعتمد على اسم الملف، وكلاهما يستخدم نظام الملفات المحلي. إذا كان لعرض ملفات html في الواجهة الأمامية، يُنصح باستخدام حزمة html/template، فهي توفر واجهة برمجة تطبيقات مطابقة تماماً لـ text/template، لكنها قامت بمعالجات أمنية لـ html، css، js.
