1. 程式人生 > 其它 >Jinja2 教程 - 第 2 部分 - 迴圈和條件

Jinja2 教程 - 第 2 部分 - 迴圈和條件

歡迎來到我的 Jinja2 教程的第 2 部分。在第 1 部分中,我們瞭解了 Jinja2 是什麼,它的用途是什麼,並開始研究模板基礎知識。接下來是迴圈和條件語句,其中包含測試和大量示例!

控制結構

在 Jinja2 中,迴圈和條件被稱為控制結構,因為它們會影響程式的流程。{%控制結構使用由和%}字元包圍的塊。

迴圈

我們首先要看的結構是迴圈。

Jinja2 作為一種模板語言不需要廣泛的迴圈型別選擇,所以我們只得到for迴圈。

For 迴圈以 開頭{% for my_item in my_collection %}和結尾{% endfor %}這與您在 Python 中迴圈迭代的方式非常相似。

my_item是一個迴圈變數,它將在我們遍歷元素時獲取值。並且my_collection是持有對迭代集合的引用的變數的名稱。

在迴圈體內部,我們可以my_item在其他控制結構中使用變數,比如if條件,或者簡單地使用{{ my_item }}語句顯示它。

好的,但是你會在哪裡使用你問的迴圈?在您的模板中使用單個變數在大多數情況下都可以正常工作,但您可能會發現引入層次結構和迴圈將有助於抽象您的資料模型。

例如,字首列表或 ACL 由許多行組成。將這些行表示為單個變數是沒有意義的。

最初,您可以使用每行一個變數對特定字首列表進行建模,如下所示:

PL_AS_65003_IN_line1: "permit 10.96.0.0/24"
PL_AS_65003_IN_line2: "permit 10.97.11.0/24"
PL_AS_65003_IN_line3: "permit 10.99.15.0/24"
PL_AS_65003_IN_line4: "permit 10.100.5.0/25"
PL_AS_65003_IN_line5: "permit 10.100.6.128/25"

可以在以下模板中使用:

# Configuring Prefix List
ip prefix-list PL_AS_65003_IN
 {{ PL_AS_65003_IN_line1 }}
 {{ PL_AS_65003_IN_line2 }}
 {{ PL_AS_65003_IN_line3 }}
 {{ PL_AS_65003_IN_line4 }}
 {{ PL_AS_65003_IN_line5 }}

渲染結果:

# Configuring Prefix List
ip prefix-list PL_AS_65003_IN
 permit 10.96.0.0/24
 permit 10.97.11.0/24
 permit 10.99.15.0/24
 permit 10.100.5.0/25
 permit 10.100.6.128/25

這種方法雖然有效,但也存在一些問題。

如果我們想在字首列表中有更多行,我們必須建立另一個變數,然後再建立一個,以此類推。我們不僅必須將這些新專案新增到我們的資料結構中,模板還必須單獨包含所有這些新變數。這是不可維護的,消耗大量時間並且很容易出錯。

有一個更好的方法,考慮下面的資料結構:

PL_AS_65003_IN:
  - permit 10.96.0.0/24
  - permit 10.97.11.0/24
  - permit 10.99.15.0/24
  - permit 10.100.5.0/25
  - permit 10.100.6.128/25

以及模板渲染字首列表配置:

# Configuring Prefix List
ip prefix-list PL_AS_65003_IN
{%- for line in PL_AS_65003_IN %}
 {{ line -}}
{% endfor %}

 

渲染後:

# Configuring Prefix List
ip prefix-list PL_AS_65003_IN
 permit 10.96.0.0/24
 permit 10.97.11.0/24
 permit 10.99.15.0/24
 permit 10.100.5.0/25
 permit 10.100.6.128/25

 

如果您仔細觀察,您會發現這本質上是對同一事物進行建模,即帶有多個條目的字首列表。但是通過使用列表,我們清楚地說明了我們的意圖。即使在視覺上,您也可以立即看出所有縮排的行都屬於 PL_AS_65003_IN。

在這裡新增字首列表很簡單,我們只需要在塊中新增一個新行。此外,我們的模板根本不需要更改。如果我們使用迴圈來迭代,就像我們在這裡所做的那樣,遍歷這個列表,那麼如果我們重新執行渲染,新的行將被拾取。小小的改變,但讓事情變得容易多了。

