2024年4月

从这一章开始,我们先放下 BCC 框架,来看仅通过 C 语言如何实现一个 eBPF。如此一来,你会更加理解 BCC 所做的底层工作。

在这一章中,我们会讨论一个 eBPF 程序被执行的完整流程,如下图所示。

一个 eBPF 程序实际上是一组
eBPF 字节码
指令。因此你可以直接使用这种特定的字节码来编写 eBPF 程序,就像写汇编代码一样。但实际上,我们都知道,汇编程序往往太抽象了。因此现在绝大部分的 eBPF 都是通过 C 语言这样的高级语言来编写的,最后经过编译,生成可供运行的字节码。

从概念上讲,eBPF 字节码将在内核的
eBPF 虚拟机
中运行。

3.1 eBPF 虚拟机

和其他虚拟机一样,eBPF 虚拟机的主要作用就是将
eBPF 字节码
转换成可以在本机 CPU 上运行的
机器码

在原始的 eBPF 实现中,字节码是在内核中解释执行的。这种方式有性能上的弊端,即,每次运行,都需要将 eBPF 从源代码编译解释为机器码,然后再运行。此外,这种传统的方式也可能存在 Spectre 相关的漏洞。

Spectre 漏洞是一类侧信道攻击,可以利用代码执行路径的依赖性来窃取敏感信息。

JIT

just-in-time
,及时编译)的出现,很好的解决了这两个问题。
JIT
可以将 eBPF 字节码即时编译成本机机器指令,直接在硬件上执行。由于编译只需要进行一次,之后的执行过程中可以直接执行本机机器指令,从而获得更高的性能。这种方式,因此能够降低潜在的 Spectre 漏洞风险。

eBPF 字节码实际上是由一组指令组成,它们运作于虚拟 eBPF 寄存器上。实际上,eBPF 指令集和寄存器能够适配目前主流的 CPU 架构,因此编译和解释这些字节码其实没有那么复杂。

3.1.1 eBPF 寄存器

eBPF 虚拟机定义了 10 个通用寄存器(
R0 ~ R9
),和一个始终指向栈顶的寄存器
R10
(只读)。这些寄存器用于在 eBPF 执行时追踪记录运行时状态。

这些 eBPF 寄存器定义于内核源码
include/uapi/linux/bpf.h
头文件中,是一个枚举类型。如下所示:

/* Register numbers */
enum {
	BPF_REG_0 = 0,
	BPF_REG_1,
	BPF_REG_2,
	BPF_REG_3,
	BPF_REG_4,
	BPF_REG_5,
	BPF_REG_6,
	BPF_REG_7,
	BPF_REG_8,
	BPF_REG_9,
	BPF_REG_10,
	__MAX_BPF_REG,
};

简单列举几个寄存器的作用:

  • eBPF 程序被执行之前,其上下文信息参数被载入
    R1
  • 函数的返回值存储于
    R0
  • eBPF 程序调用其他函数之前,会将函数参数存入
    R1 ~ R5

3.1.2 eBPF 指令集

include/uapi/linux/bpf.h
头文件中也给出了 eBPF 指令的结构定义,如下:

struct bpf_insn {
	__u8	code;		// 1	字节		/* opcode */						// A
	__u8	dst_reg:4;	// 0.5	字节		/* dest register */					// B
	__u8	src_reg:4;	// 0.5	字节		/* source register */
	__s16	off;		// 2	字节		/* signed offset */					// C
	__s32	imm;		// 4	字节		/* signed immediate constant */
};

代码解释:

【A】每个指令都包含一个操作码,代表当前指令是什么操作。例如,加法操作
ADD
、跳转操作
JUMP
等等。

