C++ grpc 认证示例学习

0 阅读21分钟

前言

本文根据github.com/grpc/grpc/t… grpc认证示例学习运行。更多的是学习记录,水平不高,能力有限,错漏之处,还请见谅。欢迎友好讨论。

环境信息

  • 操作系统版本:ubuntu24.04
  • CMake版本:4.2.0
  • Git版本:2.43.0
  • GCC版本:gcc 13.3.0
  • OpenSSL版本: 3.0.13

在之前的教程mp.weixin.qq.com/s/50Tep3mq7… 中给出的是在Centos 7.6下的部署流程,现在我重装了操作系统为ubuntu 24.04,并且重新编译了grpc文件。相关二进制文件可以关注公众号 只做人间不老仙,后台发送 "grpc ubuntu 编译文件"获取我编译内容的压缩包 。

代码运行流程

编译

参考 mp.weixin.qq.com/s/50Tep3mq7… 克隆仓库 github.com/EarthlyImmo… 并配置grpc依赖。

配置好后,可以先修改一下 start_build.sh 中的gcc和g++的路径。

在blog_code/auth目录下执行:

./start_build.sh

完成编译。

运行

在blog_code/auth/build/server_withssl目录下运行服务器:

./server_withssl

另起一个终端,在blog_code/auth/build/client_withssl文件夹下运行客户端:

./client_withssl

代码大部分是copy的grpc官方的示例,这里对cmake文件和目录结构做了调整,对部分注释或者日志做了调整。

代码简单分析

代码使用hellworld示例,不过是使用的异步回调API,实现非常简单。关于异步回调API更完整的介绍和示例可以参考mp.weixin.qq.com/s/4hU0XMHne…

SSL协议介绍

SSL(Secure Sockets Layer)及其后继协议 TLS(Transport Layer Security)是一种加密协议,旨在为网络通信提供保密性完整性身份验证

  • 加密:防止第三方窃听传输中的数据(例如通过抓包工具看到明文内容)。
  • 身份验证:通过数字证书验证通信双方的身份(例如客户端验证服务器的真实身份,防止中间人攻击)。
  • 数据完整性:确保数据在传输过程中未被篡改。

具体的加密实现原理,就不进行深究了。这里看SSL的相关文件使用和验证流程。

相关文件使用介绍。查看blog_code/auth/credentials文件夹,可以发现有三个文件。

这三个文件各自作用如下:

  • root.crt:根证书 (Root Certificate) ,它是一个自签名的证书,是整个信任链条的起点,也就是“信任锚”。它的公钥用于验证其签发的所有其他证书(如服务器证书)是否真实有效。在客户端代码中,加载这个文件就是告诉 gRPC:“请信任由这个根证书所签署的所有证书。
  • localhost.crt: 服务器证书 (Server Certificate) 。这是颁发给gRPC 服务器的“身份证”。它包含了服务器的域名、公钥等信息,并且最关键的是,它附带了由根证书(或其私钥)生成的数字签名。客户端可以用根证书来验证这个签名的真伪。
  • localhost.key: 服务器私钥 (Server Private Key) 。这是服务器的 绝密 文件,必须妥善保管,绝不能泄露。在 SSL 握手阶段,服务器使用它来证明自己持有与服务器证书中的公钥相匹配的私钥,从而向客户端证实“我就是我”。

可以简单总结为:key是私密的钥匙,crt是公开的身份证,root.crt是颁发身份证的权威机构的公章。

