fabric 是什么
项目地址在: https://gitee.com/xiaofeipapa/fabric-tutorial
建议先下载代码, 边看边运行.
fabric 是python的一个组件, 能够在远程服务器运行你写好的脚本, 也就是说这是一个运维自动化工具.
以前运维人员在远程服务器上安装软件, 要先登录 -> 写脚本(如果习惯本地写程序, 那么还要上传程序到服务器) -> 针对服务器反馈输入数据 -> 调试 ..
如果用fabric, 这个流程就是全部自动化了. fabric的程序开发流程一般是这样:
- 先在远程服务器试验你的脚本/想法.
- 用python fabric 实现它们.
- 运行结果, 观察是否正确.
如果只需要操作一两台服务器, 那么手动操作肯定比写程序快. 如果要操作几十上百台机器, 那么当然是写好程序, 然后让他们自动在几十台服务器上面跑更方便. (程序员就是要学会偷懒! )
2021版教程说明
网上能找到的fabric 大多东摘西抄, 程序不能运行. 锅主要由fabric 来背, 一开始他们并不支持 python3, 所以当python3 流行之后, 以前针对 python2 的fabric教程就基本不能用了; 于是有热心程序员们搞了个非官方的fabric3 , 写了一些教程(很少且很多文章是错的), 但后来fabric 官方支持了 python3 , 也出了个教程....
于是在互联网上至少存在3种fabric 教程. 针对python2的, 针对非官方python3版本的, 还有官方python3 官方版本的, 初学者大概会被弄得要发疯了吧....
所以我将这份教程命名为2021版, 表示这是今年最新的版本. 同时这是针对官方python3版本的教材, 如果你不熟悉fabric, 就不用看网上其他教程了, 看这一篇即可.
最后, 这篇教程用的是 python3 的fabric 版本. 因此你本机要先安装python3. 如果是linux 版本, 一般是这样:
sudo apt install python3-pip
安装完成之后, 只要用:
sudo pip3 install xxx
所安装的包/组件就都是python3版本了.
如果是windows系统, 假设你下载安装的就是python3的软件, 那么当你使用 pip install 的时候, 这个pip 就是3的版本. 所以不需要再多设置.
如何安装
linux:
sudo pip3 install fabric
windows:
pip install fabric
实验环境
如果你很熟悉docker 或者自己就有云服务器, 那么试验环境就有了. 如果你没有(特别是windows 用户, 很难装好docker) 怎么办.
你可以使用我的这个虚拟机镜像. 首先你要安装 virtualbox, 下载地址在这里: https://www.virtualbox.org/wiki/Downloads
在某云盘下载我的这个虚拟机镜像:
这是个 xubuntu 系统(ubuntu的变种), 我最爱的系统之一.
安装之后用virtualbox 把它导入, 一步步操作如下:
打开virtualbox之后, 选择New
image.png
Type选择Linux, Version 选择Ubuntu(64bit) , 然后点击Next.
image.png内存默认 1024 就够了, 点Next
image.png选择"Use an existing ... " , 然后点右边那个文件夹图标
在打开的界面中, 选择 Add
image.png
然后选择刚才下载的xub.vdi文件, 如图:
image.png
然后点 choose
image.png
这时候你会看到 create 已经变得可选了, 点它. 就会看到虚拟机已经成功创建.
image.png点击右上方的Settings, 然后在弹出的界面设置网卡:
image.png
(中文名字应该是桥接网络) . 这样选了之后, 你的电脑(称为宿主机) 就可以访问虚拟机里的操作系统了.
双击xub 启动系统, 你会看到 xubuntu的界面. 这个系统有两个用户的信息, 分别是:
xiaofeipapa / 密码 aa
root / 密码 test
在xubuntu的桌面点击鼠标右键, 选择 Open Terminal Here, 打开命令行, 输入:
ifconfig
查看自己的ip是多少. 在我这里是 192.168.135.24 .
然后在宿主机里打开命令行, 输入:
ssh root@192.168.135.24
然后输入root的密码(test ) , 就可以看到ssh 登录成功了. 好了, 你拥有一个实验环境, 可以在上面试验fabric了.
实验环境信息总结
ip : 192.168.135.24 ( 也许你的机器会不同, 在虚拟机里用ifconfig来查看)
用户: root / test
fabric 基础
以下程序的参数都针对我那个虚拟机镜像, 如果你有自己的虚拟机或者云服务器, 改相应参数即可.
起步: 连接服务器, 执行命令
#! /usr/bin/python3
# -*- coding: UTF-8 -*-
"""
作者: 小肥爬爬
简书: https://www.jianshu.com/u/db796a501972
gitee: https://gitee.com/xiaofeipapa/python-toolkit
您可以自由转载此博客文章, 恳请保留原链接, 谢谢!
"""
from fabric import Connection
def do_it():
host = '192.168.135.24'
user = 'root'
password = 'test'
# ssh 连接的正确姿势
conn = Connection(host=host, user=user, connect_kwargs={'password': password})
# 在远程机器运行命令(用run方法), 并获得返回结果
# hide 表示隐藏远程机器在控制台的输出, 达到静默的效果
# 默认 warn是False, 如果远程机器运行命令出错, 那么本地会抛出异常堆栈. 设为True 则不显示这堆栈.
cmd = 'ls /tmp'
result = conn.run(cmd, hide=True, warn=True, encoding='utf-8')
# 正常运行时, 信息在 stdout里
print('-------- 下面是 stdout 信息')
print(result.stdout.strip())
# 出错时, 信息在 stderr 里
print('-------- 下面是 stderr 信息')
print(result.stderr.strip())
if __name__ == '__main__':
do_it()
注释里已经把api用法说得很清楚了. 请阅读并运行程序加深印象.
封装工具类 fab_utils.py
在我们写程序的时候, 其实大部分时候都运行正常. 如果每次run之后都去判断 stdout, stderr ... 代码很快变得混乱不堪. 所以我的选择是将之封装成方法, 使之更加轻便. 这个文件命名为 fab_utils.py, 内容如下:
#! /usr/bin/python3
# -*- coding: UTF-8 -*-
"""
作者: 小肥爬爬
简书: https://www.jianshu.com/u/db796a501972
gitee: https://gitee.com/xiaofeipapa/python-toolkit
您可以自由转载此博客文章, 恳请保留原链接, 谢谢!
将fabric api 再封装一层, 使之更好用
"""
class FabException(Exception):
def __init__(self, msg):
Exception.__init__(self, msg)
# 运行远程命令
def run(conn, cmd, hide=True, warn=True):
r = conn.run(cmd, encoding='utf8', hide=hide, warn=warn)
result, err = r.stdout.strip(), r.stderr.strip()
if err:
raise FabException(err)
return result
以后, 我会把这个工具库逐渐扩大, 加入更多有用的方法. 经过这种方式, fabric的大多数功能就变成你的工具库, 可以轻松写出大多数的运维程序.
写一个程序 s2_test_fab_utils.py 来测试:
#! /usr/bin/python3
# -*- coding: UTF-8 -*-
"""
作者: 小肥爬爬
简书: https://www.jianshu.com/u/db796a501972
gitee: https://gitee.com/xiaofeipapa/python-toolkit
您可以自由转载此博客文章, 恳请保留原链接, 谢谢!
"""
from fabric import Connection
import fab_utils
def do_it():
host = '192.168.135.24'
user = 'root'
password = 'test'
# ssh 连接的正确姿势
conn = Connection(host=host, user=user, connect_kwargs={'password': password})
# 结果变得更简单了
cmd = 'ls /tmp'
result = fab_utils.run(conn, cmd)
# 正常运行时, 信息在 stdout里
print('-------- 下面是结果')
print(result)
# 出错时, 程序会抛出异常
# 来一个出错的例子, tmp/xiaofeipapa 目录不存在
cmd = 'ls /tmp/xiaofeipapa'
result = fab_utils.run(conn, cmd)
print(result)
if __name__ == '__main__':
do_it()
运行这个结果, 你能看到第一个例子成功输出结果, 第二个例子抛出异常, 这就是我想要的效果.
ssh无密码登录ssh
登录密码写在参数里当然不安全. 用linux 系统的同学都知道, 可以配置无密码方式访问服务器, 避免暴露密码明文在参数里. (windows我暂时不知道怎么办, 有待请教. )
首先在宿主机生成密钥, 一路按回车:
ssh-keygen -t rsa
然后运行这个脚本:
ssh-copy-id root@192.168.135.24
输入root的登录密码 test, 以后就可以无密码访问服务器了. 相应地, fabric的连接变成如下:
conn = Connection(host=host, user=user)
不用输入密码了.
运行自定义shell脚本
像ls, cd 都是bash 预先提供的命令. 如果要运行自己写的shell怎么办? 有两个办法: 如果sh 文件很大, 那么先在本地写好, 然后传到服务器上运行. 如果shell很少, 直接用这个方式运行:
conn.client.exec_command(cmd)
下面的这个例子演示了如何运行短的shell脚本:
#! /usr/bin/python3
# -*- coding: UTF-8 -*-
"""
作者: 小肥爬爬
简书: https://www.jianshu.com/u/db796a501972
gitee: https://gitee.com/xiaofeipapa/python-toolkit
您可以自由转载此博客文章, 恳请保留原链接, 谢谢!
"""
from fabric import Connection
import fab_utils
def do_it():
host = '192.168.0.12'
user = 'root'
# ssh 连接的正确姿势
conn = Connection(host=host, user=user)
# 运行shell之前, 要随便运行一个命令, 获得运行环境
fab_utils.run(conn, 'uname -a')
# 运行shell脚本
# 在shell里 [ -d xxx ] 表示检查文件夹是否存在
# [ -d xxx ] 表示检查文件是否存在
cmd = '[ -d /tmp ] && echo ok'
_stdin, _stdout, _stderr = conn.client.exec_command(cmd)
result = _stdout.read().strip().decode('utf8')
print('--- std out: ')
print(result)
if __name__ == '__main__':
do_it()
检查文件/夹是否存在
bash没有提供检查文件夹是否存在, 文件是否存在的方法, 而这两个方法是经常需要使用的. 所以我们可以用简单的shell脚本来封装这两个方法. 在 fab_utils.py 里加入如下方法:
# 检查文件夹是否存在
def is_dir_exists(conn, dir_path):
cmd = '[ -d ' + dir_path + ' ] && echo ok'
_stdin, _stdout, _stderr = conn.client.exec_command(cmd)
result = _stdout.read().strip().decode('utf8')
return result == 'ok'
# 检查文件文件是否存在
def is_file_exists(conn, file_path):
cmd = '[ -f ' + file_path + ' ] && echo ok'
_stdin, _stdout, _stderr = conn.client.exec_command(cmd)
result = _stdout.read().strip().decode('utf8')
return result == 'ok'
# 判断是否有该文件夹, 不存在时可以通过参数控制生成文件夹
def check_has_dir(conn, dir_path, create_if_not_exist=True):
if not is_dir_exists(conn, dir_path):
if create_if_not_exist:
run(conn, 'mkdir -p ' + dir_path)
# 判断是否有该文件, 不存在时可以通过参数控制生成文件夹
def check_has_file(conn, file_path, create_if_not_exist=True):
if not is_dir_exists(conn, file_path):
if create_if_not_exist:
run(conn, 'touch ' + file_path)
然后写一个python文件来检查:
#! /usr/bin/python3
# -*- coding: UTF-8 -*-
"""
作者: 小肥爬爬
简书: https://www.jianshu.com/u/db796a501972
gitee: https://gitee.com/xiaofeipapa/python-toolkit
您可以自由转载此博客文章, 恳请保留原链接, 谢谢!
"""
from fabric import Connection
import fab_utils
import os, os.path
def do_it():
host = '192.168.0.12'
user = 'root'
# ssh 连接的正确姿势
conn = Connection(host=host, user=user)
# 运行shell之前, 要随便运行一个命令, 获得运行环境
fab_utils.run(conn, 'uname -a')
# 检查文件夹
dir_path = '/root/data'
fab_utils.check_has_dir(conn, dir_path)
# 查看创建文件夹结果
result = fab_utils.run(conn, 'ls ' + dir_path)
print(result)
# 检查文件
file_path = os.path.join(dir_path, 'done.txt')
fab_utils.check_has_file(conn, file_path)
# 查看创建文件结果
result = fab_utils.run(conn, 'ls ' + file_path)
print(result)
if __name__ == '__main__':
do_it()
安全的删除
我们都知道在服务器上尽量不要用rm命令, 否则删错文件那就完了. 所以在用fabric的时候我们同样要注意这个问题. 在fab_utils.py 里加入这个方法:
# 在服务器安全删除文件/文件夹的方法
def safe_rm(conn, file_path):
# 先检查是否存在
try:
run(conn, 'ls ' + file_path)
except FabException:
print(file_path + ' 不存在')
return
# 在/tmp下生成当天目录
result = run(conn, 'date +%Y-%m-%d')
trash_dir = '/tmp/my_trash/' + result
check_has_dir(conn, trash_dir)
# 在当天下生成uuid, 确保mv 不会失败
result = run(conn, 'uuidgen')
trash_dir = trash_dir + '/' + result
check_has_dir(conn, trash_dir)
run(conn, 'mv ' + file_path + ' ' + trash_dir)
写一个文件来测试. 程序在 s5_rm_test.py
#! /usr/bin/python3
# -*- coding: UTF-8 -*-
"""
作者: 小肥爬爬
简书: https://www.jianshu.com/u/db796a501972
gitee: https://gitee.com/xiaofeipapa/python-toolkit
您可以自由转载此博客文章, 恳请保留原链接, 谢谢!
"""
from fabric import Connection
import fab_utils
import os, os.path
def do_it():
host = '192.168.0.12'
user = 'root'
# ssh 连接的正确姿势
conn = Connection(host=host, user=user)
# 运行shell之前, 要随便运行一个命令, 获得运行环境
fab_utils.run(conn, 'uname -a')
# 删除之前创建的文件夹 /root/data
dir_path = '/root/data'
fab_utils.safe_rm(conn, dir_path)
if fab_utils.is_dir_exists(conn, dir_path):
print('--- 文件夹还在')
else:
print('--- 文件夹已经删除')
if __name__ == '__main__':
do_it()
写入文本文件
在服务器上我们通常会有写文件的任务, 例如写入环境变量到配置文件, 在fabric 里可以用简单的shell命令来完成:
conn.run("echo 'hello' >> /tmp/test.txt")
这个命令又是双引号又是单引号, 所以还是封装起来更好用. 在fab_utils.py 里加入如下方法:
# 追加一行到文件末尾
def append_line(conn, file_path, content):
if not is_file_exists(conn, file_path):
return
cmd = "echo '" + content + "' >> " + file_path
run(conn, cmd)
然后写一个文件来测试, 代码在 s6_env_var.py
#! /usr/bin/python3
# -*- coding: UTF-8 -*-
"""
作者: 小肥爬爬
简书: https://www.jianshu.com/u/db796a501972
gitee: https://gitee.com/xiaofeipapa/python-toolkit
您可以自由转载此博客文章, 恳请保留原链接, 谢谢!
"""
from fabric import Connection
import fab_utils
def do_it():
host = '192.168.0.12'
user = 'root'
# ssh 连接的正确姿势
conn = Connection(host=host, user=user)
# 运行shell之前, 要随便运行一个命令, 获得运行环境
fab_utils.run(conn, 'uname -a')
# 在/tmp 下生成 test_rc 文件,
file_path = '/root/test_rc'
fab_utils.check_has_file(conn, file_path)
# 写入内容到文件里
fab_utils.append_line(conn, file_path, "#==其他配置信息1")
fab_utils.append_line(conn, file_path, "#==其他配置信息2")
fab_utils.append_line(conn, file_path, "#==其他配置信息3")
fab_utils.append_line(conn, file_path, "\n")
fab_utils.append_line(conn, file_path, "#==JAVA安装信息")
fab_utils.append_line(conn, file_path, "JAVA_HOME=XXX")
fab_utils.append_line(conn, file_path, "CLASS_PATH=YYY")
fab_utils.append_line(conn, file_path, "export PATH=$PATH:JAVA_HOME/bin")
# 查看文件内容
result = fab_utils.run(conn, 'cat ' + file_path)
print(result)
if __name__ == '__main__':
do_it()
自动响应
有些命令如 passwd , adduser 会等候你继续输入下一步. 像这种需要手动响应的自动化程序应该怎么写? 在fabric 里这样做:
from invoke import Responder
# 其他代码
resp1 = Responder(
pattern=r'Enter new UNIX password:',
response=pwd + '\n'
)
resp2 = Responder(
pattern=r'Retype new UNIX password:',
response=pwd + '\n'
)
conn.run('passwd ' + username, pty=True, hide=True, watchers=[resp1, resp2])
Responder 是 invoke 框架的一部分(fabric 已经包括invoke框架) . 它的使用方法如下:
- pattern: 正则表达式, 用来匹配命令行的输出. 如果里面有需要转义的字符, 千万要记得转义.
- response: 需要输入的内容.
这两个参数结合起来的意思是: 如果匹配到命令输出, 需要输入信息的时候, 就自动输入response定好的字符串.
如果不确定某些命令的pattern和resonse 是什么. 可以先在服务器手动输入命令看看结果, 然后再写程序. 如果程序多次需要输入, 那么就要写多个responder, 用个数组包起来.
注意: 如果程序长时间没有反馈, 证明你的pattern 写得有问题.
最常见的原因就是转义字符没有进行处理. 下面这是一个例子:
# 在yum上安装软件
def yum_install(conn, cmd):
resp = Responder(
# pattern=r'Is this ok [y/d/N]:', # 错误例子: 没有正则转义
pattern=r'Is this ok \[y/d/N\]:',
response='y\n'
)
conn.run(cmd, pty=True, watchers=[resp], hide=True)
下面是一个完整的例子: 我们将用户xiaofeipapa的密码改为 bb, 整个过程是自动化无人工干预的:
#! /usr/bin/python3
# -*- coding: UTF-8 -*-
"""
作者: 小肥爬爬
简书: https://www.jianshu.com/u/db796a501972
gitee: https://gitee.com/xiaofeipapa/python-toolkit
您可以自由转载此博客文章, 恳请保留原链接, 谢谢!
"""
from fabric import Connection
import fab_utils
from invoke import Responder
def do_it():
host = '192.168.0.12'
user = 'root'
# ssh 连接的正确姿势
conn = Connection(host=host, user=user)
# 运行shell之前, 要随便运行一个命令, 获得运行环境
fab_utils.run(conn, 'uname -a')
# 将密码改成bb
username = 'xiaofeipapa'
pwd = 'bb'
resp1 = Responder(
pattern=r'Enter new UNIX password:',
response=pwd + '\n'
)
resp2 = Responder(
pattern=r'Retype new UNIX password:',
response=pwd + '\n'
)
conn.run('passwd ' + username, pty=True, hide=True, watchers=[resp1, resp2])
if __name__ == '__main__':
do_it()
fabric 实战案例: 在远程服务器上安装 jdk环境
前面零零散散地介绍了很多fabric的用法, 现在用这个案例把它综合起来: 在远程服务器(虚拟机) 里安装jdk环境.
这个任务做开发/运维的同学应该都不陌生. java技术栈的运维架构通常是1个nginx, 后端挂若干个安装jdk服务器, 以此达到负载均衡的目的.它的任务可以分解为:
- 在远程服务器上下载解压jdk包.
- 在配置文件写入若干个环境变量(如JAVA_HOME, CLASSPATH) , 指向jdk的解压路径.
- 创建新的登录用户, 开发者用它来登录机器, 发布jar 包.
当然其中还包括一些弯弯绕绕的必要任务. 例如怎么判断任务已经运行过, 所以不会再重复运行? 怎么判断jdk包已经存在, 不用再重新下载? 怎么判断环境变量已经写入到配置文件, 不用重复写入?
这些技巧一一列举如下.
怎么知道任务是否第一次执行?
思路: 给任务一个名字, 例如 install_jdk. 第一次在目标机器运行之后, 就会在 /root 目录下生成这个文件: /root/data/install_jdk/done.txt . 所以程序可以判断这个文件是否存在, 如果不存在表示此任务还没执行过. 如果存在, 那么可以通过命令行参数来指定是否再次运行任务.
代码如下:
def _prepare(self, rerun):
"""
检查任务是否已经启动过
:return:
"""
# 必须要先run连接远程服务器, 否则 conn.client.exec_command 会报错
fab_utils.run(self.conn, 'ls /tmp')
# 生成 /root/data 目录
base_dir = '/root/data'
fab_utils.create_if_remote_dir_not_exist(self.conn, base_dir)
# 检查该task 是否已经执行过. 如果已经执行过, 在 /root/data/task 目录下有 done.txt 文件
task_home = base_dir + '/' + self.task
fab_utils.create_if_remote_dir_not_exist(self.conn, task_home)
self.task_home = task_home
task_file = task_home + '/done.txt'
if not fab_utils.is_remote_file_exist(self.conn, task_file):
return
# 如果存在, 又不指定重新运行, 则退出
if not rerun:
title('此程序已经在目标机器运行过了. 如果想再次运行, 请设置启动参数 -r true')
exit(0)
# ...... 其他代码
# 命令行指定启动参数
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser('传入参数:***.py')
parser.add_argument('-r', '--rerun', default=True) # 开发时用
# parser.add_argument('-r', '--rerun', default=False)
args = parser.parse_args()
rerun = args.rerun
print('---- 参数 rerun: ', rerun)
do_it(args)
判断jdk存在,不用重复下载
思路: 当第一次下载好jdk包的时候, 我们可以拿到该包的md5, 然后如果服务器上已经存在jdk包并且md5一致, 那么就不用再下载. 否则开始下载jdk.
代码如下:
with self.conn.cd(self.task_home):
# 检查下载jdk
file_path = self.task_home + '/' + jdk_file
file_md5 = 'ef599e322eee42f6769991dd3e3b1a31'
down_link = 'wget https://repo.huaweicloud.com/java/jdk/8u181-b13/' + jdk_file
fab_utils.check_download(self.conn, file_path, file_md5, down_link)
# 创建jvm目录
fab_utils.create_if_remote_dir_not_exist(self.conn, jvm_dir)
# 解压. 华为的这个包解压后名字不同, 所以手动指定一个目录
hint('正在解压 jdk ......')
fab_utils.run(self.conn, 'tar xvf ' + jdk_file + ' -C ' + jvm_dir + ' --strip-components 1 ')
写入配置文件与重复检查
有两个思路:
- 利用sed, 如果发现已经有相应的环境变量, 将之删除重新写入.
- 用 echo 显示 JAVA_HOME, 如果有值
第一个思路容易错删其他内容, 所以后来采用了第二个思路. 代码如下:
run_result = conn.run('echo $JAVA_HOME ', hide=True).stdout.strip()
if run_result == jvm_dir:
hint(system_file + ' 已经包含 JAVA_HOME, 不需要重新设置')
else:
# 追加内容到系统配置
conn.run("echo '# ===== java 配置' >> " + system_file)
conn.run("echo 'export JAVA_HOME=" + jvm_dir + "' >> " + system_file)
# 将 exporter jar 也加入到 classpath 里
export = 'export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar:$JAVA_HOME/jre/lib/rt.jar:'\
+ exporter_jar_path
conn.run("echo '" + export + "' >> " + system_file)
conn.run("echo 'export PATH=$PATH:$JAVA_HOME/bin' >> " + system_file)
# 使环境变量生效
conn.run('source ' + system_file)
这里还有个小窍门, linux 那么多配置文件如 /etc/profile , /etc/bashrc , 究竟应该写到哪个文件? 查了一下资料, /etc/profile 只在用户第一次登录的时候起作用, 所以应该写到 /etc/bashrc 里.
怎么生成新用户
思路: 生成新用户很简单, 需要考虑的是给它设定密码. 考察了一下linux上生成密码的方式, 最后决定用 pwgen . 用法:
# 生成10个字符的密码, 个数为1
sudo apt install pwgen
pwgen -s 10 1
代码大概长这样:
def add_user(self, username):
"""
增加用户, 并设置密码.
:param username: 用户名
:return: 登录密码
"""
if self._is_user_exist(username):
title('用户 ' + username + ' 已经存在')
fab_utils.close_connection(self.conn)
return None
# 生成用户. 该命令只在centos 测试过
fab_utils.run(self.conn, 'adduser ' + username)
# 生成10位密码
cmd = 'pwgen -s 10 1'
password = fab_utils.run(self.conn, cmd)
# 更改密码
fab_utils.yum_passwd(self.conn, username, password)
# 退出登录
fab_utils.close_connection(self.conn)
# 登录进行测试
self.verify_login(username, self.host, password)
return password
完整代码
项目地址: https://gitee.com/xiaofeipapa/fabric-tutorial
请看 setup_java.py 文件
总结
通过这么多方法和整个案例的展示, 你应该掌握了fabric 90%的用法, 剩下的就靠你自己探索了. 希望你用fabric玩得愉快~
记得给个star和赞啊, 少年~
网友评论