您可能已經注意到這裡仍有改進的空間。字首列表的名稱在字首列表定義和我們的for迴圈中是硬編碼的。不要害怕,這是我們很快會改進的。

迴圈遍歷字典

現在讓我們看看如何遍歷字典。我們將再次使用for迴圈構造,記住,這就是我們所擁有的!

我們可以使用與迭代列表元素相同的語法,但這裡我們將迭代字典鍵要檢索分配給鍵的值,我們需要使用下標,即[]符號。

使用字典而不是列表的一個優點是我們可以使用元素的名稱作為參考,這使得檢索物件及其值變得更加容易。

假設我們使用 list 來表示我們的介面集合:

interfaces:
  - Ethernet1:
      description: leaf01-eth51
      ipv4_address: 10.50.0.0/31
  - Ethernet2:
      description: leaf02-eth51
      ipv4_address: 10.50.0.2/31

 

沒有簡單的方法來檢索Ethernet2條目。我們要麼必須遍歷所有元素並進行鍵名比較,要麼必須求助於高階過濾器。

需要注意的一件事(希望這一點越來越明顯)是我們需要花一些時間對資料進行建模,以便於使用。這是您在第一次嘗試時很少會做對的事情,所以不要害怕嘗試和迭代。

按照我們的示例,我們可以將資料儲存在分配給interfaces字典中鍵的各個介面上,而不是將它們放在列表中

interfaces:
  Ethernet1:
    description: leaf01-eth51
    ipv4_address: 10.50.0.0/31
  Ethernet2:
    description: leaf02-eth51
    ipv4_address: 10.50.0.2/31

 

現在我們可以像這樣在模板中訪問這些資料:

{% for intf in interfaces -%}
interface {{ intf }}
 description {{ interfaces[intf].description }}
 ip address {{ interfaces[intf].ipv4_address }}
{% endfor %}

 

給我們最終結果:

interface Ethernet1
 description leaf01-eth51
 ip address 10.50.0.0/31
interface Ethernet2
 description leaf02-eth51
 ip address 10.50.0.2/31

 

這裡intf指的是Ethernet1Ethernet2鍵。要訪問每個介面的屬性,我們需要使用interfaces[intf]符號。

還有另一種迭代字典的方法,我個人更喜歡。items()我們可以通過使用方法同時檢索鍵和它的值。

{% for iname, idata in interfaces.items() -%}
interface {{ iname }}
 description {{ idata.description }}
 ip address {{ idata.ipv4_address }}
{% endfor %}

 

最終結果是相同的,但通過使用items()方法,我們簡化了對屬性的訪問。如果您想遞迴地迭代深度巢狀的字典,這一點就變得尤為重要。

我還承諾展示如何改進字首列表示例,這就是它的items()用武之地。

我們通過使每個字首列表名稱成為字典中的鍵來對我們的資料結構進行小修改prefix_lists

prefix_lists:
  PL_AS_65003_IN:
    - permit 10.96.0.0/24
    - permit 10.97.11.0/24
    - permit 10.99.15.0/24
    - permit 10.100.5.0/25
    - permit 10.100.6.128/25

 

我們現在新增外迴圈迭代字典中的鍵值對:

# Configuring Prefix List
{% for pl_name, pl_lines in prefix_lists.items() -%}
ip prefix-list {{ pl_name }}
{%- for line in pl_lines %}
 {{ line -}}
{%  endfor -%}
{% endfor %}

 

渲染給我們同樣的結果:

# Configuring Prefix List
ip prefix-list PL_AS_65003_IN
 permit 10.96.0.0/24
 permit 10.97.11.0/24
 permit 10.99.15.0/24
 permit 10.100.5.0/25
 permit 10.100.6.128/25

 

在這裡,不再有對字首列表名稱的硬編碼引用!如果您需要另一個字首列表,您只需將其新增到字典中,它就會被我們的迴圈prefix_lists自動拾取。for

注意:如果您使用的是 Python < 3.6 的版本,則不訂購字典。這意味著您記錄資料的順序可能與模板內處理專案的順序不同。

如果您依賴於它們被記錄的順序,您應該collections.OrderedDict在 Python 指令碼中使用 Jinja2 時使用,或者您可以dictsort在模板中應用過濾器以按鍵或值對字典進行排序。

按鍵排序:

{% for k, v in my_dict | dictsort -%}

 