可以通过下面的流程生成这三个文件:

  • 环境准备:一台安装了 OpenSSL 的 Linux 机器,使用 OpenSSL 来扮演 私有证书颁发机构 (Private CA)

    • 执行openssl version指令可以查看当前安装的openssl版本
  • 生成根证书:执行如下指令生成

    • # 1. 生成根证书的私钥 (root.key)
      #  openssl genrsa: 调用 Openssl 工具,执行生成 RSA 私钥的命令
      # -out root.key: 指定生成的私钥文件的输出名称和路径。这里将私钥保存为当前目录下的 root.key文件
      #  2048: 指定生成的 RSA 私钥的位数(长度)。2048 位是目前在安全性和性能之间广泛推荐的平衡值,能提供足够强的安全性。
      openssl genrsa -out root.key 2048
      
      # 2. 使用私钥生成自签名的根证书 (root.crt)
      #  openssl req: 调用 Openssl 工具,执行与证书签名请求(CSR)和证书生成相关的命令
      #    -x509: 指示 req命令直接生成一个自签名的 X.509 证书,而不是默认的生成一个证书签名请求(CSR)文件。这对于创建根证书(CA)是必需的
      #  -new: 表示创建一个新的证书请求。与-x509结合使用,即基于新的请求生成自签名证书。
      #  -nodes:是 “no DES” 的缩写。它指定在生成私钥(当与 -newkey一起使用时)或使用现有私钥时,不对输出的私钥进行加密。这意味着私钥文件 (ca.key) 将不设密码保护,服务器启动时可以直接读取而无需输入密码。注意:在生产环境中,应对私钥进行加密保护以提高安全性。
      #   -key ca.key:指定用于签署此证书的私钥文件。这里使用上一步生成的 ca.key。
      #    -subj “/CN=MyTestCA”:通过命令行直接指定证书的“主题”(Subject)信息,而无需交互式提问。/CN=MyTestCA表示将证书的通用名称(Common Name)字段设置为 “MyTestCA”。对于根证书,这通常是该 CA 的可识别名称。
      #    -days 3650: 设置此证书的有效期,从生成之日算起,共 3650 天(约10年)。根证书的有效期通常设置得很长
      #   -out root.crt:指定最终生成的自签名根证书文件的输出名称和路径。这里将证书保存为当前目录下的 root.crt文件
      openssl req -x509 -new -nodes -key root.key -subj "/CN=MyTestCA" -days 3650 -out root.crt
      
    • 生成内容:

      • root.key根私钥。极其敏感,请离线、安全保管
      • root.crt根证书。这正是需要在 gRPC 客户端代码中加载的 kRootCertificate 文件。
  • 生成服务器证书 (Server Certificate):有了根证书,就可以用它来签发服务器的证书,执行如下代码 shell

    • # 1. 生成服务器的私钥 (localhost.key)
      # 各参数说明可以参考 生成根证书的私钥 指令
      openssl genrsa -out localhost.key 2048
      
      # 2. 使用服务器私钥生成一个证书签名请求 (localhost.csr)
      #    CSR 就像一张“证书申请表”,包含了服务器的信息和公钥。
      #    注意这里的 "/CN=localhost",CN (Common Name) 必须与你的服务器域名完全一致。
      openssl req -new -key localhost.key -subj "/CN=localhost" -out localhost.csr
      
      # 3. 使用根证书的私钥 (root.key) 和根证书 (root.crt) 来签署这个CSR,最终生成服务器证书 (localhost.crt)
      #   CAcreateserial是一个便捷操作选项。证书需要一个唯一的序列号。此参数指示 OpenSSL:
      #   -- 如果存在CA序列号文件(此示例为root.srl),则从中读取下一个序列号并使用
      #   -- 如果序列号文件不存在,则自动创建一个新的序列号文件(root.srl),并生成一个随机的初始序列号
      #  -- (如果不使用此参数,则需要手动通过 -set_serial <number>来指定序列号。)
      openssl x509 -req -in localhost.csr -CA root.crt -CAkey root.key -CAcreateserial -out localhost.crt -days 365
      
    • 生成内容:

      • localhost.key服务器私钥。需要将它配置在 gRPC 服务器端。
      • localhost.crt服务器证书。需要将它配置在 gRPC 服务器端。

最终生成内容:

各个文件的作用,前面都有提到。

将代码中用到的证书换成我自己生成的证书进行测试:

按照代码运行流程中的说明重新编译运行,服务器运行结果:

客户端运行结果:

SSL安全握手流程。流程如下:

