07 在被管理节点上创建文件或目录
1. 修改文件并将其复制到主机
1.1 描述文件模块
Files模块库包含的模块允许用户完成与Linux文件管理相关的大多数任务,如创建、复制、编辑和修改文件的权限和其他属性。下表提供了常用文件管理模块的列表:
常用文件模块
模块名称 | 模块说明 |
---|---|
blockinfile | 插入、更新或删除由可自定义标记线包围的多行文本块 |
copy | 将文件从本地或远程计算机复制到受管主机上的某个位置。 类似于file模块,copy模块还可以设置文件属性,包括SELinux上下文件。 |
fetch | 此模块的作用和copy模块类似,但以相反方式工作。此模块用于从远程计算机获取文件到控制节点, 并将它们存储在按主机名组织的文件树中。 |
file | 设置权限、所有权、SELinux上下文以及常规文件、符号链接、硬链接和目录的时间戳等属性。 此模块还可以创建或删除常规文件、符号链接、硬链接和目录。其他多个与文件相关的 模块支持与file模块相同的属性设置选项,包括copy模块。 |
lineinfile | 确保特定行位于某文件中,或使用反向引用正则表达式来替换现有行。 此模块主要在用户想要更改文件的某一行时使用。 |
stat | 检索文件的状态信息,类似于Linux中的stat命令。 |
synchronize | 围绕rsync命令的一个打包程序,可加快和简化常见任务。 synchronize模块无法提供对rsync命令的完整功能的访问权限,但确实最常见的调用更容易实施。 用户可能仍需通过run command模块直接调用rsync命令。 |
1.2 files模块的自动化示例
在受管主机上创建、复制、编辑和删除文件是用户可以使用Files模块库中的模块实施的常见任务。
以下示例显示了可以使用这些模块自动执行常见文件管理任务的方式。
1.2.1 确保受管主机上存在文件
使用file模块处理受管主机上的文件。其工作方式与touch命令类似,如果不存在则创建一个空文件,如果存在,则更新其修改时间。在本例中,除了处理文件之外,Ansible还确保将文件的所有者、组和权限设置为特定值。
- name: Touch a file and set permissions file: path: /path/to/file # 路径 owner: user1 # 拥有者 group: group1 # 组 mode: 0640 # 权限 state: touch # 状态创建
1.2.2 修改文件属性
使用file模块还可以确保新的或现有的文件具有正确的权限和SELinux类型。
例如,以下文件保留了相对于用户主目录的默认SELinux上下文,这不是所需的上下文
[root@localhost ~]# ls -Z samba_file # 查看这个文件类型
- rw-r--r-- owner group unconfined_u:object_r:user_home_t:s0 samba_file
以下任务确保了anaconda-ks.cfg文件的SELinux上下文件类型属性是所需的samba_share_t类型。此行为与Linux中的chcon命令类似。
- name: SELinux type is set to samba_share_t file: path: /path/to/samba_file setype: samba_share_t # 通过setype修改文件类型
示例结果:
[root@localhost ~]# ls -Z samba_file # 再来查看类型修改
- rw-r--r-- owner group unconfined_u:object_r:samba_share_t:s0 samba_file
文件属性参数在多个文件管理模块中可用。运行ansible-doc file和ansible-doc copy命令以获取其他信息。
1.2.3 使SELinux文件上下文更改具有持久性
设置文件上下文时,file模块的行为与chcon类似。通过运行restorecon,可能会意外地撤消使用该模块所做的更改。使用file设置上下文后,用户可以使用system模块集合中的sefcontext来更新SELinux策略,如semanage fcontext。
[root@web01 ~]# ls -Z anaconda-ks.cfg # 查看这个文件的类型 system_u:object_r:admin_home_t:s0 anaconda-ks.cfg [root@localhost httpd]# vim test1.yml --- - hosts: all gather_facts: no tasks: - name: print info sefcontext: # 模块 target: /root/anaconda-ks.cfg # 目标 setype: samba_share_t # 修改的类行 state: present # 状态现有的 - name: shengxiao command: restorecon -irv /root/anaconda-ks.cfg # 让这个文件生效 [root@localhost httpd]# ansible-playbook test1.yml # 运行 PLAY [all] ***************************************************************************************************** TASK [print info] ********************************************************************************************** ok: [web01.example.com] TASK [shengxiao] *********************************************************************************************** changed: [web01.example.com] PLAY RECAP ***************************************************************************************************** web01.example.com : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 [root@web01 ~]# ls -Z anaconda-ks.cfg # 查看受管主机的这个文件类型已经更改 system_u:object_r:samba_share_t:s0 anaconda-ks.cfg
注意:sefcontext模块更新SELinux策略中目标的默认上下文,但不更改现有文件的上下文。
1.2.4 在受管主机上复制和编辑文件
在此示例中,copy模块用于将位于控制节点上的Ansible工作目录中的文件复制到选定的受管主机。
默认情况下,此模块假定设置了force: yes。这会强制该模块覆盖远程文件(如果存在但包含与正在复制的文件不同的内容)。如果设置force: no,则它仅会将该文件复制到受管主机(如果该文件尚不存在)。
[root@localhost httpd]# ls ! ansible.cfg files group_vars host_vars install.yml inventory test1.yml test.yml vars [root@localhost httpd]# ls files/ # 想把第一个文件放到受控主机tmp下面去 CentOS-Base.repo game hosts.j2 httpd-vhosts.conf test.j2 [root@localhost httpd]# vim test1.yml --- - hosts: all gather_facts: no tasks: - name: print info copy: # 用copy模块 src: files/CentOS-Base.repo # 源文件位子 dest: /tmp/ # 目标位子 [root@localhost httpd]# ansible-playbook test1.yml PLAY [all] ***************************************************************************************************** TASK [print info] ********************************************************************************************** changed: [web01.example.com] PLAY RECAP ***************************************************************************************************** web01.example.com : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 [root@web01 ~]# ls /tmp/ CentOS-Base.repo #受控主机上就有了 [root@web01 ~]# cat /tmp/CentOS-Base.repo # CentOS-Base.repo # # The mirror system uses the connecting IP address of the client and the # 以下省略。。。 [root@localhost httpd]# vim files/CentOS-Base.repo # hehe # 第一行前面加入内容 # CentOS-Base.repo [root@localhost httpd]# ansible-playbook test1.yml # 执行 [root@web01 ~]# ll /tmp/ 总用量 12 -rw-r--r--. 1 root root 1660 6月 12 16:30 CentOS-Base.repo # 如果文件没有发生改变不做任何事,文件发生改变才会复制过去
要从受管主机检索文件,请使用fetch模块。这可用于在将参考系统分发给其他受管主机之前从参考系统中检查诸如SSH公钥之类的文件。
[root@web01 ~]# ls anaconda-ks.cfg test.sh # 受管主机有个脚本 [root@localhost httpd]# ls /tmp/ # 控制主机上没,现在把脚本传过来 systemd-private-89bd133a0742460c8c462c95a83ef968-chronyd.service-5CBVwi vmware-root_996-2991071970 --- - hosts: all gather_facts: no tasks: - name: print info fetch: # 模块 src: /root/test.sh # 源位子文件 dest: /tmp/test.sh #目标位子 [root@localhost httpd]# ansible-playbook test1.yml PLAY [all] ****************************************************************************************** TASK [print info] *********************************************************************************** changed: [web01.example.com] PLAY RECAP ****************************************************************************************** web01.example.com : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 [root@localhost httpd]# ls /tmp/ systemd-private-89bd133a0742460c8c462c95a83ef968-chronyd.service-5CBVwi vmware-root_996-2991071970 test.sh # 执行之后查看就有了,但是一个目录,它把整个结构都传过来了,
要确保现有文件中存在特定的单行文本,请使用lineinfile模块:
- name: Add a line of text to a file lineinfile: path: /path/to/file line: 'Add this line to the file' state: present
要将文本块添加到现有文件,请使用blockinfile模块:
[root@web01 tmp]# cat abc This is my item: one n This is my item: two # 源文件时这个样子 [root@localhost httpd]# vim test1.yml --- - hosts: all gather_facts: no tasks: - name: blockinfile practice blockinfile: path: /tmp/abc state: present block: | # 加两行 hello world hello tom [root@localhost httpd]# ansible-playbook test1.yml # 执行 [root@web01 tmp]# cat abc This is my item: one n This is my item: two # BEGIN ANSIBLE MANAGED BLOCK # 加进去以后加的内容被包裹有注释 hello world hello tom # END ANSIBLE MANAGED BLOCK
注意:使用blockinfile模块时,注释块标记插入到块的开头和结尾,以确保幂等性。
[root@web01 tmp]# cat /tmp/abc # 源文件时这样的 This is my item: one This is my item: two # BEGIN ANSIBLE MANAGED BLOCK hello world dwsdfwef wefwe fwefwefwe wfwe fwef hello tom # END ANSIBLE MANAGED BLOCK [root@localhost httpd]# vim test1.yml --- - hosts: all gather_facts: no tasks: - name: blockinfile practice blockinfile: path: /tmp/abc state: present block: | hello world # 确保有这3行 hello tom hello jerry [root@localhost httpd]# ansible-playbook test1.yml [root@web01 tmp]# cat /tmp/abc This is my item: one This is my item: two # BEGIN ANSIBLE MANAGED BLOCK hello world # 只有这3行其他的都被删除了 ,它只会修改被包裹起来的,确保幂等性 hello tom hello jerry # END ANSIBLE MANAGED BLOCK
用户可以使用该模块的marker参数,帮助确保将正确的注释字符或文本用于相关文件。
1.2.5 从受管主机中删除文件
从受管主机中删除文件的基本示例是使用file模块和state: absent参数。state参数对于许多模块是可选的。一些模块也支持其他选项。
[root@web01 ~]# ls /tmp/ abc # 一个文件 hehe # 一个目录 [root@localhost httpd]# vim test1.yml --- - hosts: all gather_facts: no tasks: - name: shanchu file: # 模块 path: /tmp/abc # 位子 state: absent # 状态删除 [root@localhost httpd]# ansible-playbook test1.yml [root@web01 ~]# ls /tmp/ # abc被删除 hehe [root@localhost httpd]# vim test1.yml --- - hosts: all gather_facts: no tasks: - name: shanchu file: # file模块文件和目录都可以删除 path: /tmp/hehe # 换成hehe目录 state: absent [root@localhost httpd]# ansible-playbook test1.yml [root@web01 ~]# ls /tmp/ # 查看hehe目录也被删除,
1.2.6 检索受管主机上的文件状态
stat模块检索文件的事实,类似于Linux中的stat命令。参数提供检索文件属性、确定文件检验和等功能。
stat模块返回一个包含文件状态数据的值的散列字典,允许用户使用单独的变量引用各条信息。
以下示例注册stat模块的结果,然后显示它检查的文件的MD5检验和。
- name: Verify the checksum of a file stat: # 模块 path: /path/to/file # 文件的位子 checksum_algorithm: md5 # 检查md5值 register: result # 所得的值注册一个变量 - debug # 打印 msg: "The checksum of the file is {{ result.stat.checksum }}"
有关stat模块返回的值的信息由ansible-doc记录,或者可以注册一个变量并显示其内容以查看可用内容:
- name: Examine all stat output of /etc/passwd hosts: 172.16.103.129 tasks: - name: stat /etc/passwd stat: path: /etc/passwd register: results - name: Display stat results debug: var: results
1.2.7 同步控制节点和受管主机之间的文件
synchronize模块是一个围绕rsync工具的打包程序,它简化了playbook中的常见文件管理任务。rsync工具必须同时安装在本机和远程主机上。默认情况下,在使用synchronize模块时,“本地主机”是同步任务所源自的主机(通常是控制节点),而“目标主机”是synchronize连接到的主机。
以下示例将位于Ansible工作目录中的文件同步到受管主机:
- name: synchronize local file to remote files
synchronize:
src: file
dest: /path/to/file
有很多种方法可以使用synchronize模块及其许多参数,包括同步目录。运行ansible-doc synchronize命令查看其他参数和playbook示例。
2. 使用jinja2模板部署自定义文件
2.1 jinja2简介
Ansible将jinja2模板系统用于模板文件。Ansible还使用jinja2语法来引用playbook中的变量。
变量和逻辑表达式置于标记或分隔符之间。例如,jinja2模板将{% EXPR %}用于表达式或逻辑(如循环),而{{ EXPR }}则用于向最终用户输出表达式或变量的结果。后一标记在呈现时将被替换为一个或多个值,对最终用户可见。使用{# COMMENT #}语法括起不应出现在最终文件中的注释。
在下例中,第一行中含有不会包含于最终文件中的注释。第二行中引用的变量被替换为所引用的系统事实的值。
[root@localhost httpd]# ls ! ansible.cfg files group_vars host_vars install.yml inventory test.yml vars [root@web01 ~]# cat /etc/hosts # 查看受管主机 127.0.0.1 localhost localhost.localdomain localhost4 localhost4.localdomain4 ::1 localhost localhost.localdomain localhost6 localhost6.localdomain6 [root@localhost httpd]# vim files/hosts.j2 # 写一个模板文件要加。j2一看就知道是模板 127.0.0.1 localhost localhost.localdomain localhost4 localhost4.localdomain4 ::1 localhost localhost.localdomain localhost6 localhost6.localdomain6 {{ ansible_facts['default_ipv4']['address'] }} {{ ansible_facts['hostname'] }} {{ ansible_facts['fqdn'] }} # 编写如下加入受管主机的ip,短名称,长名称 [root@localhost httpd]# vim test.yml # 编写一个playbook --- - hosts: all gather_facts: no tasks: - name: undate hosts # 更新hosts文件 copy: # 使用copy模块 src: files/hosts.j2 # 源文件的位子 dest: /etc/hosts #目标文件的位子 [root@localhost httpd]# ansible-playbook test.yml # 运行 PLAY [all] ************************************************************************************ TASK [undate hosts] *************************************************************************** changed: [web01.example.com] PLAY RECAP ************************************************************************************ web01.example.com : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 [root@web01 ~]# cat /etc/hosts 127.0.0.1 localhost localhost.localdomain localhost4 localhost4.localdomain4 ::1 localhost localhost.localdomain localhost6 localhost6.localdomain6 {{ ansible_facts['default_ipv4']['address'] }} {{ ansible_facts['hostname'] }} {{ ansible_facts['fqdn'] }} # 更新成功,但是没有取到相应的值
--- - hosts: all tasks: - name: update /etc/hosts template: #换成 template模块 src: files/hosts.j2 dest: /etc/hosts [root@localhost httpd]# ansible-playbook test.yml # 运行 PLAY [all] ************************************************************************************ TASK [Gathering Facts] ************************************************************************ ok: [web01.example.com] TASK [update /etc/hosts] ********************************************************************** changed: [web01.example.com] PLAY RECAP ************************************************************************************ web01.example.com : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 [root@web01 ~]# cat /etc/hosts 127.0.0.1 localhost localhost.localdomain localhost4 localhost4.localdomain4 ::1 localhost localhost.localdomain localhost6 localhost6.localdomain6 192.168.149.136 web01 web01.example.com # 引用的变量被替换为所引用的系统事实的值
2.2 构建jinja2模板
jinja2模板由多个元素组成:数据、变量和表达式。在呈现jinja2模板时,这些变量和表达式被替换为对应的值。模板中使用的变量可以在playbook的vars部分中指定。可以将受管主机的事实用作模板中的变量。
请记住,可以使用ansible system_hostname -i inventory_file -m setup命令来获取与受管主机相关的事实。
下例演示了如何使用变量及Ansible从受管主机检索的事实创建/etc/ssh/sshd_config的模板。当执行相关的playbook时,任何事实都将被替换为所配置的受管主机中对应的值。
注意:包含jinja2模板的文件不需要有任何特定的文件扩展名(如.j2)。但是,提供此类文件扩展名会让你更容易记住它是模板文件。
[root@localhost httpd]# vim files/test.j2 # 模板文件 # {{ ansible_managed }} jjyy 123 456 789 [root@localhost httpd]# vim test.yml --- - hosts: all tasks: - name: update template: src: files/test.j2 # 源文件 dest: /tmp/abc #目标位子 [root@localhost httpd]# ansible-playbook test.yml PLAY [all] ************************************************************************************ TASK [Gathering Facts] ************************************************************************ ok: [web01.example.com] TASK [update] ********************************************************************************* changed: [web01.example.com] PLAY RECAP ************************************************************************************ web01.example.com : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 [root@web01 tmp]# cat abc # 受管主机查看 # Ansible managed jjyy 123 456 789
2.3 部署jinja2模板
jinja2模板是功能强大的工具,可用于自定义要在受管主机上部署的配置文件。创建了适用于配置文件的jinja2模板后,它可以通过template模板部署到受管主机上,该模块支持将控制节点中的本地文件转移到受管主机。
若要使用template模块,请使用下列语法。与src键关联的值指定来源jinja2模板,而与dest键关联的值指定要在目标主机上创建的文件。
tasks: - name: template render template: src: /tmp/j2-template.j2 dest: /tmp/dest-config-file.txt
template模块还允许指定已部署文件的所有者、组、权限和SELINUX上下文,就像file模块一样。它也可以取用validate选项运行任意命令(如visudo -c),在将文件复制到位之前检查该文件的语法是否正确。
有关更多详细信息,请参阅ansible-doc template
2.4 管理模板文件
为避免系统管理员修改Ansible部署的文件,最好在模板顶部包含注释,以指示不应手动编辑该文件。
可使用ansible_managed指令中设置的"Ansible managed"字符串来执行此操作。这不是正常变量,但可以在模板中用作一个变量。ansible_managed指令在ansible.cfg文件中设置:
ansible_managed = Ansible managed
要将ansible_managed字符串包含在jinja2模板内,请使用下列语法:
{{ ansible_managed }}
2.5 控制结构
用户可以在模板文件中使用jinja2控制结构,以减少重复输入,为play中的每个主机动态输入条目,或者有条件地将文本插入到文件中。
2.5.1 使用循环
jinja2使用for语句来提供循环功能。在下例中,user变量替换为users变量中包含的所有值,一行一个值。
[root@localhost httpd]# vim files/test.j2 {% for user in users %} # 有一个变量叫users,循环这个变量,取出来打印 {{ user }} {% endfor %} [root@localhost httpd]# vim test.yml --- - hosts: all vars: # 定义一个变量,内容如下 users: - tom - jerry - zhangsan - lisi tasks: - name: update template: src: files/test.j2 dest: /tmp/abc [root@localhost httpd]# cat files/test.j2 {% for user in users %} {{ user }} # 这里只写了一行 {% endfor %} [root@localhost httpd]# ansible-playbook test.yml PLAY [all] ************************************************************************************ TASK [Gathering Facts] ************************************************************************ ok: [web01.example.com] TASK [update] ********************************************************************************* changed: [web01.example.com] PLAY RECAP ************************************************************************************ web01.example.com : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 [root@web01 ~]# cat /tmp/abc # 执行完以后查看引用了这里有4行, tom jerry zhangsan lisi
以下示例模板使用for语句逐一运行users变量中的所有值,将myuser替换为各个值,但值为root时除外。
[root@localhost httpd]# vim files/test.j2 {# for statement #} {% for myuser in users if not myuser == "root" %} # 如果myuser不等于root后面的循环执行 User number {{ loop.index }} - {{ myuser }} # {% endfor %} [root@localhost httpd]# vim test.yml --- - hosts: all vars: users: - tom - jerry - zhangsan - lisi - root # 加入root和wangwu - wangwu tasks: - name: update template: src: files/test.j2 dest: /tmp/abc [root@web01 ~]# cat /tmp/abc User number 1 - tom User number 2 - jerry User number 3 - zhangsan User number 4 - lisi User number 5 - wangwu
loop.index变量扩展至循环当前所处的索引号。它在循环第一次执行时值为1,每一次迭代递增1。
再如,此模板也使用了for语句,并且假定使用的清单文件中已定义了myhosts变量。此变量将包含要管理的主机的列表。使用下列for语句时,文件中将列出清单myhosts组内的所有主机。
{% for myhost in groups['myhosts'] %} {{ myhost }} {% endfor %}
举一个更实际的例子,用户可以使用该模板从主机事实动态生成/etc/hosts文件。假设playbook如下:
- name: /etc/hosts is up to date hosts: all gather_facts: yes tasks: - name: Deploy /etc/hosts template: src: templates/hosts.j2 dest: /etc/hosts
下述三行templates/hosts.j2模板从all组中的所有主机构造文件。(由于变量名称的长度,模板的中间行非常长。)它迭代组中的每个主机以获得/etc/hosts文件的三个事实。
{% for host in groups['all'] %} {{ hostvars['host']['ansible_facts']['default_ipv4']['address'] }} {{ hostvars['host']['ansible_facts']['fqdn'] }} {{ hostvars['host']['ansible_facts']['hostname'] }} {% endfor %}
2.5.2 使用条件句
jinja2使用if语句来提供条件控制。如果满足某些条件,这允许用户在已部署的文件中放置一行。
在以下示例中,仅当finished变量的值为True时,才可将result变量的值放入已部署的文件。
{% if finished %} {{ result }} {% endif %}
注意,在Ansible模板中我们可以使用jinja2循环和条件,但不能在Ansible Playbook中使用
2.5.3 变量过滤器
jinja2提供了过滤器,更改模板表达式的输出格式(例如,输出到果JSON)。有适用于YAML和JSON等语言的过滤器。to_json过滤器使用JSON格式化表达式输出,to_yaml过滤器则使用YAML格式化表达式输出。
{{ output | to_json }} # 把变量换成json格式
{{ output | to_yaml }} #把变量换成yaml格式,换一个格式显示
也有其他过滤器,如to_nice_json和to_nice_yaml过滤器,它们将表达式输出格式化为JSON或YAML等人类可读格式。
[root@localhost httpd]# vim test.yml --- - hosts: all vars: users: # 定义变量tom用户20岁80分,jerry用户19岁85分 用的yuml格式 tom: age: 20 score: 80 jerry: age: 19 score: 85 tasks: - name: update template: src: files/test.j2 dest: /tmp/abc [root@localhost httpd]# vim files/test.j2 # 只引用user {{ users }} [root@localhost httpd]# ansible-playbook test.yml [root@web01 ~]# cat /tmp/abc # 看到是这个样子,转换成json格式 {'tom': {'age': 20, 'score': 80}, 'jerry': {'age': 19, 'score': 85}} [root@localhost httpd]# vim files/test.j2 {{ users | to_nice_jion }} # 修改如下 [root@localhost httpd]# ansible-playbook test.yml [root@web01 ~]# cat /tmp/abc # nice_json显示的结构更清晰 { "jerry": { "age": 19, "score": 85 }, "tom": { "age": 20, "score": 80 } } [root@localhost httpd]# vim test.yml --- - hosts: all vars: users: {'tom': {'age': 20, 'score': 80}, 'jerry': {'age': 19, 'score': 85}} # json的格式编写 tasks: - name: update template: src: files/test.j2 dest: /tmp/abc [root@localhost httpd]# vim files/test.j2 {{ users | to_nice_yuml }} #修改成nice_yuml [root@localhost httpd]# ansible-playbook test.yml # 执行 [root@web01 ~]# cat /tmp/abc jerry: age: 19 score: 85 tom: age: 20 score: 80
from_json和from_yaml过滤器相应要求JSON或YAML格式的字符串,并对它们进行解析。
{{ output | from_json }}
{{ output | from_yaml }}
2.5.4 变量测试
在Ansible Playbook中与when子句一同使用的表达式是jinja2表达式。用于测试返回值的内置Ansible测试包括failed、changed、successded和skipped。以下任务演示了如何在条件表达式内使用测试。
tasks: ...output omitted... - debug: msg="the execution was aborted" when: returnvalue is failed
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通