按值排序:

{% for k, v in my_dict | dictsort(by='value') -%}

 

這就結束了 Jinja2 模板中迴圈的基礎知識。上述用例應滿足您 95% 的需求。

如果您正在尋找與迴圈相關的一些高階功能的討論,請放心,我也會寫這些內容。我決定在本教程的最後幾章留下更深入的 Jinja2 主題,並專注於讓您更快地提高工作效率的核心內容。

條件和測試

現在我們已經完成了迴圈,是時候繼續討論條件了。

Jinja2 實現了一種條件語句,即if語句。對於分支,我們可以使用elifand else

Jinja2 中的條件可以以幾種不同的方式使用。現在,我們將看看一些用例以及它們如何與其他語言特性相結合。

比較

我們首先要看的是將值與條件進行比較,這些使用==, !=, >, >=, <, <=運算子。這些都是相當標準的,但我還是會展示一些例子。

使用比較的一種常見情況是根據已安裝作業系統的版本或供應商改變命令語法。例如,前段時間 Arista 由於訴訟而不得不更改一些命令,我​​們可以使用一個簡單的 if 語句來確保我們的模板適用於所有 EOS 版本:

使用 EOS 4.19 的主機模板、變數和渲染模板:

(venv) przemek@quasar:~/nauto/jinja/python$ python j2_render.py \
-t templates/eos-ver.j2 -f vars/eos-ver-419.yml -d yaml
###############################################################################
# Loaded template: templates/eos-ver.j2
###############################################################################

hostname {{ hostname }}
{% if eos_ver >= 4.22 -%}
Detected EOS ver {{ eos_ver }}, using new command syntax.
{% else -%}
Detected EOS ver {{ eos_ver }}, using old command syntax.
{% endif %}

###############################################################################
# Render variables
###############################################################################

eos_ver: 4.19
hostname: arista_old_eos

###############################################################################
# Rendered template
###############################################################################

hostname arista_old_eos
Detected EOS ver 4.19, using old command syntax.

 

執行 EOS 4.22 的裝置也是如此:

(venv) przemek@quasar:~/nauto/jinja/python$ python j2_render.py \
-t templates/eos-ver.j2 -f vars/eos-ver-422.yml -d yaml
###############################################################################
# Loaded template: templates/eos-ver.j2
###############################################################################

hostname {{ hostname }}
{% if eos_ver >= 4.22 -%}
Detected EOS ver {{ eos_ver }}, using new command syntax.
{% else -%}
Detected EOS ver {{ eos_ver }}, using old command syntax.
{% endif %}

###############################################################################
# Render variables
###############################################################################

eos_ver: 4.22
hostname: arista_new_eos

###############################################################################
# Rendered template
###############################################################################

hostname arista_new_eos
Detected EOS ver 4.22, using new command syntax.

 

非常簡單,但非常有用。我們所做的只是檢查記錄的 EOS 版本是否小於或大於/等於 4.22,這足以確保正確的語法使其進入配置。

為了通過比較顯示更復雜的分支,我在這裡提供了支援多種路由協議的模板示例,其中僅為每個裝置生成相關配置。

首先我們為主機定義一些資料。

執行 BGP 的裝置:

hostname: router-w-bgp
routing_protocol: bgp

interfaces:
  Loopback0: 
    ip: 10.0.0.1
    mask: 32

bgp:
  as: 65001

 

執行 OSPF 的裝置:

hostname: router-w-ospf
routing_protocol: ospf

interfaces:
  Loopback0:
    ip: 10.0.0.2
    mask: 32

ospf:
  pid: 1

 

僅具有預設路由的裝置:

hostname: router-w-defgw

interfaces:
  Ethernet1:
    ip: 10.10.0.10
    mask: 24

default_nh: 10.10.0.1

 

然後我們使用帶有分支的條件建立一個模板。可以根據需要輕鬆新增其他協議選擇。

hostname {{ hostname }}
ip routing

{% for intf, idata in interfaces.items() -%}
interface {{ intf }}
  ip address {{ idata.ip }}/{{ idata.mask }}
{%- endfor %}