流程描述:

  • 准备阶段

    • 客户端:预先加载了受信任的根证书(例如 root.crt),作为信任锚点。
    • 服务器:配置了自己的证书(localhost.crt)和对应的私钥(localhost.key)。证书中包含服务器的公钥和身份信息(如域名 localhost)。
  • TLS 握手开始

    • ClientHello:客户端向服务器发送支持的 TLS 版本、加密套件列表和一个客户端随机数(client_random) 。这个随机数用于后续密钥生成,保证每次会话的唯一性。
    • ServerHello:服务器选择一个双方都支持的加密套件,并返回一个服务器随机数(server_random)
    • Certificate:服务器发送自己的数字证书链。证书中包含服务器的公钥、颁发者信息、有效期以及 CA 的签名。
    • ServerHelloDone:服务器通知客户端自己的握手消息发送完毕。
  • 证书验证

    • 客户端使用本地信任的根证书验证服务器证书的数字签名,确保证书是由受信任的 CA 签发且未被篡改。
    • 同时检查证书中的 CN(Common Name)或 SAN(Subject Alternative Name) 是否与访问的服务器地址(如 localhost)一致,防止中间人伪造证书。
  • 密钥交换与私钥解密

    • 生成预主密钥:客户端生成一个 48 字节的随机数 pre_master_secret。这是后续所有密钥的种子。
    • 加密传输:客户端用服务器证书中的公钥加密 pre_master_secret,并通过 ClientKeyExchange 消息发送给服务器。
    • 私钥解密:服务器收到后,使用自己的私钥解密,得到 pre_master_secret这是私钥在整个握手过程中唯一的使用场景,它确保了只有真正的服务器才能解密得到预主秘钥。
  • 主密钥与会话密钥的派生

    • 双方使用相同的伪随机函数(PRF) ,结合 pre_master_secretclient_randomserver_random,计算出 48 字节的主密钥(master_secret) 。公式为: master_secret = PRF(pre_master_secret, "master secret", client_random + server_random)

    • 然后,双方从主密钥进一步派生出用于加密数据的会话密钥,包括:

      • 客户端加密密钥(用于客户端发送的数据)
      • 服务器加密密钥(用于服务器发送的数据)
      • 客户端 MAC 密钥(用于消息完整性校验)
      • 服务器 MAC 密钥
    • 这些密钥是对称密钥,速度快,适合后续大量数据传输。

  • 切换加密信道与 Finished 验证

    • ChangeCipherSpec:双方发送此消息,告知对方后续的通信将使用刚刚协商好的会话密钥进行加密。
    • Finished:双方发送第一条加密消息,内容为之前所有握手消息的摘要。接收方解密并验证摘要是否正确,以确认握手过程中没有任何消息被篡改或重放,且双方协商的密钥一致。
  • 安全数据传输

    • 握手完成后,所有 gRPC 消息(包括请求和响应)都使用会话密钥进行对称加密完整性保护
    • 攻击者即使截获网络包,也只能看到密文,无法解密或篡改内容。

在《图解HTTP》的 7.2.5 HTTPS的安全通信机制 及其相关章节,有关于SSL握手流程的描述。可以参考。

服务器代码分析

服务器只有一个简单的一元RPC(Unary RPC)的异步回调API实现。与认证示例相关的逻辑在服务器初始化部分。在调用ServerBuilder::AddListeningPort时,第二个参数是grpc::SslServerCredentials(ssl_options)。而在不使用SSL认证的示例github.com/grpc/grpc/t… grpc::InsecureServerCredentials()

可以看到,服务器SSL相关设置加载了localhost.crt和localhost.key。如前所述,这两个文件分别为:

  • localhost.crt: 服务器证书 (Server Certificate)
  • localhost.key:服务器私钥 (Server Private Key)

更加具体的这两个文件在SSL协议中的作用,可以查看 SSL协议介绍 一节。

客户端代码分析

同样的,客户端也只有一个简单的一元RPC(Unary RPC)的异步回调API实现。与认证示例相关的逻辑在客户端初始化部分。在调用grpc::CreateChannel时,第二个参数是grpc::SslCredentials(ssl_options)。而在不使用SSL认证的示例github.com/grpc/grpc/t… grpc::InsecureChannelCredentials()

可以看到,客户端SSL相关设置加载了root.crt。如前所述,root.crt 是 根证书 (Root Certificate)

更加具体的文件在SSL协议中的作用,可以查看 SSL协议介绍 一节。

抓包分析SSL握手流程

mp.weixin.qq.com/s/jPT8GBiNB… 7.6, 在ubuntu下,wireshark可以通过如下指令安装:

sudo apt update
sudo apt install wireshark
sudo apt install tshark