Iovisor 项目 "Unofficial eBPF spec" 中给出了一个有效的指令列表(
https://github.com/iovisor/bpf-docs/blob/master/eBPF.md)。

【B】有些操作可能涉及两个寄存器。

【C】有些操作可能需要 offset(偏移量)和 imm(立即数)。

bpf_insn
结构体一共 64 位(8字节)。当一段 eBPF 程序被载入内核时,其字节码就会由一系列的
bpf_insn
来表示。而 eBPF 验证器就是检查这段信息,以确保安全性的。(见第 6 章)

解释:code:8 bit;dts_reg:4 bit;src_reg:4 bit;off:16 bit;imm:32 bit

实际上,
bpf_insn
结构体在某些情况(宽指令)下,可能会额外扩展 8 字节

,这样一来单条指令可能会达到 16 字节。(注意:伏笔)

操作码可以分为以下几类:

  • 加载一个值到寄存器中(可以是立即数
    imm
    ,也可以是另一个寄存器中的值)。
  • 将一个寄存器中的值存入内存。
  • 执行算术运算(加、减、乘等等)。
  • 在某些条件下,跳转到另一个指令执行。

接下来,我们来看一个简单的例子(使用 libbpf 库),详细追踪一下它从源代码到字节码再到机器码的全过程。

3.2 另一个 eBPF 的 Hello World

上一章我们给出的 eBPF 程序是通过内核探针 kprobe 绑定事件进行触发的。这次我们换一种方式,以网络包的到达作为 eBPF 程序的触发条件。

在目前 eBPF 的应用领域中,网络数据包的处理非常热门。网络接口中的 eBPF 程序是很牛的,它可以检查甚至修改网络包中的内容,并且可以控制内核的后续行为(接收、丢弃或重定向)。有关网络方面的应用,详见第 8 章。书中在这里给出了一个网络包处理的 eBPF 例子,是因为作者认为,因网络包到达而触发的 eBPF 程序对于理解整个过程很有帮助。

但接下来给出的例子不会添加太多的逻辑,仅仅是在网络包到达时打印 “Hello World”。

下面的程序名为
hello.bpf.c

注意:在
libbpf
框架中,eBPF 程序后缀为
.bof.c

这一点和前文有所差别。

#include <linux/bpf.h>							// A
#include <bpf/bpf_helpers.h>

int counter = 0;								// B

SEC("xdp")										// C
int hello(void *ctx) {							// D
    bpf_printk("Hello World %d\n", counter);
    counter++;
    return XDP_PASS;
}

char LICENSE[] SEC("license") = "Dual BSD/GPL";	// E

代码解释:

【A】
#include <linux/bpf.h>
,eBPF 程序需要包含这个头文件。

【B】eBPF 程序是可以使用全局变量的!这个变量
counter
会在每次运行时自增。

【C】
SEC("xdp")

SEC()
是一个宏定义,它定义了一个名为
xdp

section
。我们将在第 5 章继续详细讨论有关
section
的内容。不过现在,可以简单把它理解为,定义了当前函数是一个
xdp
(eXpress Data Path)类型的 eBPF 程序。

【D】这一部分代码定义了一个函数,名为
hello()
。这就是真正的 eBPF 程序了。函数内部调用了一个名为
bpf_printk()
的函数,用来写入一个字符串;同时将全局计数器
counter
自增。在函数的最后,返回值为
XDP_PASS
。这里实际上是 eBPF 程序对内核下达的用于处理当前网络包指令,这里是通过这个网络包,不作操作。

【E】最后这句代码,也是一个
SEC()
宏定义,规定了当前 eBPF 程序的许可证。这是因为,很多内核函数(包括 eBPF 辅助函数)都标识了
GPL
兼容许可证,eBPF 程序只有也添加这些标识才能使用它们。当然,eBPF 验证器也会验证 eBPF 许可证信息(详见第 6 章)。

到这里为止,我们就可以看到
BCC

libbpf
的区别了。以打印字符串为例,BCC 框架中是
bpf_trace_printk()
,libbpf 框架中是
bpf_printk()
。实际上这俩都是内核函数
bpf_trace_printk()
的封装。

在编写完 eBPF 源码之后,下一步就是将其编译为内核能够理解的目标文件了。

3.3 编译出目标文件

这一节中,我们的主要目标就是,将前文给出的 eBPF 源码编译成 eBPF 字节码,以便能够被 eBPF 虚拟机所理解。

LLVM + Clang
是很合适的编译器。你只需要指定
-target bpf
参数即可完成编译。

hello.bpf.o: %.o: %.c
	clang \
		-target bpf \
		-I/usr/include/ \
		-g \
		-O2 -c $< -o $@

注意,译者这里给出的 Makefile 文件与书中给出的并不相同。变化之处是头文件路径,该路径是被引用的 libbpf 开发包的地址(
bpf/bpf_helpers.h
在这)。

你可以预先查看这个目录是否存在 libbpf 相关的头文件,如果不存在,那么你需要先安装 libbpf 开发包。否则编译时会提示:"hello.bpf.c:2:10: fatal error: 'bpf/bpf_helpers.h' file not found"。

可以直接用包管理器安装 libbpf 开发包,以
yum/dnf
为例。

yum install -y libbpf-devel.x86_64

通过这种规则编译后,将会生成一个名为
hello.bpf.o
的目标文件。
-g
参数是可选的,可以在目标文件中生成一些
debug
信息(在字节码的侧边栏显示源码),阅读这些信息对于理解 eBPF 是很有帮助的。

3.4 看看编译出来的是啥

首先,使用
file
工具看看这个
.o
文件是个啥。

$ file hello.bpf.o
hello.bpf.o: ELF 64-bit LSB relocatable, eBPF, version 1 (SYSV), with debug_info, not stripped

对输出的解释:

  • ELF
    :这个文件类型是
    ELF
    (Executable and Linkable Format),即可执行或可链接类型的文件。
  • 64-bit LSB relocatable
    :表明这是一个 64 位的 LSB(小端法?不确定) 架构。
  • eBPF
    :这个文件包含 eBPF 代码。
  • version 1 (SYSV)
    :版本号。
  • with debug_info
    :说明这个目标文件带有
    debug
    信息。

可以使用
llvm-objdump
工具来查看这个 eBPF 目标文件。

$ llvm-objdump -S hello.bpf.o

可以看到如下的内容(注意这里的内容和书上不同,这里是译者机器上给出的字节码):

hello.bpf.o:    file format elf64-bpf							; A

Disassembly of section xdp:										; B

0000000000000000 <hello>:										; C
; int hello(void *ctx) {
       0:       18 01 00 00 72 6c 64 20 00 00 00 00 25 64 0a 00 r1 = 2924860387126386 ll
;     bpf_printk("Hello World %d\n", counter);					; D
       2:       7b 1a f8 ff 00 00 00 00 *(u64 *)(r10 - 8) = r1
       3:       18 01 00 00 48 65 6c 6c 00 00 00 00 6f 20 57 6f r1 = 8022916924116329800 ll
       5:       7b 1a f0 ff 00 00 00 00 *(u64 *)(r10 - 16) = r1
       6:       18 06 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r6 = 0 ll
       8:       61 63 00 00 00 00 00 00 r3 = *(u32 *)(r6 + 0)
       9:       bf a1 00 00 00 00 00 00 r1 = r10
      10:       07 01 00 00 f0 ff ff ff r1 += -16
;     bpf_printk("Hello World %d\n", counter);
      11:       b7 02 00 00 10 00 00 00 r2 = 16
      12:       85 00 00 00 06 00 00 00 call 6
;     counter++;												; E
      13:       61 61 00 00 00 00 00 00 r1 = *(u32 *)(r6 + 0)
      14:       07 01 00 00 01 00 00 00 r1 += 1
      15:       63 16 00 00 00 00 00 00 *(u32 *)(r6 + 0) = r1
;     return XDP_PASS;											; F
      16:       b7 00 00 00 02 00 00 00 r0 = 2
      17:       95 00 00 00 00 00 00 00 exit

代码解释:

【A】第一行说明
hello.bpf.o
文件是一个 64-bit 的 eBPF 代码的
ELF
文件。

【B】接下来是对
xdp
section 的声明。这就是我们之前在
SEC()
中定义的内容。

【C】这部分是
hello()
函数。

【D】接下来两个部分,是
bpf_printk()
的字节码。

【E】下面三行,是
counter
自增的字节码。

【F】最后两行是 eBPF 程序的返回值
XDP_PASS

除非你有特殊的需求,不然的话,上述字节码建议就图一乐看看,不用深究其和源代码的对应关系。人工去重复编译器的工作没有意义。但是为了学习,我们还是简单来分析一下几点内容。


hello()
函数为例,
hello()
函数内是一行行的 eBPF 指令(前文说的
bpf_insn
结构)。

对于每一行的字节码指令,最左一列代表这行指令相比
hello()
函数在内存中位置的偏移量,中间一大坨是当前指令的字节码形式,右边一坨是人类可读的指令解释(汇编形式)。

不难发现,最左侧的偏移量从上往下是递增的。递增的大小可能是 1,可能是 2。这是因为 eBPF 指令的大小可能为 8 (通常情况)或 16 字节(前文 [3.1.2 eBPF 指令集](#3.1.2-eBPF 指令集) 中提到过)。而在 64-bit 的平台上,一个内存单元占据 8 字节,因此,每条指令可能会占据 1~2 个内存单元。
以偏移量为 0 的这条指令为例
:这一行字节码指令刚好是一条宽指令(中间一坨占据 16 个字节),因此下一行指令的偏移量便为 2 了。

中间一坨是真正的字节码内容。其第一个字节为指令操作码,用于告知内核当前是什么操作。

例如,偏移量为 11 的这条指令,如下:

11:       b7 02 00 00 10 00 00 00 r2 = 16

指令操作码为
0xb7
, 那么,这个操作码应该如何翻译呢?eBPF 指令基金会给出了一个标准文档(
https://datatracker.ietf.org/doc/html/draft-ietf-bpf-isa
),你可以在这个文档中查询指令操作码对应的操作伪代码。

可以看到,
0xb7
对应的伪代码是
dst = (s64) (s8) imm
,即,将目标地址设置为一个立即数。

再来看,第 2 个字节是
0x02
,代表源地址和目标地址,即源地址为空,目标地址为寄存器
R2

再来看,接下来 2 个字节(一共16 bit)为 0,代表偏移量
off
为空。

再来看,接下来 4 个字节(一共 32 bit),为
0x10
(小端法实际上为
0x00000010
),是立即数的十六进制表示,对应的十进制数为 16。

这条指令的实际含义就是通知内核,将寄存器
R2
的地址上存入一个立即数
16

译者注:如果你结合前文给出的
bpf_insn
结构体来看,你就会发现,是可以一一对应的。

再举一个例子。偏移量为 16 的指令也是一个写入立即数的操作,和上面类似:

 16:       b7 00 00 00 02 00 00 00 r0 = 2

这里不再详细介绍了,感兴趣可以自己分析一下。这条指令的含义是,将寄存器
R0
的地址中存入立即数
2

我们前文介绍过([3.1.1 eBPF 寄存器](#3.1.1-eBPF 寄存器)),寄存器
R0
用来存储函数的返回值。这里的立即数
2
其实是
XDP_PASS
的宏定义值。

好了,到目前为止,我们已经获得了字节码格式的目标文件,接下来的目的就是把它加载到内核中了!

3.5 字节码载入内核

在这一章里,我们使用一个工具来完成 eBPF 载入内核的操作。这个工具是
bpftool
,一个服务于 eBPF 程序的很常用的工具。

现在很多发行版操作系统都会默认集成安装这个工具了,如果没有,可以尝试使用对应的软件包管理器安装它。

使用下面的命令,可以将 eBPF 字节码文件载入内核(注意 root 权限)。

$ bpftool prog load hello.bpf.o /sys/fs/bpf/hello

这条命令是将我们编译好的
hello.bpf.o
文件载入内核,并
PIN

/sys/fs/bpf/hello
这个位置上。

译者注:在低版本的 bpftool 上,这条命令可能会执行失败,报错如下:

libbpf: Error loading ELF section .BTF: -22. Ignored and continue.
libbpf: Program 'xdp' contains non-map related relo data pointing to section 5
Error: failed to open object file

这个错误的原因是内核版本太低,对应的 eBPF 不支持全局的静态变量。如果遇到这个问题,请适当升级你的内核版本。

详情请参考:
https://stackoverflow.com/questions/48653061/ebpf-global-variables-and-structs

成功载入后,你可以查看
/sys/fs/bpf
目录中的输出打印。

$ ls /sys/fs/bpf
hello

至此,
hello.bpf.o
文件就被成功载入内核了。那么接下来,我们继续利用
bpftool
这个强大工具,来看一看这个 eBPF 程序在内核中到底是个什么样子。

3.6 载入后的 eBPF 全貌

首先,若你想查看当前内核中载入的所有 eBPF 程序,可以使用下面的命令。这个指令会输出一个列表。

$ bpftool prog list
5: xdp  name hello  tag ec5542c3187de469  gpl
        loaded_at 2024-01-23T08:33:12+0800  uid 0
        xlated 144B  jited 95B  memlock 4096B  map_ids 3
        btf_id 5

译者给出的例子均是在我的系统上运行的结果,与书上不同,请读者悉知。后文不再赘述。

每段 eBPF 程序在内核中都有一个唯一标识(ID),当前为 5。你可以根据 eBPF 的 ID,继续使用
bpftool
来查看 eBPF 的详细信息。

$ bpftool prog show id 5 --pretty
{
    "id": 5,
    "type": "xdp",
    "name": "hello",
    "tag": "ec5542c3187de469",
    "gpl_compatible": true,
    "loaded_at": 1705969992,
    "uid": 0,
    "bytes_xlated": 144,
    "jited": true,
    "bytes_jited": 95,
    "bytes_memlock": 4096,
    "map_ids": [3
    ],
    "btf_id": 5
}

这些字段的含义都很直观:

  • id
    :当前 eBPF 程序 ID 为 5。
  • type
    :这是一个
    xdp
    类型的 eBPF 程序,可以绑定到
    xdp
    事件的网络接口上。eBPF 还有其他类型,后面再说(第 7 章)。
  • name
    :当前程序名称为 “hello”,其实就是
    hello()
    函数名。
  • tag
    :这个字段也是 eBPF 程序的另一个标识,后面详细说([3.6.1 BPF tag](#3.6.1-BPF tag))。
  • gpl_compatible
    :基于
    GPL 兼容许可证
  • loaded_at
    :时间戳。为当前 eBPF 载入的时间。
  • uid
    :用户 ID。0 为
    root
    用户。
  • bytes_xlated
    :编译后的 eBPF 字节码共有 144 个字节。后面详细说([3.6.2 BPF xlated 编译产物](#3.6.2-BPF xlated 编译产物))。
  • jited
    :这段 eBPF 已经被
    JIT
    即时编译了。
  • bytes_jited

    JIT
    即时编译产出 95 字节的机器码。后面说([3.6.3 BPF jited 编译产物](#3.6.3-BPF jited 编译产物))。
  • bytes_memlock
    :当前 eBPF 预留了 4096 个字节的内存,这些内存页不会被换走。
  • map_ids
    :这段程序使用了 ID 为 3 的
    BPF_MAP
    (全局变量实际上就是
    BPF_MAP
    )。
  • btf_id
    :当前程序包含一个 BTF 程序块(只有使用了
    -g
    参数编译后,这条信息才会显示在
    .o
    文件中)。有关 BTF,我们将在第 5 章详细展开讨论。

3.6.1 BPF tag

BPF tag
字段是一个基于程序所有指令的 SHA 哈希值(Secure Hashing Algorithm)。
BPF tag
同样可以用来标识 eBPF 程序。与
BPF ID
不同之处在于,每次载入或卸载 eBPF 程序时,ID 可能会不同,但是
tag
始终保持不变。

bpftool
工具支持通过
ID/name/tag/pinned
四种方式来查看 eBPF 详情。下面四条命令得出的结果相同:

$ bpftool prog show id 5
$ bpftool prog show name hello
$ bpftool prog show tag ec5542c3187de469
$ bpftool prog show pinned /sys/fs/bpf/hello

值得注意的是,eBPF 程序的 name、tag 可能会相同,但其 ID、pinned 都是唯一的。

3.6.2 BPF xlated 编译产物

不要把这一节和下一节的两个编译阶段搞混淆了。书上在这里给出了一个让我感觉很迷惑的标题 “The translated Bytecode”,直译为:翻译后的字节码。但实际上,
这一阶段是 eBPF 字节码(
.o
目标文件)经历
BPF 验证器
之后的微调版
BPF 字节码

。在这里,译者姑且称它为 “BPF xlated 编译产物”。

为什么是微调版 BPF 字节码,后面会有机会解释。

我们用
bpftool
工具来看一看这一阶段的字节码长什么样。

$ bpftool prog dump xlated name hello
int hello(void * ctx):
; int hello(void *ctx) {                                                        // D
   0: (18) r1 = 0xa642520646c72
; bpf_printk("Hello World %d\n", counter);
   2: (7b) *(u64 *)(r10 -8) = r1
   3: (18) r1 = 0x6f57206f6c6c6548
   5: (7b) *(u64 *)(r10 -16) = r1
   6: (18) r6 = map[id:3][0]+0
   8: (61) r3 = *(u32 *)(r6 +0)
   9: (bf) r1 = r10
;
  10: (07) r1 += -16
; bpf_printk("Hello World %d\n", counter);
  11: (b7) r2 = 16
  12: (85) call bpf_trace_printk#-57216
; counter++;
  13: (61) r1 = *(u32 *)(r6 +0)
  14: (07) r1 += 1
  15: (63) *(u32 *)(r6 +0) = r1
; return XDP_PASS;
  16: (b7) r0 = 2
  17: (95) exit

乍一看上去,和前文我们使用
llvm-objdump
工具得出的字节码(
3.4 看看编译出来的是啥
)很相似。指令长得很像,偏移地址完全相同。

3.6.3 BPF jited 编译产物

这一阶段发生在上一节的编译产物之后,是
JIT
编译的产物。
JIT
之后,eBPF 字节码(此时应该称其为机器码了)就具有了运行在本机 CPU 上的能力,虽然已经很底层了,但它仍然与一般的机器码不同。
bytes_jited
字段告知了我们这一部分机器码的长度。

其实有两种方式运行 eBPF 程序。我们现在讨论的,是使用 JIT 编译器生成机器码然后执行。另一种方式是,直接解释运行 eBPF 字节码。

显然 JIT 方式性能更强。

bpftool
工具能够将
JIT 机器码
输出为汇编语言。

$ bpftool prog dump jited name hello

输出如下:

int hello(void * ctx):
bpf_prog_ec5542c3187de469_hello:
; int hello(void *ctx) {                                                        // D
   0:   nopl   0x0(%rax,%rax,1)
   5:   xchg   %ax,%ax
   7:   push   %rbp
   8:   mov    %rsp,%rbp
   b:   sub    $0x10,%rsp
  12:   push   %rbx
  13:   movabs $0xa642520646c72,%rdi
; bpf_printk("Hello World %d\n", counter);
  1d:   mov    %rdi,-0x8(%rbp)
  21:   movabs $0x6f57206f6c6c6548,%rdi
  2b:   mov    %rdi,-0x10(%rbp)
  2f:   movabs $0xffffba56c0362000,%rbx
  39:   mov    0x0(%rbx),%edx
  3c:   mov    %rbp,%rdi
;
  3f:   add    $0xfffffffffffffff0,%rdi
; bpf_printk("Hello World %d\n", counter);
  43:   mov    $0x10,%esi
  48:   callq  0xffffffffed7f1930
; counter++;
  4d:   mov    0x0(%rbx),%edi
  50:   add    $0x1,%rdi
  54:   mov    %edi,0x0(%rbx)
; return XDP_PASS;
  57:   mov    $0x2,%eax
  5c:   pop    %rbx
  5d:   leaveq
  5e:   retq

有些版本的 bpftool 不支持输出 JIT 产物。可以参考:
https://github.com/libbpf/bpftool

到目前为止,eBPF 程序已经被载入内核,但并没有和任何事件关联绑定,现在什么都触发不了它。接下来,我们给它装上开关。

3.7 绑定一个事件

eBPF 程序只能绑定到和他类型匹配的事件上去。(详见第 7 章)当前的例子是一个
xdp
程序,因此需要绑定到网络接口的
XDP
事件上去。

使用下面的命令,如果绑定成功,什么也不会输出。

$ bpftool net attach xdp id 5 dev enp0s8

在这个命令中,我们通过 ID 来绑定对应的 eBPF 程序。当然使用 name 或 tag 来指定 eBPF 程序也是 OK 的。

注意,我们指定了
enp0s8
这个网卡(译者使用的是虚拟机,但是不影响)。

现在,我们可以使用以下命令查看 eBPF 的所有网络事件绑定列表:

$ bpftool net list
xdp:
enp0s8(3) generic id 5

tc:

flow_dissector:

能够看到,ID 为 5 的 eBPF 程序已经被绑定到
enp0s8
网卡的
XDP
事件上了。后面的
tc

flow_dissector
我们第 7 章再详细讨论。

除此之外,你还可以使用
ip link
命令查看网络接口信息,本机输出如下:

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    ···
3: enp0s8: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdpgeneric qdisc fq_codel state UP mode DEFAULT group default qlen 1000
    ···
    prog/xdp id 5 tag ec5542c3187de469 jited
···

你可以看到
enp0s8
网卡接口上绑定的 eBPF 程序信息,包括:ID、tag 信息以及被 JIT 编译过。

lo
是本机回环网络接口,用于同一台计算机的内部通信(不需要经过物理网络)。
lo
的 IP 地址通常是固定的,为
127.0.0.1

ip link
命令也可以被用于绑定和解绑
xdp
程序,第 7 章再说。

那么,此时此刻,我们的
hello()
eBPF 程序就可以发挥它的作用了。当每有网络包到达
enp0s8
时,都会向
/sys/kernel/debug/tracing/trace_pipe
中输出一次
Hello World

你可以使用
cat
查看输出:

$ cat /sys/kernel/debug/tracing/trace_pipe
 <idle>-0       [003] d.s. 56972.929829: bpf_trace_printk: Hello World 170
 <idle>-0       [003] dNs. 56973.582190: bpf_trace_printk: Hello World 171
	sshd-64304   [003] d.s1 56973.592084: bpf_trace_printk: Hello World 172
	sshd-64304   [003] d.s1 56973.596605: bpf_trace_printk: Hello World 173
 <idle>-0       [003] d.s. 56974.426690: bpf_trace_printk: Hello World 174

你也可以使用
bpftool prog tracelog
查看相同的内容。

$ bpftool prog tracelog

现在,我们将上述输出结果来和第 2 章的 eBPF 程序对比一下。

首先,系统调用事件和
xdp
事件是两个完全不同的内核事件。

在系统调用事件中,进程通过执行系统调用,从用户态陷入内核态,并以此来触发 eBPF 程序的执行。此时 eBPF 函数所处的上下文是进程相关的信息。


xdp
事件中,一旦有网络包到达指定网卡,eBPF 程序就发生了。此时内核对于接收到的网络包是啥一无所知。更有甚者,内核对于网络包的去留也不能独断。

在上述的输出中,每一行的
Hello World
之后跟随着一个不断递增的数字,这就是我们定义的
counter
计数器。这个
counter
是一个全局变量,并且我们前文提到过,它实际上是由
BPF_MAP
实现的([3.6 载入后的 eBPF 全貌](#3.6-载入后的 eBPF 全貌))。

接下来,我们来瞧一瞧 eBPF 程序中的全局变量。

3.8 全局变量

为啥
BPF_MAP
可以用作全局变量?

这很好理解。我们前面的章节说过,
BPF_MAP
这种结构是静态的,存放在一段特定的内存中。它不仅允许从用户空间访问,还允许一段 eBPF 程序在多次运行中访问,甚至允许多个不同的 eBPF 程序来访问。

BPF_MAP
的这种特性,用来当做全局变量再好不过了。

2019 年 2 月,全局变量才被正式地引入 eBPF。

见:
https://lore.kernel.org/bpf/20190228231829.11993-7-daniel@iogearbox.net/t/#u

同样的,你可以使用
bpftool
来查看内核空间的
BPF_MAP

$ bpftool map list
3: array  name hello.bss  flags 0x400
        key 4B  value 4B  max_entries 1  memlock 8192B
        btf_id 5

和前文我们得出的 eBPF 程序信息一样,
hello()
程序被 ID 为 3 的 map 所关联。

bss
(block started by symbol)实际上是一个目标文件内的其中一个
section
,其通常用于存放全局变量。我们继续使用
bpftool
来查看它的内容。

$ bpftool map dump name hello.bss
[{
        "value": {
            ".bss": [{
                    "counter": 780
                }
            ]
        }
    }
]

上面的结果,你也可以使用
bpftool map dump id 3
命令得到。

注意,我们查看的
BPF_MAP
被应用为全局变量,是会实时变化的。上述给出的内容实际上是某一时刻下的内容。

书中提到,如果在编译时指定了
-g
,并且当前
BTF
信息可用,
bpftool map dump name hello.bss
就会给出一个很漂亮的输出:

![image-20240124101136607](D:\lianyihong\DeskTop\学习资料\eBPF\Learning eBPF.assets\image-20240124101136607.png)

有关
BTF
,我们将在第 5 章深入探讨。

书中的例子,在编译后,还能够看到一个名为
hello.rodata
的 map,这是一段只读的信息。这里不再赘述,有兴趣可以查看原书。

到目前为止,我们已经完整的查看了整个 eBPF 在内核中的样貌了。是时候把它清理掉了。

清理需要分两步:

  • 和事件解绑。
  • 从内核卸载。

3.9 清理-1:和事件解绑

解绑事件的操作与绑定操作正好相反。

$ bpftool net detach xdp dev enp0s8

这个命令如果执行成功了,啥也不会输出,我们可以使用
bpftool net list
看一下。

$ bpftool net list
xdp:

tc:

flow_dissector:

解绑事件成功。

3.10 清理-2:从内核卸载

解绑事件并不会影响 eBPF 程序在内核中的加载状态。用
bpftool
工具看一下:

$ bpftool prog show id 5
5: xdp  name hello  tag ec5542c3187de469  gpl
        loaded_at 2024-01-23T08:33:12+0800  uid 0
        xlated 144B  jited 95B  memlock 4096B  map_ids 3
        btf_id 5

还在内核空间。

但是,
bpftool
到书成为止,还没有提供直接卸载 eBPF 程序的命令。但是我们可以这样做:

$ rm -f /sys/fs/bpf/hello

再次查看名称为
hello()
的 eBPF 程序:

$ bpftool prog show name hello

恭喜你,这个 eBPF 程序已经成功从内核态卸载了。

3.11 BPF 和 BPF 调用

eBPF 是支持函数调用的。注意啊,这里说的不是前文提到的尾调用(Tail Call),而是正儿八百的函数调用。即,将一部分逻辑抽象成自定义函数,然后在 eBPF 程序中调用它。

举个例子,我们魔改一下第二章的尾调用程序,让它来绑定系统系统调用
sys_enter
的追踪点。我们来看一看 eBPF 是如何抽象和调用函数的。

代码位置:chapter3/hello-func.bpf.c

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

static __attribute((noinline)) int get_opcode(struct bpf_raw_tracepoint_args *ctx) {
    return ctx->args[1];
}

SEC("raw_tp/")
int hello(struct bpf_raw_tracepoint_args *ctx) {
    int opcode = get_opcode(ctx);
    bpf_printk("Syscall: %d", opcode);
    return 0;
}

char LICENSE[] SEC("license") = "Dual BSD/GPL";

我们将获取 opcode 动作抽象成函数,并声明其为
static
静态的。使用方式和几乎和正常 C 函数一样。

不过,这里我们使用了
__attribute((noinline))
来规定编译器不要将我们的函数编译成内联函数的形式(正常来讲,编译器会对 eBPF 函数做内联优化)。

在对应目录下使用
make
进行编译(Makefile 文件参考前文),并使用
bpftool
将其载入内核。

$ bpftool prog load hello-func.bpf.o /sys/fs/bpf/hello
$ bpftool prog list name hello
4: raw_tracepoint  name hello  tag c86c2cef74f2057a  gpl
        loaded_at 2024-01-25T09:49:22+0800  uid 0
        xlated 120B  jited 86B  memlock 4096B
        btf_id 5

继续,查看字节码:

$ bpftool prog dump xlated name hello

字节码如下:

int hello(struct bpf_raw_tracepoint_args * ctx):
; int opcode = get_opcode(ctx);							; A
   0: (85) call pc+12#bpf_prog_cbacc90865b1b9a5_F
   1: (b7) r1 = 6563104
; bpf_printk("Syscall: %d", opcode);
   2: (63) *(u32 *)(r10 -8) = r1
   3: (18) r1 = 0x3a6c6c6163737953
   5: (7b) *(u64 *)(r10 -16) = r1
   6: (bf) r1 = r10
;
   7: (07) r1 += -16
; bpf_printk("Syscall: %d", opcode);
   8: (b7) r2 = 12
   9: (bf) r3 = r0
  10: (85) call bpf_trace_printk#-57216
; return 0;
  11: (b7) r0 = 0
  12: (95) exit
int get_opcode(struct bpf_raw_tracepoint_args * ctx):	; B
; return ctx->args[1];
  13: (79) r0 = *(u64 *)(r1 +8)
; return ctx->args[1];
  14: (95) exit

代码解释:

【A】在这一行,我们可以看到 eBPF 程序调用了
get_opcode()
函数,第 0 条指令的操作码为
0x85
,代表函数调用。这条指令中的
call pc+12
,代表下一条即将被执行的指令为当前 pc(程序计数器)向前移动 12 次的位置,也就是指令 13。

【B】这一部分是
get_opcode()
函数的字节码,起始位置就在指令 13。

函数调用指令会将当前状态保存在 eBPF 虚拟机运行栈上,和一般的函数调用无二,当被调用者退出时,调用者将接续之前的状态运行。

注意,前文在介绍尾调用时提到过:eBPF 运行栈仅有 512 字节大小,因此设计多层函数调用的嵌套是非常不明智的选择。

3.12 小结

本章深入剖析了一个基于 C 语言的 eBPF 程序从被编码、编译,到载入内核、绑定事件,再到执行、卸载的全过程。在这期间,我们使用了
bpftool
这个利器,作为掌控 eBPF 程序的强大法宝。

此外,我们了解了不同的 eBPF 事件种类(kprobe、tracepoint、xdp),以及他们的触发时机和简单区别。

我们也学习了如何使用
BPF_MAP
结构来实现全局变量,以及如何在 eBPF 程序中抽象和定义函数,在某种程度上便捷了我们的 eBPF 编程。

那么在下一章中,我们将继续深入
bpf()
系统调用的机理。在使用
bpftool
的时候究竟发生了什么?系统如何将我们的 eBPF 程序载入内核?又是如何绑定到某个事件上的?且听下回分解。

本文分享自华为云社区《
Python构建机器学习API服务从模型到部署的完整指南
》,作者: 柠檬味拥抱。

在当今数据驱动的世界中,机器学习模型在解决各种问题中扮演着重要角色。然而,将这些模型应用到实际问题中并与其他系统集成,往往需要构建API服务。本文将介绍如何使用Python构建机器学习模型的API服务,并提供案例代码作为示例。

1. 确定模型

首先,我们需要选择并训练一个适当的机器学习模型。这可能涉及数据收集、预处理、特征工程和模型训练等步骤。在本文中,我们将以一个简单的示例来说明,假设我们已经有一个训练好的线性回归模型,用于预测房屋价格。

fromsklearn.linear_model import LinearRegression
import numpy
asnp

# 生成示例数据
X
= np.array([[1], [2], [3], [4], [5]])
y
= np.array([1, 2, 3, 4, 5])

# 训练线性回归模型
model
=LinearRegression()
model.fit(X, y)

2. 构建API服务

接下来,我们将使用Python中的Flask框架构建API服务,以便将我们的机器学习模型部署为可访问的API。

fromflask import Flask, request, jsonify

app
=Flask(__name__)

# 定义API端点
@app.route(
'/predict', methods=['POST'])
def predict():
data
=request.json # 获取POST请求中的JSON数据
x_value
= data['x'] # 提取输入特征值

# 使用模型进行预测
prediction
=model.predict([[x_value]])

# 返回预测结果
return jsonify({'prediction': prediction[0]})if __name__ == '__main__':
app.run(debug
=True)

3. 测试API服务

现在,我们已经构建了API服务,可以通过向
/predict
端点发送POST请求来获取模型预测结果。

import requests

# 定义要发送的数据
data
= {'x': 6}

# 发送POST请求
response
= requests.post('http://localhost:5000/predict', json=data)

# 打印预测结果
print(
'预测结果:', response.json()['prediction'])

4. 测试API服务

现在,我们已经构建了API服务,可以通过向
/predict
端点发送POST请求来获取模型预测结果。

import requests

# 定义要发送的数据
data
= {'x': 6}

# 发送POST请求
response
= requests.post('http://localhost:5000/predict', json=data)

# 打印预测结果
print(
'预测结果:', response.json()['prediction'])

5. 添加数据验证

在构建API服务时,数据验证是非常重要的一步,可以确保输入数据的有效性和安全性。我们可以使用Python中的Flask框架的扩展库Flask-WTF或Flask-RESTful来实现数据验证功能。下面是一个使用Flask-WTF进行数据验证的示例:

fromflask import Flask, request, jsonifyfromwtforms import Form, FloatField, validators

app
=Flask(__name__)

# 定义表单类来验证输入数据
classInputForm(Form):
x
= FloatField('x', [validators.InputRequired()])

# 定义API端点
@app.route(
'/predict', methods=['POST'])
def predict():
form
=InputForm(request.form)ifform.validate():
x_value
= form.data['x'] # 提取输入特征值

# 使用模型进行预测
prediction
=model.predict([[x_value]])

# 返回预测结果
return jsonify({'prediction': prediction[0]})else:return jsonify({'error': 'Invalid input'})if __name__ == '__main__':
app.run(debug
=True)

6. 部署到生产环境

在完成API服务的开发后,我们需要将其部署到生产环境中,以便其他系统可以访问。您可以选择各种方式来部署,如使用容器化技术(如Docker)进行部署,或将其部署到云服务提供商的托管服务上(如AWS、Azure或Google Cloud)。无论选择哪种方式,都需要确保服务的安全性、可靠性和可扩展性。

7. 进一步优化

除了上述步骤外,您还可以进一步优化API服务,例如添加日志记录、监控服务性能、实现负载均衡等,以确保服务的稳定性和可用性。

通过以上步骤,您可以成功地构建一个机器学习模型的API服务,并将其部署到生产环境中,从而为其他系统提供预测功能。

8. 添加安全性措施

在实际生产环境中,确保API服务的安全性是至关重要的。您可以采取一些措施来增强API服务的安全性,例如:

  • 使用HTTPS协议来保护数据传输的安全性。
  • 实现身份验证和授权机制,以确保只有授权用户才能访问API服务。
  • 对输入数据进行严格的验证和过滤,以防止恶意攻击,如SQL注入、XSS攻击等。

9. 实现模型更新机制

随着时间的推移,您的机器学习模型可能需要定期更新以适应新的数据和情境。因此,实现模型更新机制是很重要的。您可以定期重新训练模型,并将新的模型替换旧的模型。在替换模型时,确保服务的平稳过渡,以避免影响现有的系统功能。

10. 监控和日志记录

在生产环境中,及时监控API服务的运行状况并记录日志是至关重要的。您可以使用各种监控工具来监测服务的性能指标,如响应时间、请求量等,并实时发现并解决潜在的问题。同时,记录详细的日志可以帮助您跟踪和排查问题,以及分析用户行为和模型性能。

11. 扩展功能和性能优化

除了基本功能之外,您还可以考虑添加一些扩展功能以及对API服务进行性能优化,例如:

  • 异步处理: 对于一些需要较长时间运行的任务,如模型推理过程中的复杂计算,您可以考虑使用异步处理来提高API的响应速度和并发处理能力。可以使用Python中的异步框架,如AsyncIO或Celery等。

  • 缓存机制: 对于频繁被请求的数据或计算结果,可以考虑使用缓存机制来减少重复计算并提高响应速度。常用的缓存技术包括内存缓存、Redis等。

  • API文档和Swagger集成: 添加API文档可以方便用户了解API的功能和使用方法。您可以使用工具如Swagger来自动生成API文档,并提供交互式的API测试界面,让用户更方便地使用API服务。

12. 实现模型监控和反馈机制

一旦API服务上线运行,您还需要考虑监控模型的性能,并收集用户的反馈以不断改进模型。您可以通过实现模型性能监控机制来定期检查模型的准确性和稳定性,并及时发现并解决模型出现的问题。同时,收集用户的反馈和建议,以便根据实际需求调整模型参数或重新训练模型。

13. 持续集成和持续部署(CI/CD)

为了保证API服务的稳定性和可靠性,建议实现持续集成和持续部署(CI/CD)流程。通过CI/CD流程,您可以自动化测试、构建和部署过程,快速检测和修复代码中的问题,并将新的功能快速交付到生产环境中,从而提高开发和部署效率。

14. 安全备份和容灾方案

最后但同样重要的是,确保API服务的安全备份和容灾方案。定期备份数据和代码,以防止意外数据丢失或系统故障。同时,考虑部署在多个地理位置的服务器上,并实现自动切换和负载均衡机制,以确保服务的高可用性和容错能力。

总结

构建机器学习模型的API服务是将机器学习模型应用到实际问题中的重要一环。本文介绍了使用Python构建这样的API服务的基本步骤,并提供了示例代码和一些实用的建议。

首先,我们选择并训练了一个简单的机器学习模型作为示例,即线性回归模型用于房价预测。然后,使用Python中的Flask框架构建了一个简单的API服务,并通过POST请求向
/predict
端点发送数据来获取模型预测结果。接着,我们介绍了如何使用Flask-WTF来进行输入数据的验证,以确保API服务的安全性。随后,我们讨论了一些在生产环境中部署API服务时需要考虑的方面,包括安全性、模型更新、监控和日志记录等。最后,我们提出了一些扩展功能和性能优化措施,如异步处理、缓存机制、API文档、持续集成和持续部署、安全备份和容灾方案等,以进一步提升API服务的性能、稳定性和用户体验。

通过本文的指导,读者可以学习如何使用Python构建机器学习模型的API服务,并了解到在实际应用中需要考虑的一些关键问题和解决方案,从而为自己的项目提供更好的支持和服务。

点击关注,第一时间了解华为云新鲜技术~

@


前言

请各大网友尊重本人原创知识分享,谨记本人博客: 南国以南i


提示:以下是本篇文章正文内容,下面案例可供参考

简介

在Linux中可以使用
crontab
来定时执行脚本。
crontab
是一个用于管理定时任务的工具,可以让在特定的时间间隔内自动运行脚本或命令。

需求:
在Linux中每分钟写入一条信息到指定文件中,使用
cron
来定期执行一个写入命令或脚本

在Linux中,定时执行脚本具有多种作用和用途,包括但不限于以下几个方面:

  1. 自动化任务
    :定时执行脚本可用于自动化重复性的任务。例如,定时备份数据、定时清理临时文件、定时运行系统维护任务等。通过将这些任务脚本定期执行,可以减轻管理员的工作负担,并确保任务按计划执行,提高系统的可靠性和稳定性。
  2. 监控和报告
    :定时执行脚本可用于监控系统状态、资源使用情况和日志文件,并生成相应的报告。例如,定时检查服务器的CPU使用率、磁盘空间占用情况、网络连接状态等,并将报告发送给管理员或其他相关人员。这样可以帮助及时发现问题并采取适当的措施。
  3. 数据处理和分析
    :定时执行脚本可用于处理和分析数据。例如,定期从外部数据源获取数据,并将其导入到数据库中进行进一步处理和分析。这对于数据采集、数据清洗、数据转换和数据分析等任务非常有用。
  4. 定时提醒和通知
    :定时执行脚本可用于生成提醒和通知。例如,定时发送电子邮件或短信提醒,以及生成日程安排或事件提醒。这对于个人、团队或组织来管理任务、安排会议或发送重要通知非常有帮助。
  5. 自定义批处理任务
    :定时执行脚本可用于运行自定义的批处理任务。根据特定的需求和工作流程,你可以编写脚本来执行一系列操作,如文件处理、数据转换、编译构建等。通过定时运行这些脚本,可以简化复杂的工作流程并提高工作效率。

总之,定时执行脚本在Linux系统中具有广泛的应用。通过利用定时任务,可以实现
自动化、监控、报告、数据处理
等各种任务,从而提高系统的
可靠性、效率
和管理效果

一、准备工作

1.1 创建写入脚本

注意
:首先,在
home
目录下创建一个名为
testscript.sh
的脚本文件,并添加以下内容:

 #!/bin/bash
echo "This is a log message $(date)" > /home/out.log

在这个示例中,脚本会将一条带有当前日期和时间的日志消息附加到指定的日志文件中。确保将
/home/out.log
替换为实际的日志文件路径。

保存并关闭文件。

1.2 设置执行权限

注意
:在执行脚本之前,确保为其设置执行权限。在终端中运行以下命令:

chmod 755 /home/testscript.sh

1.3 添加定时任务

crontab
表的每一行代表一个定时任务,定时任务语法格式如下:

* * * * * command
 
第一个*表示分钟数(0-59)
第二个*表示小时数(0-23)
第三个*表示一个月中的第几天(1-31)
第四个*表示月份(1-12)
第五个*表示一周中的第几天(0-7,0和7都表示星期日)
command是要执行的命令或脚本

例如,要在
每天的凌晨12点
执行脚本,添加以下行到
cron
表中:
我们可以在
Crontab
的命令中添加重定向符号 ">>" 将输出重定向到指定的文件中。例如:

crontab -l # 查看当前是否存在任务
crontab -e # 编辑 指定执行哪个脚本以及执行时间

# 每日凌晨执行脚本
0 0 * * * /home/testscript.sh >> /home/testscript.log 2>&1     

1.4 配置生效

service crond reload    //重新载入配置  前提是crond已经启动

二、Tomcat日志 按每天分割

2.1 创建一个 sh文件

创建脚本

#创建脚本
vim auto-deal-log.sh

脚本内容:

#! /bin/bash
echo "date is `date +%Y-%m-%d,%H:%M:%S`"
echo "tomcat-sca日志分割"
sca_log_path=/home/admin/sca/tomcat-sca/logs
cp $sca_log_path/catalina.out $sca_log_path/catalina.`date +%Y-%m-%d`.log

# 清空tomcat默认日志输出文件
echo "" > $sca_log_path/catalina.out

echo "nginx日志分割"
nginx_log_path=/home/admin/mid/nginx/logs
cp $nginx_log_path/access.log $nginx_log_path/access.`date +%Y-%m-%d`.log
cp $nginx_log_path/error.log $nginx_log_path/error.`date +%Y-%m-%d`.log
echo "" > $nginx_log_path/access.log
echo "" > $nginx_log_path/error.log


# 查找并清理不需要的文件
echo "清理tomcat*,保留30天"
find /home/admin/sca/tomcat*/logs/ -mtime +30 -name "*" -exec rm -rf {} \;
echo "清理batch日志,保留30天"
find /home/admin/sca/log/batch/ -mtime +30 -name "*" -exec rm -rf {} \;
echo "清理xxl-job日志,保留7天"
find /home/admin/sca/log/xxl-job/ -mtime +7 -name "*" -exec rm -rf {} \;
echo "清理auto-split-deal-log日志,保留7天"
find /home/admin/sca/log/auto-split-deal-log/ -mtime +7 -name "*" -exec rm -rf {} \;
echo "清理视频文件,保留5天"
find /home/admin/sca/ccrecord/ -mtime +5 -name "*.wev" -exec rm -rf {} \;
echo "日志分割及清理完毕"

2.2 设置执行权限

chmod +x auto-deal-log.sh

2.3 设置crontab指令,指定每日定时任务

# 每日凌晨执行分割日志脚本
0 0 * * * /opt/sh/auto-deal-log.sh  >> /path/to/auto-deal-log.log 2>&1   

2.4 配置生效

service crond reload    //重新载入配置  前提是crond已经启动

总结

我是
南国以南i
记录点滴每天成长一点点,学习是永无止境的!转载请附原文链接!!!

前言

vue3.4增加了
defineModel
宏函数,在子组件内修改了
defineModel
的返回值,父组件上
v-model
绑定的变量就会被更新。大家都知道
v-model

:modelValue

@update:modelValue
的语法糖,但是你知道为什么我们在子组件内没有写任何关于
props
的定义和
emit
事件触发的代码吗?还有在
template
渲染中
defineModel
的返回值等于父组件
v-model
绑定的变量值,那么这个返回值是否就是名为
modelValue
的props呢?直接修改
defineModel
的返回值就会修改父组件上面绑定的变量,那么这个行为是否相当于子组件直接修改了父组件的变量值,破坏了vue的单向数据流呢?

先说答案

defineModel
宏函数经过编译后会给vue组件对象上面增加
modelValue
的props选项和
update:modelValue
的emits选项,执行
defineModel
宏函数的代码会变成执行
useModel
函数,如下图:
convert

经过编译后
defineModel
宏函数已经变成了
useModel
函数,而
useModel
函数的返回值是一个ref对象。注意这个是ref对象不是props,所以我们才可以在组件内直接修改
defineModel
的返回值。当我们对这个
ref
对象进行“读操作”时,会像
Proxy
一样被拦截到
ref
对象的get方法。在get方法中会返回本地维护
localValue
变量,
localValue
变量依靠
watchSyncEffect

localValue
变量始终和父组件传递的
modelValue

props
值一致。

对返回值进行“写操作”会被拦截到
ref
对象的set方法中,在set方法中会将最新值同步到本地维护
localValue
变量,调用vue实例上的emit方法抛出
update:modelValue
事件给父组件,由父组件去更新父组件中
v-model
绑定的变量。如下图:
useModel

所以在子组件内无需写任何关于
props
的定义和
emit
事件触发的代码,因为在编译
defineModel
宏函数的时候已经帮我们生成了
modelValue
的props选项。在对返回的ref变量进行写操作时会触发set方法,在set方法中会调用vue实例上的emit方法抛出
update:modelValue
事件给父组件。

defineModel
宏函数的返回值是一个ref变量,而不是一个props。所以我们可以直接修改
defineModel
宏函数的返回值,父组件绑定的变量之所以会改变是因为在底层会抛出
update:modelValue
事件给父组件,由父组件去更新绑定的变量,这一行为当然满足vue的单向数据流。

什么是vue的单向数据流

vue的单向数据流是指,通过props将父组件的变量传递给子组件,在子组件中是没有权限去修改父组件传递过来的变量。只能通过
emit
抛出事件给父组件,让父组件在事件回调中去修改props传递的变量,然后通过props将更新后的变量传递给子组件。在这一过程中数据的流动是单向的,由父组件传递给子组件,只有父组件有数据的更改权,子组件不可直接更改数据。
single-progress

一个
defineModel
的例子

我在前面的
一文搞懂 Vue3 defineModel 双向绑定:告别繁琐代码!
文章中已经讲过了
defineModel
的各种用法,在这篇文章中我们就不多余赘述了。我们直接来看一个简单的
defineModel
的例子。

下面这个是父组件的代码:

<template>
  <CommonChild v-model="inputValue" />
  <p>input value is: {{ inputValue }}</p>
</template>

<script setup lang="ts">
import { ref } from "vue";
import CommonChild from "./child.vue";

const inputValue = ref();
</script>

父组件的代码很简单,使用
v-model
指令将
inputValue
变量传递给子组件。然后在父组件上使用p标签渲染出
inputValue
变量的值。

我们接下来看子组件的代码:

<template>
  <input v-model="model" />
  <button @click="handelReset">reset</button>
</template>

<script setup lang="ts">
const model = defineModel();

function handelReset() {
  model.value = "init";
}
</script>

子组件内的代码也很简单,将
defineModel
的返回值赋值给
model
变量。然后使用
v-model
指令将
model
变量绑定到子组件的input输入框上面。并且还在按钮的click事件时使用
model.value = "init"
将绑定的值重置为
init
字符串。请注意在子组件中我们没有任何定义props的代码,也没有抛出
emit
事件的代码。而是通过
defineModel
宏函数的返回值来接收父组件传过来的名为
modelValue
的prop,并且在子组件中是直接通过给
defineModel
宏函数的返回值进行赋值来修改父组件绑定的
inputValue
变量的值。

defineModel
编译后的样子

要回答前面提的几个问题,我们还是得从编译后的子组件代码说起。下面这个是经过简化编译后的子组件代码:

import {
  defineComponent as _defineComponent,
  useModel as _useModel
} from "/node_modules/.vite/deps/vue.js?v=23bfe016";

const _sfc_main = _defineComponent({
  __name: "child",
  props: {
    modelValue: {},
    modelModifiers: {},
  },
  emits: ["update:modelValue"],
  setup(__props) {
    const model = _useModel(__props, "modelValue");
    function handelReset() {
      model.value = "init";
    }
    const __returned__ = { model, handelReset };
    return __returned__;
  },
});

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return (
    // ... 省略
  );
}
_sfc_main.render = _sfc_render;
export default _sfc_main;

从上面我们可以看到编译后主要有
_sfc_main

_sfc_render
这两块,其中
_sfc_render

render
函数,不是我们这篇文章关注的重点。我们来主要看
_sfc_main
对象,看这个对象的样子有name、props、emits、setup属性,我想你也能够猜出来他就是vue的组件对象。从组件对象中我们可以看到已经有了一个
modelValue

props
属性,还有使用
emits
选项声明了
update:modelValue
事件。我们在源代码中没有任何地方有定义
props

emits
选项,很明显这两个是通过编译
defineModel
宏函数而来的。

我们接着来看里面的setup函数,可以看到经过编译后的setup函数中代码和我们的源代码很相似。只有
defineModel
不在了,取而代之的是一个
useModel
函数。

// 编译前的代码
const model = defineModel();

// 编译后的代码
const model = _useModel(__props, "modelValue");

还是同样的套路,在浏览器的sources面板上面找到编译后的js文件,然后给这个
useModel
打个断点。至于如何找到编译后的js文件我们在前面的文章中已经讲了很多遍了,这里就不赘述了。刷新浏览器我们看到断点已经走到了使用
useModel
函数的地方,我们这里给
useModel
函数传了两个参数。第一个参数为子组件接收的
props
对象,第二个参数是写死的字符串
modelValue
。进入到
useModel
函数内部,简化后的
useModel
函数是这样的:

function useModel(props, name) {
  const i = getCurrentInstance();
  const res = customRef((track2, trigger2) => {
    watchSyncEffect(() => {
      // 省略
    });
  });
  return res;
}

从上面的代码中我们可以看到
useModel
中使用到的函数没有一个是vue内部源码专用的函数,全都是调用的vue暴露出来的API。
这意味着我们可以参考
defineModel
的实现源码,也就是
useModel
函数,然后根据自己实际情况改良一个适合自己项目的
defineModel
函数。

我们先来简单介绍一下
useModel
函数中使用到的API,分别是
getCurrentInstance

customRef

watchSyncEffect
,这三个API都是从vue中import导入的。

getCurrentInstance
函数

首先来看看
getCurrentInstance
函数,他的作用是返回当前的vue实例。为什么要调用这个函数呢?因为在setup中this是拿不到vue实例的,后面对值进行写操作时会调用vue实例上面的emit方法抛出update事件。

watchSyncEffect
函数

接着我们来看
watchSyncEffect
函数,这个API大家平时应该比较熟悉了。他的作用是立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时立即重新执行这个函数。

比如下面这段代码,会立即执行
console
,当
count
变量的值改变后,也会立即执行console。

const count = ref(0)

watchSyncEffect(() => console.log(count.value))
// -> 输出 0

customRef
函数

最后我们来看
customRef
函数,他是
useModel
函数的核心。这个函数小伙伴们应该用的比较少,我们这篇文章只简单讲讲他的用法即可。如果小伙伴们对
customRef
函数感兴趣可以留言或者给我发消息,关注的小伙伴们多了我后面会安排一篇文章来专门讲
customRef
函数。官方的解释为:

创建一个自定义的 ref,显式声明对其依赖追踪和更新触发的控制方式。
customRef()
预期接收一个工厂函数作为参数,这个工厂函数接受
track

trigger
两个函数作为参数,并返回一个带有
get

set
方法的对象。

这句话的意思是
customRef
函数的返回值是一个ref对象。当我们对返回值ref对象进行“读操作”时,会被拦截到ref对象的get方法中。当我们对返回值ref对象进行“写操作”时,会被拦截到ref对象的set方法中。和
Promise
相似同样接收一个工厂函数作为参数,
Promise
的工厂函数是接收的
resolve

reject
两个函数作为参数,
customRef
的工厂函数是接收的
track

trigger
两个函数作为参数。
track
用于手动进行依赖收集,
trigger
函数用于手动进行依赖触发。

我们知道vue的响应式原理是由依赖收集和依赖触发的方式实现的,比如我们在template中使用一个
ref
变量。当template被编译为
render
函数后,在浏览器中执行
render
函数时,就会对
ref
变量进行读操作。读操作会被拦截到Proxy的get方法中,由于此时在执行
render
函数,所以当前的依赖就是
render
函数。在get方法中会进行依赖收集,将当前的
render
函数作为依赖收集起来。
注意这里的依赖收集是vue内部自动完成的,在我们的代码中无需手动去进行依赖收集。

当我们对
ref
变量进行写操作时,此时会被拦截到Proxy的set方法,在set方法中会将收集到的依赖依次取出来执行,我们前面收集的依赖是
render
函数。所以
render
函数就会重新执行,执行
render
函数生成虚拟DOM,再生成真实DOM,这样浏览器中渲染的就是最新的
ref
变量的值。
同样这里依赖触发也是在vue内部自动完成的,在我们的代码中无需手动去触发依赖。

搞清楚了依赖收集和依赖触发现在来讲
track

trigger
两个函数你应该就能很容易理解了,
track

trigger
两个函数可以让我们手动控制什么时候进行依赖收集和依赖触发。执行
track
函数就会手动收集依赖,执行
trigger
函数就会手动触发依赖,进行页面刷新。在
defineModel
这个场景中
track
手动收集的依赖就是
render
函数,
trigger
手动触发会导致
render
函数重新执行,进而完成页面刷新。

useModel
函数

现在我们可以来看
useModel
函数了,简化后的代码如下:

function useModel(props, name) {
  const i = getCurrentInstance();

  const res = customRef((track2, trigger2) => {
    let localValue;
    watchSyncEffect(() => {
      const propValue = props[name];
      if (hasChanged(localValue, propValue)) {
        localValue = propValue;
        trigger2();
      }
    });
    return {
      get() {
        track2();
        return localValue;
      },
      set(value) {
        if (hasChanged(value, localValue)) {
          localValue = value;
          trigger2();
        }
        i.emit(`update:${name}`, value);
      },
    };
  });
  return res;
}

从上面我们可以看到
useModel
函数的代码其实很简单,
useModel
的返回值就是
customRef
函数的返回值,也就是一个
ref
变量对象。我们看到返回值对象中有
get

set
方法,还有在
customRef
函数中使用了
watchSyncEffect
函数。

get
方法

在前面的demo中,我们在子组件的template中使用
v-model

defineModel
的返回值绑定到一个input输入框中。代码如下:

<input v-model="model" />

在第一次执行
render
函数时会对
model
变量进行读操作,而
model
变量是
defineModel
宏函数的返回值。编译后我们看到
defineModel
宏函数变成了
useModel
函数。所以对
model
变量进行读操作,其实就是对
useModel
函数的返回值进行读操作。我们看到
useModel
函数的返回值是一个自定义ref,在自定义ref中有get和set方法,当对自定义
ref
进行读操作时会被拦截到
ref
对象中的get方法。这里在
get
方法中会手动执行
track2
方法进行依赖收集。因为此时是在执行
render
函数,所以收集到的依赖就是
render
函数,然后将本地维护的
localValue
的值进行拦截返回。

set
方法

在我们前面的demo中,子组件reset按钮的click事件中会对
defineModel
的返回值
model
变量进行写操作,代码如下:

function handelReset() {
  model.value = "init";
}

和对
model
变量“读操作”同理,对
model
变量进行“写操作”也会被拦截到返回值
ref
对象的set方法中。在
set
方法中会先判断新的值和本地维护的
localValue
的值比起来是否有修改。如果有修改那就将更新后的值同步更新到本地维护的
localValue
变量,这样就保证了本地维护的
localValue
始终是最新的值。然后执行
trigger2
函数手动触发收集的依赖,在前面
get
的时候收集的依赖是
render
函数,所以这里触发依赖会重新执行render函数,然后将最新的值渲染到浏览器上面。

在set方法中接着会调用vue实例上面的
emit
方法进行抛出事件,代码如下:

i.emit(`update:${name}`, value)

这里的
i
就是
getCurrentInstance
函数的返回值。前面我们讲过了
getCurrentInstance
函数的返回值是当前vue实例,所以这里就是调用vue实例上面的
emit
方法向父组件抛出事件。这里的
name
也就是调用
useModel
函数时传入的第二个参数,我们来回忆一下前面是怎样调用
useModel
函数的 ,代码如下:

const model = _useModel(__props, "modelValue")

传入的第一个参数为当前的
props
对象,第二个参数是写死的字符串
"modelValue"
。那这里调用
emit
抛出的事件就是
update:modelValue
,传递的参数为最新的value的值。
这就是为什么不需要在子组件中使用使用
emit
抛出事件,因为在
defineModel
宏函数编译成的
useModel
函数中已经帮我们使用
emit
抛出事件了。

watchSyncEffect
函数

我们接着来看子组件中怎么接收父组件传递过来的props呢,答案就在
watchSyncEffect
函数中。回忆一下前面讲过的
useModel
函数中的
watchSyncEffect
代码如下:

function useModel(props, name) {
  const res = customRef((track2, trigger2) => {
    let localValue;
    watchSyncEffect(() => {
      const propValue = props[name];
      if (hasChanged(localValue, propValue)) {
        localValue = propValue;
        trigger2();
      }
    });
    return {
     // ...省略
    };
  });
  return res;
}

这个
name
也就是调用
useModel
函数时传过来的第二个参数,我们前面已经讲过了是一个写死的字符串
"modelValue"
。那这里的
const propValue = props[name]
就是取父组件传递过来的名为
modelValue

prop
,我们知道
v-model
就是
:modelValue
的语法糖,所以这个
propValue
就是取的是父组件
v-model
绑定的变量值。如果本地维护的
localValue
变量的值不等于父组件传递过来的值,那么就将本地维护的
localValue
变量更新,让
localValue
变量始终和父组件传递过来的值一样。并且触发依赖重新执行子组件的
render
函数,将子组件的最新变量的值更新到浏览器中。为什么要调用
trigger2
函数呢?原因是可以在子组件的template中渲染
defineModel
函数的返回值,也就是父组件传递过来的prop变量。如果父组件传递过来的prop变量值改变后不重新调用
trigger2
函数以重新执行
render函数
,那么子组件中的渲染的变量值就一直都是旧的值了。因为这个是在
watchSyncEffect
内执行的,所以每次父组件传过来的
props
值变化后都会再执行一次,让本地维护的
localValue
变量的值始终等于父组件传递过来的值,并且子组件页面上也始终渲染的是最新的变量值。

这就是为什么在子组件中没有任何
props
定义了,因为在
defineModel
宏函数编译后会给vue组件对象塞一个
modelValue
的prop,并且在
useModel
函数中会维护一个名为
localValue
的本地变量接收父组件传递过来的
props.modelValue
,并且让
localValue
变量和
props.modelValue
的值始终保持一致。

总结

现在我们可以回答前面提的几个问题了:

  • 使用
    defineModel
    宏函数后,为什么我们在子组件内没有写任何关于
    props
    定义的代码?

    答案是本地会维护一个
    localValue
    变量接收父组件传递过来的名为
    modelValue
    的props。调用
    defineModel
    函数的代码经过编译后会变成一个调用
    useModel
    函数的代码,
    useModel
    函数的返回值是一个
    ref
    对象。当我们对
    defineModel
    的返回值进行“读操作”时,类似于
    Proxy

    get
    方法一样会对读操作进行拦截到返回值
    ref
    对象的
    get
    方法中。而
    get
    方法的返回值为本地维护的
    localValue
    变量,在
    watchSyncEffect
    的回调中将父组件传递过来的名为
    modelValue
    的props赋值给本地维护的
    localValue
    变量。并且由于是在
    watchSyncEffect
    中,所以每次
    props
    改变都会执行这个回调,所以本地维护的
    localValue
    变量始终是等于父组件传递过来的
    modelValue
    。也正是因为
    defineModel
    宏函数的返回值是一个ref对象而不是一个prop,所以我们可以在子组件内直接将
    defineModel
    的返回值使用
    v-model
    绑定到子组件input输入框上面。

  • 使用
    defineModel
    宏函数后,为什么我们在子组件内没有写任何关于
    emit
    事件触发的代码?

    答案是因为调用
    defineModel
    函数的代码经过编译后会变成一个调用
    useModel
    函数的代码,
    useModel
    函数的返回值是一个
    ref
    对象。当我们直接修改
    defineModel
    的返回值,也就是修改
    useModel
    函数的返回值。类似于
    Proxy

    set
    方法一样会对写行为进行拦截到
    ref
    对象中的
    set
    方法中。在
    set
    方法中会手动触发依赖,
    render
    函数就会重新执行,浏览器上就会渲染最新的变量值。然后调用vue实例上的
    emit
    方法,向父组件抛出
    update:modelValue
    事件。并且将最新的值随着事件一起传递给父组件,由父组件在
    update:modelValue
    事件回调中将父组件中
    v-model
    绑定的变量更新为最新值。


  • template
    渲染中
    defineModel
    的返回值等于父组件
    v-model
    绑定的变量值,那么这个返回值是否就是名为
    modelValue
    的props呢?

    从第一个回答中我们知道
    defineModel
    的返回值不是props,而是一个ref对象。

  • 直接修改
    defineModel
    的返回值就会修改父组件上面绑定的变量,那么这个行为是否相当于子组件直接修改了父组件的变量值,破坏了vue的单向数据流呢?

    修改
    defineModel
    的返回值,就会更新父组件中
    v-model
    绑定的变量值。看着就像是子组件中直接修改了父组件的变量值,从表面上看着像是打破了vue的单向数据流。实则并不是那样的,虽然我们在代码中没有写过
    emit
    抛出事件的代码,但是在
    defineModel
    函数编译成的
    useModel
    函数中已经帮我们使用
    emit
    抛出事件了。所以并没有打破vue的单向数据流

关注公众号:
前端欧阳
,解锁我更多
vue
干货文章。还可以加我微信,私信我想看哪些
vue
原理文章,我会根据大家的反馈进行创作。
qrcode
wxcode

说到Python的强大的地方,那真的是太多了,优雅、简洁、丰富且强大的第三方库、开发速度快,社区活跃度高等,所以才使得Python才会如此的受欢迎。

今天给大家介绍一个特别暴力的Python库:
FuckIt

1、FuckIt介绍

FuckIt是一个Python实用工具,用于解决由于Python代码出错而导致的运行异常或崩溃。它试图解释Python代码,除去错误部分,并将修改后的代码(尽可能使其仍然与原代码保持相似)输出到控制台或文件中。

通过这个库可以帮助我们检查和修复代码中的各种问题,包括语法错误、未使用的变量和导入以及样式问题。

2、安装与使用

pip install fuckit 

使用前:

def my_func():
    print("hello")
    raise Exception("不得了,出BUG了!")
    print("world")

使用后:

import fuckit

@fuckit
def my_func():
    print("hello")
    raise Exception("不得了,出BUG了!")
    print("world")

本示例展示了如何使用FuckIt.py 来解决代码中的错误。该示例包括一个函数my_func(),包含一些代码行并且会在其中引发一个异常。“尝试”功能,意味着工具会尽可能地尝试将代码处理为可运行代码,以解决出现异常而导致程序崩溃的问题。

另外一种写法:

import fuckit

with fuckit() as f:
    f.my_func()
    f.something()
print("Done!")

3、小结

FuckIt适用于在紧急情况下或者测试阶段快速恢复代码的功能,对于Python学习者也具有一定的启发和参考意义。但在正式的生产环境下,最好还是避免使用该工具,而是通过正常的代码调试流程以确保代码的完整性和可靠性。