{% if routing_protocol == 'bgp' -%}
router bgp {{ bgp.as }}
  router-id {{ interfaces.Loopback0.ip }}
  network {{ interfaces.Loopback0.ip }}/{{ interfaces.Loopback0.mask }}
{%- elif routing_protocol == 'ospf' -%}
router ospf {{ ospf.pid }}
  router-id {{ interfaces.Loopback0.ip }}
  network {{ interfaces.Loopback0.ip }}/{{ interfaces.Loopback0.mask }} area 0
{%- else -%}
  ip route 0.0.0.0/0 {{ default_nh }}
{%- endif %}

 

所有裝置的渲染結果:

hostname router-w-bgp
ip routing

interface Loopback0
  ip address 10.0.0.1/32

router bgp 65001
  router-id 10.0.0.1
  network 10.0.0.1/32
hostname router-w-ospf
ip routing

interface Loopback0
  ip address 10.0.0.2/32

router ospf 1
  router-id 10.0.0.2
  network 10.0.0.2/32 area 0
hostname router-w-defgw
ip routing

interface Ethernet1
  ip address 10.10.0.10/24

ip route 0.0.0.0/0 10.10.0.1

 

有了它,一個模板支援 3 種不同的配置選項,非常酷。

邏輯運算子

沒有邏輯運算子,條件的實現是不完整的。Jinja2 以

andor and not

形式提供這些ornot

這裡沒什麼好說的,所以這裡只是一個簡短的例子,展示了所有這些在行動中的作用:

(venv) przemek@quasar:~/nauto/jinja/python$ python j2_render.py \
-t templates/if-logic-ops.j2 -f vars/if-logic-ops.yml
###############################################################################
# Loaded template: templates/if-logic-ops.j2
###############################################################################

{% if x and y -%}
Both x and y are True. x: {{ x }}, y: {{ y }}
{%- endif %}

{% if x or z -%}
At least one of x and z is True. x: {{ x }}, z: {{ z }}
{%- endif %}

{% if not z -%}
We see that z is not True. z: {{ z }}
{%- endif %}

###############################################################################
# Render variables
###############################################################################

x: true
y: true
z: false

###############################################################################
# Rendered template
###############################################################################

Both x and y are True. x: True, y: True

At least one of x and z is True. x: True, z: False

We see that z is not True. z: False

真實性

這是檢視不同變數型別及其真實性的好地方。與 Python、字串、列表、字典等的情況一樣,如果變數不為空,則它們的計算結果為 True。對於空值,評估結果為 False。

我建立了一個示例來說明非空和空、字串、列表和字典的真實性:

(venv) przemek@quasar:~/nauto/jinja/python$ python j2_render.py \
-t templates/if-types-truth.j2 -f vars/if-types-truth.yml
###############################################################################
# Loaded template: templates/if-types-truth.j2
###############################################################################

{% macro bool_eval(value) -%}
{% if value -%}
True
{%- else -%}
False
{%- endif %}
{%- endmacro -%}

My one element list has bool value of: {{ bool_eval(my_list) }}
My one key dict has bool value of: {{ bool_eval(my_dict) }}
My short string has bool value of: {{ bool_eval(my_string) }}

My empty list has bool value of: {{ bool_eval(my_list_empty) }}
My empty dict has bool value of: {{ bool_eval(my_dict_empty) }}
My empty string has bool value of: {{ bool_eval(my_string_empty) }}

###############################################################################
# Render variables
###############################################################################

{
    "my_list": [
        "list-element"
    ],
    "my_dict": {
        "my_key": "my_value"
    },
    "my_string": "example string",
    "my_list_empty": [],
    "my_dict_empty": {},
    "my_string_empty": ""
}

###############################################################################
# Rendered template
###############################################################################

My one element list has bool value of: True
My one key dict has bool value of: True
My short string has bool value of: True

My empty list has bool value of: False
My empty dict has bool value of: False
My empty string has bool value of: False

 

我個人建議不要測試非布林型別的真實性。沒有多少情況下這可能有用,它可能會使您的意圖不明顯。如果您只是想檢查變數是否存在is defined,那麼我們將很快看到的測試通常是更好的選擇。

測試

Jinja2 中的測試與變數一起使用並返回 True 或 False,具體取決於值是否通過測試。要使用此功能is,請在變數後新增和測試名稱。

最有用的測試是defined我已經提到的。該測試僅檢查給定變數是否已定義,即渲染引擎是否可以在接收到的資料中找到它。