安装 wireshark 过程中会跳出来一个选择界面,好像是用来配置的,我看最后一句好像是说 “如果你感到疑惑,推荐选择No",然后我选了No。目前看没有影响使用,问题不大。

按照mp.weixin.qq.com/s/jPT8GBiNB…

...
Frame 4: 609 bytes on wire (4872 bits), 609 bytes captured (4872 bits)
...
Transmission Control Protocol, Src Port: 37712, Dst Port: 50051, Seq: 1, Ack: 1, Len: 517
 Transport Layer Security
    TLSv1 Record Layer: Handshake Protocol: Client Hello
         Content Type: Handshake (22)
         Version: TLS 1.0 (0x0301)
         Length: 512
         Handshake Protocol: Client Hello
             Handshake Type: Client Hello (1)
             Length: 508
             Version: TLS 1.2 (0x0303)
             Random: 5370a2b845dbf8c7f0dfe8dc19f8a8773dd922dda7f4d832618c3290a1632028
                 GMT Unix Time: May 12, 2014 18:30:16.000000000 CST
                 Random Bytes: 45dbf8c7f0dfe8dc19f8a8773dd922dda7f4d832618c3290a1632028
             Session ID Length: 32
             Session ID: 56e0dd54caa8ce7a0da4f75d671944ffa955adc06fbdd8fe7465427d3cf8286e
             Cipher Suites Length: 14
             Cipher Suites (7 suites)
                 Cipher Suite: TLS_AES_128_GCM_SHA256 (0x1301)
                 Cipher Suite: TLS_AES_256_GCM_SHA384 (0x1302)
                 Cipher Suite: TLS_CHACHA20_POLY1305_SHA256 (0x1303)
                 Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 (0xc02b)
                 Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 (0xc02c)
                 Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (0xc02f)
                 Cipher Suite: TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (0xc030)
             Compression Methods Length: 1
             Compression Methods (1 method)
...
Frame 6: 1406 bytes on wire (11248 bits), 1406 bytes captured (11248 bits)
...
Transmission Control Protocol, Src Port: 50051, Dst Port: 37712, Seq: 1, Ack: 518, Len: 1314
...
Transport Layer Security
     TLSv1.2 Record Layer: Handshake Protocol: Server Hello
         Content Type: Handshake (22)
         Version: TLS 1.2 (0x0303)
         Length: 155
         Handshake Protocol: Server Hello
             Handshake Type: Server Hello (2)
             Length: 151
             Version: TLS 1.2 (0x0303)
             Random: 2d3650fe6de12d067a9258d9844d75a11cf9537707b7d1fe3a6d577cedb84b7b
             Session ID Length: 32
             Session ID: 56e0dd54caa8ce7a0da4f75d671944ffa955adc06fbdd8fe7465427d3cf8286e
             Cipher Suite: TLS_AES_128_GCM_SHA256 (0x1301)
             Compression Method: null (0)
             Extensions Length: 79
             Extension: key_share (len=69) secp256r1
                 Type: key_share (51)
                 Length: 69
                 Key Share extension
                     Key Share Entry: Group: secp256r1, Key Exchange length: 65
                         Group: secp256r1 (23)
                         Key Exchange Length: 65
                         Key Exchange: 04ddb63e504eb6503e48a1ddf639d925e746b94520aca3ca078be320651fcf642400839044fbd28669c8abfd62c1acaab774d     cebcdc32b6189ada138802083f6d0
             Extension: supported_versions (len=2) TLS 1.3
                 Type: supported_versions (43)
                 Length: 2
                 Supported Version: TLS 1.3 (0x0304)
             [JA3S Fullstring: 771,4865,51-43]
             [JA3S: eb1d94daa7e0344597e756a1fb6e7054]
     TLSv1.3 Record Layer: Change Cipher Spec Protocol: Change Cipher Spec
         Content Type: Change Cipher Spec (20)
         Version: TLS 1.2 (0x0303)
         Length: 1
         Change Cipher Spec Message
     TLSv1.3 Record Layer: Application Data Protocol: Application Data
         Opaque Type: Application Data (23)
         Version: TLS 1.2 (0x0303)
         Length: 1143
         Encrypted Application Data [truncated]: 982fb69adb002147ff54bab398d7fb30833ee079c05d0ab88b659b56d8e94067106550c6bd3ce59778158e83a25     65c2ee5fa5b7429685289993723851147869623d6edb02780c3d4bf1ce693b6878547c7d3a974039f92f90376fd1eb9d6f932cb48d27