檢查是否定義了變數是我在大多數模板中使用的。請記住,預設情況下未定義的變數只會計算為空字串。通過檢查變數是否在其預期用途之前定義,您可以確保您的模板在渲染期間失敗。如果沒有這個測試,您可能會以不完整的文件結束,並且沒有跡象表明有什麼不對勁。

我發現方便的另一類測試用於檢查變數的型別。某些操作要求兩個運算元的型別相同,如果它們不是 Jinja2 將丟擲錯誤。這適用於比較數字或迭代列表和字典之類的事情。

boolean- 檢查變數是否為布林值
integer- 檢查變數是否為整數
float- 檢查變數是否為浮點數
number- 檢查變數是否為數字,整數和浮點數均返回 True
string- 檢查變數是否為字串
mapping- 檢查變數是否為對映, 即字典
iterable- 檢查變數是否可以迭代,將匹配字串、列表、字典等
sequence- 檢查變數是否為序列

以下是應用了這些測試的一些變數的示例:

(venv) przemek@quasar:~/nauto/jinja/python$ python j2_render.py \
-t templates/tests-type.j2 -f vars/tests-type.yml
###############################################################################
# Loaded template: templates/tests-type.j2
###############################################################################

{{ hostname }} is an iterable: {{ hostname is iterable }}
{{ hostname }} is a sequence: {{ hostname is sequence }}
{{ hostname }} is a string: {{ hostname is string }}

{{ eos_ver }} is a number: {{ eos_ver is number }}
{{ eos_ver }} is an integer: {{ eos_ver is integer }}
{{ eos_ver }} is a float: {{ eos_ver is float }}

{{ bgp_as }} is a number: {{ bgp_as is number }}
{{ bgp_as }} is an integer: {{ bgp_as is integer }}
{{ bgp_as }} is a float: {{ bgp_as is float }}

{{ interfaces }} is an iterable: {{ interfaces is iterable }}
{{ interfaces }} is a sequence: {{ interfaces is sequence }}
{{ interfaces }} is a mapping: {{ interfaces is mapping }}

{{ dns_servers }} is an iterable: {{ dns_servers is iterable }}
{{ dns_servers }} is a sequence: {{ dns_servers is sequence }}
{{ dns_servers }} is a mapping: {{ dns_servers is mapping }}

###############################################################################
# Render variables
###############################################################################

{
    "hostname": "sw-office-lon-01",
    "eos_ver": 4.22,
    "bgp_as": 65001,
    "interfaces": {
        "Ethernet1": "Uplink to core"
    },
    "dns_servers": [
        "1.1.1.1",
        "8.8.4.4",
        "8.8.8.8"
    ]
}

###############################################################################
# Rendered template
###############################################################################

sw-office-lon-01 is an iterable: True
sw-office-lon-01 is a sequence: True
sw-office-lon-01 is a string: True

4.22 is a number: True
4.22 is an integer: False
4.22 is a float: True

65001 is a number: True
65001 is an integer: True
65001 is a float: False

{'Ethernet1': 'Uplink to core'} is an iterable: True
{'Ethernet1': 'Uplink to core'} is a sequence: True
{'Ethernet1': 'Uplink to core'} is a mapping: True

['1.1.1.1', '8.8.4.4', '8.8.8.8'] is an iterable: True
['1.1.1.1', '8.8.4.4', '8.8.8.8'] is a sequence: True
['1.1.1.1', '8.8.4.4', '8.8.8.8'] is a mapping: False

 

您可能已經注意到,其中一些測試可能看起來有點模稜兩可。例如,要測試變數是否是列表,僅檢查它是序列還是可迭代是不夠的。字串也是序列和可迭代物件。字典也是如此,即使 vanilla Python 將它們分類為 Iterable 和 Mapping 而不是 Sequence:

>>> from collections.abc import Iterable, Sequence, Mapping
>>>
>>> interfaces = {"Ethernet1": "Uplink to core"}
>>>
>>> isinstance(interfaces, Iterable)
True
>>> isinstance(interfaces, Sequence)
False
>>> isinstance(interfaces, Mapping)
True

 

那麼這一切意味著什麼呢?好吧,我建議對每種型別的變數進行以下測試:

  • Number、Float、Integer - 這些都按預期工作,因此請選擇適合您用例的任何內容。

  • 字串 - 使用string測試就足夠了:

{{ my_string is string }}

  • 字典 - 使用mapping測試就足夠了:

{{ my_dict is mapping }}

  • 列表 - 這是一個艱難的,完整的檢查應該測試變數是否是一個序列,但同時它不能是一個對映或字串:

{{ my_list is sequence and my list is not mapping and my list is not string }}

在某些情況下,我們知道字典或字串不太可能出現,因此我們可以通過擺脫mappingstring測試來縮短檢查:

{{ my_list is sequence and my list is not string }}
{{ my_list is sequence and my list is not mapping }}

 

有關可用測試的完整列表,請點選參考資料中的連結。

環路濾波

我想簡要介紹的最後一件事是迴圈過濾和in運算子。

環路過濾正如其名稱所暗示的那樣。它允許您使用if帶有迴圈的語句for來跳過您不感興趣的元素。

例如,我們可以遍歷包含介面的字典並僅處理具有 IP 地址的介面:

(venv) przemek@quasar:~/nauto/jinja/python$ python j2_render.py \
-t templates/loop-filter.j2 -f vars/loop-filter.yml
###############################################################################
# Loaded template: templates/loop-filter.j2
###############################################################################

=== Interfaces with assigned IPv4 addresses ===

{% for intf, idata in interfaces.items() if idata.ipv4_address is defined -%}
{{ intf }} - {{ idata.description }}: {{ idata.ipv4_address }}
{% endfor %}

###############################################################################
# Render variables
###############################################################################

{
    "interfaces": {
        "Loopback0": {
            "description": "Management plane traffic",
            "ipv4_address": "10.255.255.34/32"
        },
        "Management1": {
            "description": "Management interface",
            "ipv4_address": "10.10.0.5/24"
        },
        "Ethernet1": {
            "description": "Span port - SPAN1"
        },
        "Ethernet2": {
            "description": "PortChannel50 - port 1"
        },
        "Ethernet51": {
            "description": "leaf01-eth51",
            "ipv4_address": "10.50.0.0/31"
        },
        "Ethernet52": {
            "description": "leaf02-eth51",
            "ipv4_address": "10.50.0.2/31"
        }
    }
}

###############################################################################
# Rendered template
###############################################################################

=== Interfaces with assigned IPv4 addresses ===

Loopback0 - Management plane traffic: 10.255.255.34/32
Management1 - Management interface: 10.10.0.5/24
Ethernet51 - leaf01-eth51: 10.50.0.0/31
Ethernet52 - leaf02-eth51: 10.50.0.2/31

 

如您所見,我們總共有 6 個介面,但其中只有 4 個分配了 IP 地址。is defined測試新增到迴圈中,我們過濾掉沒有 IP 地址的介面。

當迭代從裝置返回的大負載時,迴圈過濾可能特別強大。在某些情況下,您可以忽略大部分元素並專注於感興趣的事物。

In操作員

in放置在兩個值之間的運算子可用於檢查左側的值是否包含在右側的值中。您可以使用它來測試元素是否出現在列表中,或者鍵是否存在於字典中。

運算子的明顯用例in是檢查我們感興趣的東西是否僅存在於集合中,我們不一定需要檢索該專案。

檢視前面的示例,我們可以檢查 Loopback0 是否在列表介面中,如果是,我們將使用它來獲取管理平面資料包,如果不是,我們將使用 Management1 介面。

模板:

{% if 'Loopback0' in interfaces -%}
sflow source-interface Loopback0
snmp-server source-interface Loopback0
ip radius source-interface Loopback0
{%- else %}
sflow source-interface Management1
snmp-server source-interface Management1
ip radius source-interface Management1
{% endif %}

 

渲染結果:

sflow source-interface Loopback0
snmp-server source-interface Loopback0
ip radius source-interface Loopback0

 

請注意,即使interfaces是一個包含大量資料的字典,我們也沒有對其進行迭代或檢索任何鍵。我們只想知道Loopback0鑰匙的存在。

老實說,上面的模板可以進行一些調整,我們基本上覆制了 3 行配置和硬編碼的介面名稱。這不是一個很好的做法,我將在下一篇文章中向您展示我們如何在這裡進行改進。

至此,我們已經結束了 Jinja2 教程的第 2 部分。接下來我將介紹空格,以便您可以使您的文件看起來恰到好處,我們將繼續研究語言功能。我希望你在這裡學到了一些有用的東西,並且回來獲得更多!

參考