...
Frame 8: 156 bytes on wire (1248 bits), 156 bytes captured (1248 bits)
...
Transmission Control Protocol, Src Port: 37712, Dst Port: 50051, Seq: 518, Ack: 1315, Len: 64
Transport Layer Security
    TLSv1.3 Record Layer: Change Cipher Spec Protocol: Change Cipher Spec
        Content Type: Change Cipher Spec (20)
        Version: TLS 1.2 (0x0303)
        Length: 1
        Change Cipher Spec Message
    TLSv1.3 Record Layer: Application Data Protocol: Application Data
        Opaque Type: Application Data (23)
        Version: TLS 1.2 (0x0303)
        Length: 53
        Encrypted Application Data: 616ccbc557126a6f875a0942628fa110686b54aa9c28504cf5104b007154f6f25d0c122cff5a49836a722773a5884dea8a43edd     5a2
Frame 9: 196 bytes on wire (1568 bits), 196 bytes captured (1568 bits)
...
Transmission Control Protocol, Src Port: 37712, Dst Port: 50051, Seq: 582, Ack: 1315, Len: 104
...
Transport Layer Security
    TLSv1.3 Record Layer: Application Data Protocol: Application Data
        Opaque Type: Application Data (23)
        Version: TLS 1.2 (0x0303)
        Length: 99
        Encrypted Application Data: c73b45e45aee8405f7a8abfdd78a0ff23c41c61f34c1439db39e75c17580e4bde892059a4bf92549ad6d85af4bd94085584fd79     7e73466405db0ebe8c31410cca2cac876a5140c510c2bd18787a0f338dc73215750fa12c4443bac5ab966554b9c2956
...

从第9帧开始,就是加密通讯数据了。可以看到明显的Client Hello(第4帧,客户端向服务器)、Server Hello(第6帧,服务器向客户端) 和 Change Cipher Spec(第8帧,客户端向服务器) 的流程。这是一个 TLS 1.3 握手过程,而不是传统的 TLS 1.2。TLS 1.3 对握手消息进行了加密和合并,因此部分握手消息(如 Certificate、CertificateVerify、Finished)在抓包中显示为加密的 Application Data 记录。在 SSL协议介绍 一节给出的是TLS1.2的握手流程,相比之下,TLS1.3做了一系列优化,这里暂时不去进行研究。

启用SSL认证对性能的影响

从逻辑上分析,可能造成性能损失的地方有:

  • SSL握手过程。但是握手流程只在建立连接时进行一次,因此在长时间运行过程中对性能影响应该不大。但是如果是频繁新建连接的场景,也需要考虑。
  • 通讯时的加密解密过程。加密解密过程会带来CPU和内存的额外开销,而且加密后的数据会膨胀,增加网络带宽占用。这些消耗每次通讯都会存在,不可忽略。

为了对性能进行比较,编写了stress_test_client_nosslstress_test_client_withssl两个压测客户端以及server_nossl这个不使用SSL认证的服务器。

首先运行server_withsslstress_test_client_withssl 用来测试在使用SSL认证的情况下的性能。stress_test_client_withssl输出如下:

=== SSL Client Performance Test ===
Target: localhost:50051
Total requests: 1000
Concurrency: 1

=== Test Results ===
Total time: 1338 ms
Successful requests: 1000/1000
Average latency: 1326.27 µs
QPS: 747.384 requests/second

测试使用1个线程,共进行1000次请求,测试qps大概为747

然后运行server_nosslstress_test_client_nossl来测试不使用SSL认证的情况下的性能,stress_test_client_nossl的输出如下:

=== No SSL Client Performance Test ===
Target: localhost:50051
Total requests: 1000
Concurrency: 1

=== Test Results ===
Total time: 1242 ms
Successful requests: 1000/1000
Average latency: 1230.16 µs
QPS: 805.153 requests/second

测试使用1个线程,共进行1000次请求,测试qps大概为805

通过结果可以看出,使用SSL比不使用SSL性能差距大概有7.2%。

可以使用火焰图进行分析。按照 火焰图相关工具安装使用 中的流程,启动server_withsslstress_test_client_withssl,其中 stress_test_client_withssl 设置requests为100000,以实现足够的采样时间。

./stress_test_client_withssl -requests 100000

查找服务器进程pid为 1897427

开启采样:

sudo perf record -F 99 -p 1897427 -g -- sleep 30

可以看到的,server_withsslstress_test_client_withssl 的cpu大概都是57%的样子,因为机器本身是2核的,所以感觉也很难再高了。本身采样程序还占用了26%的cpu。

采样结束,拿到perf.data,

生成火焰图:

# 生成火焰图
# 将 perf.data 转换为中间格式
sudo perf script > out.perf
# 折叠堆栈样本 (Stack Collapse)
stackcollapse-perf.pl out.perf > out.folded
# 生成 SVG 格式的火焰图
flamegraph.pl out.folded > grpc_ssl_flame.svg

将grpc_ssl_flame.svg下载到本地,并且用浏览器打开。Ctrl + F 直接查找ssl,结果如下:

由于不了解SSL的实现,所以对火焰图中的函数统计未必全面和准确。不过从现有的结果中已经可以说明,在当前测试用例下,使用SSL还是会造成一定的性能损失。

火焰图相关工具安装使用

# 安装 perf
sudo apt-get install linux-tools-common linux-tools-`uname -r` -y

# 下载 FlameGraph 脚本
git clone https://github.com/brendangregg/FlameGraph.git
export PATH=$PATH:$(pwd)/FlameGraph

# 查找服务器进程
pid=$(pgrep -f server_withssl)
echo "Server PID: $pid"

# 使用 perf record 采集性能数据
# -F 99: 采样频率为 99 Hz(每秒99次)
# -p $pid: 指定要分析的进程ID。
# -g: 记录调用栈(call graph),这对生成火焰图至关重要。
# -- sleep 30: 采集持续30秒的数据。您可以根据需要调整时长(例如60秒或更长)。
sudo perf record -F 99 -p $pid -g -- sleep 30

# 生成火焰图
# 将 perf.data 转换为中间格式
sudo perf script > out.perf
# 折叠堆栈样本 (Stack Collapse)
stackcollapse-perf.pl out.perf > out.folded
# 生成 SVG 格式的火焰图
flamegraph.pl out.folded > grpc_ssl_flame.svg

# 解读结果
# 打开火焰图:使用浏览器打开 grpc_ssl_flame.svg。

VSCode代码格式化设置

在编写过程中,发现代码格式不是很统一,不利于阅读,因此进行格式化设置。使用Clang-Format作为核心工具,部署过程如下:

首先在系统安装:

sudo apt update
sudo apt install clang-format

然后安装VSCode扩展,在VSCode扩展商店(Ctrl+Shift+X)中搜索并安装以下两个插件:

  • C/C++(作者:Microsoft)- 提供C++语言支持
  • Clang-Format(作者:xaver)- 核心格式化工具

然后创建格式化配置文件,在项目根目录下创建.clang-format文件,定义格式化规则,执行指令生成默认配置(以Google风格为例):

clang-format -style=google -dump-config > .clang-format

按照我的喜好,我修改了如下配置:

IndentWidth: 4 # 缩进宽度为4    
BreakBeforeBraces: Allman # 所有大括号单独换行
AllowShortIfStatementsOnASingleLine: false # 不带 else 的简短 if 语句 不允许放在同一行

配置很多,并没有一一搞清楚是控制什么,可以在使用的过程中不断修改优化。

修改完后,配置VSCode设置,打开VSCode设置(Ctrl+,),在settings.json中添加以下配置:

{
    "editor.formatOnSave": true,
    "editor.formatOnSaveMode": "file",
    "C_Cpp.clang_format_style": "file",
    "editor.defaultFormatter": "ms-vscode.cpptools",
    "editor.tabSize": 4
}

设置完后,格式化可以通过如下途径进行:

  • 快捷键Shift + Alt + F
  • 右键选择格式化:

  • 保存时自动格式化

其他说明

由于没有深入了解SSL的实现原理,文中部分内容是参考Deepseek以及程序运行现象的个人理解猜测,难免有不正确的地方,需要注意甄别。

参考资料

欢迎关注公众号:只做人间不老仙