2024年8月

最近在给我们的客户私有化部署我们的
TorchV
系统,客户给的资源足够充裕,借此机会记录下部署千问72B模型的过程,分享给大家!

一、基础信息

  • 操作系统

    Ubuntu 22.04.3 LTS

  • GPU:
    A800(80GB) * 8

  • 内存
    :1TB

二、软件信息

Python: 3.10

Pytorch:2.3.0

Transformers:4.43.0

vLLM:0.5.0

cuda: 12.2

模型:
QWen2-72B-Instruct

三、安装步骤

1、安装Conda

Conda 是一个开源的包管理系统和环境管理系统,旨在简化软件包的安装、配置和使用

对于Python环境的部署,能够非常方便的切换环境。

可以通过conda官网链接下载安装:
https://www.anaconda.com/download#downloads

# 下载
wget https://repo.anaconda.com/archive/Anaconda3-2023.09-0-Linux-x86_64.sh
# 安装
bash Anaconda3-2023.09-0-Linux-x86_64.sh
# 配置环境变量
echo 'export PATH="/path/to/anaconda3/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc

安装完成后,通过命令验证安装是否成功

conda --version

安装完成之后,可以配置镜像源,方便快速下载依赖包

# 配置源

conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/
conda config --set show_channel_urls yes


conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge/
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/msys2/
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/bioconda/
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/menpo/
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/pytorch/

conda的相关命令

 # 指定虚拟环境名称为llm,python版本是3.9
 conda create --name llm python=3.9
 # 激活conda新环境
 conda activate llm
 # 查看当前环境列表
 conda env list

2、下载QWen2-72B-Instruct模型

Huggingface:
https://huggingface.co/Qwen/Qwen2-72B-Instruct

ModelScope:
https://modelscope.cn/models/qwen/Qwen2-72B-Instruct

两个地址都可以下载,下载完成后,将模型文件存放在服务器上。

⚠️ 注意服务器的磁盘空间。

3、安装Pytorch等环境依赖信息

⚠️ 在安装Pytorch时,需要保证和cuda驱动版本保持一致,不然会出现各种莫名其妙的问题

版本选择参考:
https://pytorch.org/get-started/locally/

通过conda创建一个新的环境,然后切换后安装依赖包

4、 安装vLLM

vLLM
框架是一个高效的大语言模型
推理和部署服务系统
,具备以下特性:

  • 高效的内存管理
    :通过
    PagedAttention
    算法,
    vLLM
    实现了对
    KV
    缓存的高效管理,减少了内存浪费,优化了模型的运行效率。
  • 高吞吐量

    vLLM
    支持异步处理和连续批处理请求,显著提高了模型推理的吞吐量,加速了文本生成和处理速度。
  • 易用性

    vLLM

    HuggingFace
    模型无缝集成,支持多种流行的大型语言模型,简化了模型部署和推理的过程。兼容
    OpenAI

    API
    服务器。
  • 分布式推理
    :框架支持在多
    GPU
    环境中进行分布式推理,通过模型并行策略和高效的数据通信,提升了处理大型模型的能力。
  • 开源共享

    vLLM
    由于其开源的属性,拥有活跃的社区支持,这也便于开发者贡献和改进,共同推动技术发展。

GitHub:
https://github.com/vllm-project/vllm

文档:
https://docs.vllm.ai/en/latest/

在通过
conda
创建了初始环境后,可以直接通过
pip
进行安装

pip install vllm

更多的安装方式,可以参考官网文档:
https://docs.vllm.ai/en/stable/getting_started/installation.html

5、模型验证

可以通过一个python脚本来验证当前的模型是否可用

脚本如下:

# test.py
from vllm import LLM, SamplingParams
from transformers import AutoTokenizer
import os
import json

def get_completion(prompts, model, tokenizer=None, max_tokens=512, temperature=0.8, top_p=0.95, max_model_len=2048):
    stop_token_ids = []
    # 创建采样参数。temperature 控制生成文本的多样性,top_p 控制核心采样的概率
    sampling_params = SamplingParams(temperature=temperature, top_p=top_p, max_tokens=max_tokens, stop_token_ids=stop_token_ids)
    # 初始化 vLLM 推理引擎
    llm = LLM(model=model, tokenizer=tokenizer, max_model_len=max_model_len,trust_remote_code=True)
    outputs = llm.generate(prompts, sampling_params)
    return outputs


if __name__ == "__main__":    
    # 初始化 vLLM 推理引擎
    model='/mnt/soft/models/qwen/Qwen2-72B-Instruct' # 指定模型路径
    # model="qwen/Qwen2-7B-Instruct" # 指定模型名称,自动下载模型
    tokenizer = None
    # 加载分词器后传入vLLM 模型,但不是必要的。
    # tokenizer = AutoTokenizer.from_pretrained(model, use_fast=False) 
    
    text = ["你好,帮我介绍一下什么时大语言模型。",
            "可以给我将一个有趣的童话故事吗?"]

    outputs = get_completion(text, model, tokenizer=tokenizer, max_tokens=512, temperature=1, top_p=1, max_model_len=2048)

    # 输出是一个包含 prompt、生成文本和其他信息的 RequestOutput 对象列表。
    # 打印输出。
    for output in outputs:
        prompt = output.prompt
        generated_text = output.outputs[0].text
        print(f"Prompt: {prompt!r}, Generated text: {generated_text!r}")

在终端执行python脚本,可以看到控制台是否正常输出

python test.py

6、启动服务 & 包装OpenAI格式的接口

验证模型可用后,那么就可以通过vLLM提供的模块,将整个模型服务包装成OpenAI格式的HTTP服务,提供给上层应用使用。

需要注意的参数配置:

  • --model
    参数指定模型名称&路径。
  • --served-model-name
    指定服务模型的名称。
  • --max-model-len
    指定模型的最大长度,如果不指定,那么会从模型配置文件中自动加载,QWen2-72B模型支持最大128K
  • --tensor-parallel-size
    指定多个GPU服务运行,QWen2-72B的模型,单卡GPU无法支撑。
  • --gpu-memory-utilization
    用于模型执行器的GPU内存分数,范围从0到1。例如,值为0.5意味着GPU内存利用率为50%。如果未指定,将使用
    默认值0.9

    vllm通过此参数预分配了部分显存,避免模型在调用的时候频繁的申请显存

关于vllm的更多参数,可以参考官方文档:
https://docs.vllm.ai/en/stable/models/engine_args.html

这里可以使用
tmux
命令来进行服务的运行。

tmux
(Terminal Multiplexer)是一个强大的终端复用器,可以让用户在一个终端窗口中同时使用多个会话。使用
tmux
可以提高工作效率,便于管理长期运行的任务和多任务操作

python3 -m vllm.entrypoints.openai.api_server --model /mnt/torchv/models/Qwen2-72B-Instruct  --served-model-name QWen2-72B-Instruct --tensor-parallel-size 8 --gpu-memory-utilization 0.7

出现端口等信息则代表当前的模型服务启动成功!!!

首先创建一个新会话

tmux new -t llm

进入会话

tmux attach -t llm

启动命令:

python -m xxx

退出当前会话

如果没反应就多试几次

英文输入下 ctrl + b  然后输入d

通过curl命令验证大模型OpenAI接口服务是否可用,脚本如下:

curl http://localhost:8000/v1/chat/completions -H "Content-Type: application/json" -d '{
  "model": "QWen2-72B-Instruct",
  "messages": [
      {
          "role": "user",
          "content": "给我讲一个童话故事"
      }
  ],
  "stream": true,
  "temperature": 0.9,
  "top_p": 0.7,
  "top_k": 20,
  "max_tokens": 512
}'

四、总结

目前的开源生态已经非常成熟了,vLLM这样的工具能够轻松实现对大模型的快速部署,工作效率上大大提升

五、References

官网资源等信息

资源 地址
QWen GitHub:
https://github.com/QwenLM/Qwen
Huggingface:
https://huggingface.co/Qwen

ModelScope:
https://modelscope.cn/organization/qwen?tab=model
docs:
https://qwen.readthedocs.io/zh-cn/latest/getting_started/quickstart.html#
Pytorch https://pytorch.org/get-started/locally/
Conda https://www.anaconda.com
vLLM https://docs.vllm.ai/en/latest/getting_started/installation.html

权重文件下载不完全

在本次部署过程中,碰到了下载模型权重文件不完整的情况,导致通过
vLLM
部署不起来,可以通过Linux的命令
sha256sum
工具来对模型权重文件进行检查,对比网站上的模型权重文件的sha256是否一致,如果不一致,需要重新下载安装

命令如下:

sha256sum your_local_file


Redis24篇集合

1 先导

我们在《
Redis系列14:使用List实现消息队列
》这一篇中详细讨论了如何使用List实现消息队列,但同时也看到很多局限性,比如:

  • 不支持消息确认机制
    ,没有很好的ACK应答
  • 不支持消息回溯
    ,无法排查问题和做消息分析
  • List按照FIFO机制执行
    ,所以存在消息堆积的风险。
  • 查询效率低
    ,作为线性结构,List中定位一个数据需要进行遍历,O(N)的时间复杂度
  • 不存在消费组(Consumer Group)的概念
    ,无法实现多个消费者组成分组进行消费

2 关于Stream

Redis Stream是Redis 5.0版本中引入的一种新的数据结构,它主要用于
高效地处理流式数据,特别适用于消息队列、日志记录和实时数据分析等场景

以下是对Redis Stream的
主要特征:
1. 数据结构
:Redis Stream是一个由有序消息组成的日志数据结构,
每个消息都有一个全局唯一的ID,确保消息的顺序性和可追踪性。
2. 消息ID
:消息的ID由两部分组成,分别是毫秒级时间戳和序列号。这种设计
确保了消息ID的单调递增性
,即新消息的ID总是大于旧消息的ID。
3. 消费者组

Redis Stream支持消费者组的概念
,允许多个消费者以组的形式订阅Stream,并且每个消息只会被组内的一个消费者处理,避免了消息的重复消费。

以及主要优势:

1. 持久化存储
:Stream中的消息可以被
持久化存储,确保数据不会丢失
,即使在Redis服务器重启后也能恢复消息。
2. 有序性
:消息按照产生顺序生成消息ID, 被添加到Stream中,并且可以
按照指定的条件检索消息,保证了消息的有序性

3.
多播与分组消费
:支持多个消费者同时消费同一流中的消息,并且可以将消费者组织成消费组,实现
消息的分组消费

4.
消息确认机制
:消费者可以通过
XACK命令确认是否成功消费
消息,保证消息至少背消费一次,确保消息不会被重复处理。
5.
阻塞读取
:消费者可以选择阻塞读取模式,当没有新消息时,消费者会等待直至新消息到达。
6.
消息可回溯
:
方便补数、特殊数据处理, 以及问题回溯查询

3 主要命令

1. XADD
:向Stream中添加消息。如果指定的Stream不存在,则会自动创建。
2. XREAD
:以阻塞/非阻塞方式获取Stream中的消息列表。
3. XREADGROUP
:从消费者组中读取消息,支持阻塞读取。
4. XACK
:确认消费者已经成功处理了消息。
5. XGROUP
:用于管理消费者组,包括创建、设置ID、销毁消费者组等操作。
6. XPENDING
:查询消费者组中的待处理消息。

3.1 XADD 消息记录

XADD命令用于向Redis Stream(流)数据结构中添加消息。

3.1.1 XADD 命令的基本语法

XADD stream_name [MAXLEN maxlen] [ID id] field1 value1 [field2 value2 ...]

1. stream_name
:指定要添加消息的Stream的名字。
2. MAXLEN maxlen
:可选参数,用于限制Stream的最大长度。当Stream的长度达到maxlen时,旧的消息会被自动删除。
3. ID id
:可选参数,用于指定消息的ID。如果不指定该参数,Redis会自动生成一个唯一的ID。
4. field1 value1 [field2 value2 ...]
:消息的字段和值,消息的内容以key-value的形式存在。

XADD命令的一个重要用途是实现消息发布功能,发布者可以使用XADD命令向Stream中添加消息。

3.1.2 XADD 示例

假设我们有一个名为
userinfo_stream
的Stream,并希望向其中添加一个包含
sensor_id

temperature
字段的消息,我们可以使用以下命令:

XADD userinfo_stream * user_name brand age 18

在这个例子中,
*
表示让Redis自动生成一个唯一的消息ID。消息包含两个字段:
username

age
,它们的值分别是
brand

18
。所以这边记录了一个用户信息,姓名为
brand
, 年龄
18
岁。

3.1.3 有啥需要注意的呢

  • 如果指定的Stream
    不存在
    ,XADD命令会
    创建一个新的Stream
  • 消息的ID是唯一的,并且Redis会保证
    Stream中消息的ID是单调递增的
    。如果指定了ID,则新消息的ID必须大于Stream中现有的所有消息的ID。
  • 使用
    MAXLEN参数可以限制Stream的大小
    ,这在处理大量消息时非常有用,可以避免Stream占用过多的内存或磁盘空间。

3.2 XREAD 消息消费

即将消息从队列中读取出来(消费)

3.2.1 XREAD 命令的基本语法

XREAD命令的基本语法如下:

XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]

1. COUNT count
:这是一个可选参数,用于指定一次读取的最大消息数量。如果不指定,默认为1。
2. BLOCK milliseconds
:这也是一个可选参数,用于指定阻塞的时间(以毫秒为单位)。如果指定了阻塞时间,并且当前没有可消费的消息,客户端将在指定的时间内阻塞等待。如果不设置该参数或设置为0,则命令将立即返回,无论是否有可消费的消息。
3. STREAMS key [key ...] ID [ID ...]
:这部分指定了要消费的流(Streams)和对应的起始消息ID。可以一次指定多个流和对应的起始ID。

XREAD命令的工作机制
1. 读取指定ID之后的消息
:XREAD命令会返回指定ID之后的消息(不包含指定ID的消息本身)。如果没有指定ID,或者指定的ID不存在于流中,那么命令将从流的开始或结束处读取消息,具体取决于ID的值(如“0-0”表示从流的开始处读取,“$”表示从流的当前最大ID处读取)。
2. 阻塞读取
:当设置了BLOCK参数后,如果当前没有可消费的消息,客户端将进入阻塞状态,直到有新的消息到达或阻塞时间超时。这种机制非常适合实现消费者等待生产者产生新消息的场景。
3. 支持多个流
:XREAD命令支持同时从多个流中读取消息,只需在命令中指定多个流和对应的起始ID即可。

3.2.2 XREAD 示例

假设我们有一个名为
userinfo_stream
的流,并且想要从该流中读取消息。以下是一些示例:
1. 非阻塞读取最新消息

XREAD COUNT 1 STREAMS userinfo_stream $

这条命令会尝试从
userinfo_stream
流中读取最新的消息(如果有的话)。
$
是一个特殊ID,表示流的当前最大ID。

2. 阻塞读取最新消息

XREAD COUNT 1 BLOCK 1000 STREAMS userinfo_stream $

这条命令会阻塞1000毫秒,等待
userinfo_stream
流中出现新的消息。如果在1000毫秒内有新消息到达,则命令会返回该消息;否则,命令将超时并返回nil。

3. 从特定ID开始读取

XREAD COUNT 2 STREAMS userinfo_stream 1722159931000-0
1) 1) "userinfo_stream"
    2)  1) 1) "1722159931000-0"
         2) 1) "user_name"
             2) "brand"
             3) "age"
             4) "18"

这条命令会从
userinfo_stream
流中读取ID大于或等于
1722159931000-0
的消息,最多返回数据。

3.2.3 需要注意啥呢?

1. 消息ID的唯一性
:在Redis Streams中,每个消息都有一个全局唯一的消息ID,
这个消息ID由两部分组成:时间戳和序列号
。时间戳表示消息被添加到流中的时间,序列号表示在同一时间戳内添加的消息的顺序。
2. 消费者组
:虽然XREAD命令本身不直接涉及消费者组的概念,但Redis Streams还支持消费者组模式,允许一组消费者协作消费同一流中的消息。
在消费者组模式下,通常会使用XREADGROUP命令而不是XREAD命令来读取消息。
3. 性能考虑
:XREAD命令在读取大量消息时可能会消耗较多的CPU和内存资源。因此,在实际应用中需要根据实际情况合理设置COUNT参数的值,避免一次性读取过多消息导致性能问题。

3.3 Consumer Group 消费组模式

典型的多播模式,在实时性要求比较高的场景,如果你想加快对消息的处理。那这是一个不错的选择,我们让队列在逻辑上进行分区,用不同的消费组来隔离消费。所以:

image

消费者组允许多个消费者(client 或 process)协同处理同一个流(Stream)中的消息。每个消费者组维护自己的消费偏移量(即已处理消息的位置),以支持消费者之间的负载均衡和容错。

3.3.1 创建消费者组

使用 XGROUP CREATE 命令创建消费者组。

# stream_name:队列名称
# consumer_group:消费者组
# msgIdStartIndex:消息Id开始位置
# msgIdStartIndex:消息Id结束位置
# $ 表示从流的当前末尾(即最新消息)开始创建消费者组。如果流不存在,MKSTREAM 选项将自动创建流
XGROUP CREATE stream_name consumer_group msgIdStartIndex-msgIdStartIndex
# 或者
XGROUP CREATE stream_name consumer_group $ MKSTREAM

下面是具体实现示例,为队列 userinfo_stream 创建了消费组1(consumer_group1)和 消费组2(consumer_group2):

> xgroup create userinfo_stream consumer_group1 0-0
OK
> xgroup create userinfo_stream consumer_group2 0-0
OK

3.3.2 读取消息

消费者可以通过
XREADGROUP
命令从消费者组中读取消息。
XREADGROUP
命令不仅读取消息,还会更新消费者组中的消费者状态,即标记哪些消息已被读取。

# group_name: 消费者群组名
# consumer_name: 消费者名称
# COUNT number: count 消费个数
# BLOCK ms: 表示如果流中没有新消息,则命令将阻塞最多 xx 毫秒,0则无限阻塞
# stream_name: 队列名称 
# id: 消息消费ID
# []:代表可选参数
# `>`:放在命令参数的最后面,表示从尚未被消费的消息开始读取;

XREADGROUP GROUP group_name consumer_name [COUNT number] [BLOCK ms] STREAMS stream_name [stream ...] id [id ...]
# 或者
XREADGROUP GROUP group_name consumer_name COUNT 1 BLOCK 2000 STREAMS stream_name >

下面是具体实现示例,消费组 consumer_group1 的消费者 consumer1 从 userinfo_stream 中以阻塞的方式读取一条消息:

XREADGROUP GROUP consumer_group1 consumer1 COUNT 1 BLOCK 0 STREAMS userinfo_stream >
1) 1) "userinfo_stream"
   2) 1) 1) "1722159931000-0"
         2) 1) "user_name"
            2) "brand"
            3) "age"
            4) "18"

3.3.3 确认消息

处理完消息后,消费者需要发送 XACK 命令来确认消息。这告诉 Redis 这条消息已经被成功处理,并且可以从消费者组的待处理消息列表中移除

# stream_name: 队列名称 
# group_name: 消费者群组名
# <message-id> 是要确认的消息的 ID。

XACK stream_name group_name <message-id>
# ACK 确认两条消息
XACK userinfo_stream consumer_group1 1722159931000-0 1722159932000-0
(integer) 2

3.3.4 PLE:消息可靠性保障

PEL(Pending Entries List)记录了当前被消费者读取但尚未确认(ACK)的消息。这些消息在消费者成功处理并发送ACK命令之前,会一直保留在PEL中。如果消费者崩溃或未能及时发送ACK命令,Redis将确保这些消息能够被重新分配给其他消费者进行处理,从而实现消息的可靠传递。

XPENDING stream_name group_name

以下的例子中,我们查看
userinfo_stream
中的 消费组
consumer_group1
中各个消费者已读取但未确认的消息信息。

XPENDING userinfo_stream consumer_group1
1) (integer) 2   # 未确认消息条数
2) "1722159931000-0"
3) "1722159932000-0"

详细的stream操作见官网文档:
https://redis.io/docs/data-types/streams-tutorial/

4 使用Golang实现Stream队列能力

4.1 先安装go-redis/redis库

> go get github.com/go-redis/redis/v8
go: downloading github.com/go-redis/redis v6.15.9+incompatible
go: downloading github.com/go-redis/redis/v8 v8.11.5
go: downloading github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f
go: downloading github.com/cespare/xxhash/v2 v2.1.2
go: added github.com/cespare/xxhash/v2 v2.1.2
go: added github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f
go: added github.com/go-redis/redis/v8 v8.11.5

注意:这里的v8是库的版本号,你可以根据实际情况进行调整

逻辑实现

package main  
  
import (  
	"context"  
	"fmt"  
	"log"  
	"time"  
  
	"github.com/go-redis/redis/v8"  
)  
  
func main() {  
	// 连接到Redis  
	rdb := redis.NewClient(&redis.Options{  
		Addr:     "localhost:6379", // Redis地址  
		Password: "",               // 密码(如果有的话)  
		DB:       0,                // 使用默认DB  
	})  
  
	ctx := context.Background()  
  
	// 创建Stream  
	_, err := rdb.XAdd(ctx, &redis.XAddArgs{  
		Stream: "mystream",  
		Values: map[string]interface{}{  
			"field1": "value1",  
			"field2": "value2",  
		},  
	}).Result()  
	if err != nil {  
		log.Fatalf("Failed to add message to stream: %v", err)  
	}  
  
	// 创建Consumer Group  
	_, err = rdb.XGroupCreate(ctx, "mystream", "mygroup", "$").Result()  
	if err != nil && err != redis.Nil {  
		log.Fatalf("Failed to create consumer group: %v", err)  
	}  
  
	// 消费者读取消息  
	go func() {  
		for {  
			msgs, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{  
				Group:    "mygroup",  
				Consumer: "myconsumer",  
				Streams:  []string{"mystream", ">"},  
				Count:    1,  
				Block:    1000, // 阻塞1000毫秒  
			}).Result()  
			if err != nil {  
				if err == redis.Nil {  
					// 超时,没有新消息  
					continue  
				}  
				log.Fatalf("Failed to read from stream: %v", err)  
			}  
  
			for _, msg := range msgs[0].Messages {  
				fmt.Printf("Received: %s %s\n", msg.ID, msg.Values)  
  
				// 确认消息  
				_, err = rdb.XAck(ctx, "mystream", "mygroup", msg.ID).Result()  
				if err != nil {  
					log.Fatalf("Failed to ack message: %v", err)  
				}  
			}  
		}  
	}()  
  
	// 模拟生产者继续发送消息  
	for i := 0; i < 5; i++ {  
		_, err := rdb.XAdd(ctx, &redis.XAddArgs{  
			Stream: "mystream",  
			Values: map[string]interface{}{  
				"field1": fmt.Sprintf("value%d", i+1),  
				"field2": "another value",  
			},  
			MaxLen:     100,  
			Approximate: true,  
		}).Result()  
		if err != nil {  
			log.Fatalf("Failed to add message to stream: %v", err)  
		}  
		time.Sleep(2 * time.Second) // 模拟生产间隔  
	}  
  
	// 注意:在实际应用中,主goroutine通常不会立即退出,而是会等待某些触发条件

5 应用场景

1. 消息队列
:Redis Stream可以作为消息队列使用,支持消息的发布、订阅和消费。
2. 日志记录
:将日志信息写入Redis Stream,方便后续的查询和分析。
3. 实时数据分析
:结合Redis的其他数据结构(如Sorted Set、Hash等),对Stream中的数据进行实时分析。

6 总结

Redis Stream是Redis在消息队列和流式数据处理领域的一个重要补充,它提供了简单但功能强大的数据流处理能力,为开发者提供了更多的选择和灵活性。相对List,Stream的优势如下:

  • 支持消息确认机制(ACK应答确认)
  • 支持消息回溯,方便排查问题和做消息分析
  • 存在消费组(Consumer Group)的概念,可以进行分组消费和批量消费,可以负载多个消费实例

开心一刻

想买摩托车了,但是钱不够,想找老爸借点

我:老爸,我想买一辆摩托车,上下班也方便

老爸:你表哥上个月骑摩托车摔走了,你不知道?还要买摩托车?

我:对不起,我不买了

老板:就是啊,骑你表哥那辆得了呗,买啥新的

你是认真的吗

先抛问题

关于
maven
的依赖(
dependency
),我相信大家多少都知道点

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.qsl</groupId>
    <artifactId>spring-boot-2_7_18</artifactId>
    <version>1.0-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.18</version>
    </parent>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>
</project>

依赖什么就引入什么,是不是很合理,也很合逻辑?我们来看下此时的
log
依赖

log依赖

使用了 idea 的 Maven Helper 插件,一款不错的 maven dependency 分析工具,推荐使用

此时你们是不是有疑问了:不就依赖
spring-boot-starter-web
,怎么会有各种
log
的依赖?

然后我在
pom.xml
中加一行,仅仅加一行

新加一行

此时的
log
依赖与之前就有了变化

log依赖变化

这是为什么?

你以为没关系,实际启动时会出现如下异常(原因请看:
SpringBoot2.7还是任性的,就是不支持Logback1.3,你能奈他何

SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
Exception in thread "main" java.lang.NoClassDefFoundError: org/slf4j/impl/StaticLoggerBinder
	at org.springframework.boot.logging.logback.LogbackLoggingSystem.getLoggerContext(LogbackLoggingSystem.java:304)
	at org.springframework.boot.logging.logback.LogbackLoggingSystem.beforeInitialize(LogbackLoggingSystem.java:118)
	at org.springframework.boot.context.logging.LoggingApplicationListener.onApplicationStartingEvent(LoggingApplicationListener.java:238)
	at org.springframework.boot.context.logging.LoggingApplicationListener.onApplicationEvent(LoggingApplicationListener.java:220)
	at org.springframework.context.event.SimpleApplicationEventMulticaster.doInvokeListener(SimpleApplicationEventMulticaster.java:178)
	at org.springframework.context.event.SimpleApplicationEventMulticaster.invokeListener(SimpleApplicationEventMulticaster.java:171)
	at org.springframework.context.event.SimpleApplicationEventMulticaster.multicastEvent(SimpleApplicationEventMulticaster.java:145)
	at org.springframework.context.event.SimpleApplicationEventMulticaster.multicastEvent(SimpleApplicationEventMulticaster.java:133)
	at org.springframework.boot.context.event.EventPublishingRunListener.starting(EventPublishingRunListener.java:79)
	at org.springframework.boot.SpringApplicationRunListeners.lambda$starting$0(SpringApplicationRunListeners.java:56)
	at java.util.ArrayList.forEach(ArrayList.java:1249)
	at org.springframework.boot.SpringApplicationRunListeners.doWithListeners(SpringApplicationRunListeners.java:120)
	at org.springframework.boot.SpringApplicationRunListeners.starting(SpringApplicationRunListeners.java:56)
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:299)
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:1300)
	at org.springframework.boot.SpringApplication.run(SpringApplication.java:1289)
	at com.qsl.Application.main(Application.java:16)
Caused by: java.lang.ClassNotFoundException: org.slf4j.impl.StaticLoggerBinder
	at java.net.URLClassLoader.findClass(URLClassLoader.java:381)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
	at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:331)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
	... 17 more

然后你就懵逼了

怎么会这样

我们再调整下
pom.xml

pom去掉springboot日志

此时的
log
依赖如下

logback1.3.14依赖的slf4j怎么是1.7.36

也许你们觉得没问题,我再给你们引申下;
logback1.3.14
依赖的
slf4j
版本是
2.0.7

logback1.3.14依赖slf4j2.0.7


slf4j1.7.36
是哪来的,为什么不是
2.0.7
?

这一连串问题下来,就问你们慌不慌,但你们不要慌,因为我会出手!

传递性依赖

在 maven 诞生之前,那时候添加 jar 依赖可以说是一个非常头疼的事,需要手动去添加所有的 jar,非常容易遗漏,然后根据异常去补遗漏的 jar;很多有经验的老手都会分类,比如引入 Spring 需要添加哪几个 jar,引入 POI 又需要添加哪几个 jar,但还是容易遗漏;而 maven 的传递性依赖机制就很好的解决了这个问题

何谓传递性依赖,回到我们最初的案例

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.qsl</groupId>
    <artifactId>spring-boot-2_7_18</artifactId>
    <version>1.0-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.18</version>
    </parent>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>
</project>

直观看上去,只依赖了
spring-boot-starter-web
,但
spring-boot-starter-web
也有自身的依赖,maven 也会进行解析,以此类推,maven 会将那些必要的间接依赖以传递性依赖的形式引入到当前的项目中

传递性依赖

问题

不就依赖
spring-boot-starter-web
,怎么会有各种
log
的依赖?

是不是清楚了?

依赖优先级

传递性依赖机制大大简化了依赖声明,对我们开发者而言非常友好,比如我们需要用到 spring 的 web 功能,只需要简单的引入

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-web</artifactId>
</dependency>

就 ok 了,是不是 so easy ?但同样会带来一些问题,比如项目 P 有如下两条传递性依赖

P -> A -> B -> C(1.0)

P -> D -> C(2.0)

那么哪个 C 会被 maven 引入到 P 项目中呢?此时 maven 会启用它的第一原则

最短路径优先

这里的
路径
指的是传递依赖的长度,一次传递依赖的长度是 1,P 到 C(1.0)传递依赖的长度是 3,而 P 到 C(2.0)传递依赖的长度是 2,所以 C(2.0)会被 maven 引入到 P 项目,而 C(1.0)会被忽略

最短路径优先
并不能解决所有问题,比如项目 P 有如下两条传递性依赖

P -> B -> C(1.0)

P -> D -> C(2.0)

两条传递依赖的长度都是 2,那 maven 会引入谁了?从 maven 2.0.9 开始,maven 增加了第二原则

第一声明优先

用来处理
最短路径优先
处理不了的情况;在项目 P 的 pom 中,先被声明的会被 maven 采用而引入到项目 P,所以 B 和 D 的声明顺序决定了 maven 是引入 C(1.0)还是引入 C(2.0),如果 B 先于 D 被声明,那么 C(1.0)会被 maven 引入到 P,而 C(2.0)会被忽略

我们再来看

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.qsl</groupId>
    <artifactId>spring-boot-2_7_18</artifactId>
    <version>1.0-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.18</version>
    </parent>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <logback.version>1.3.14</logback.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>
</project>

此时的 logback

log依赖变化

为什么是 1.3.14,而不是 1.2.12?这里其实涉及到
自定义属性
的覆盖,有点类似 java 中的 override;1.2.12 是在父依赖(spring-boot-starter-parent)的父依赖(spring-boot-dependencies)中声明的自定义属性

logback1.2.12

而我们自己声明的自定义属性
<logback.version>1.3.14</logback.version>
正好覆盖掉了
1.2.12
,所以 maven 采用的是
1.3.14

是不是只剩最后一个问题了,我们先来回顾下问题,pom.xml 内容如下

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.qsl</groupId>
    <artifactId>spring-boot-2_7_18</artifactId>
    <version>1.0-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.18</version>
    </parent>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <logback.version>1.3.14</logback.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <exclusions>
                <exclusion>
                    <artifactId>spring-boot-starter-logging</artifactId>
                    <groupId>org.springframework.boot</groupId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>${logback.version}</version>
        </dependency>
    </dependencies>
</project>

此时的依赖

logback1.3.14依赖的slf4j怎么是1.7.36

slf4j 为什么是 1.7.36,而不是 logback 中的 2.0.7?这里其实涉及到
自定义属性
的优先级

自定义属性的优先级同样遵循 maven 传递依赖的第一、第二原则

从爷爷(spring-boot-dependencies)继承来的
slf4j.version

1.7.36

slf4j1.7.36

相当于是自己的,传递依赖的长度是 0,而 logback 从其父亲继承而来的
slf4j.version
(2.0.7)

slf4j2.0.7

传递依赖长度是 1,所以 maven 采用的是
1.7.36
而不是
2.0.7
;那如何改了,最简单的方式如下

<properties>
	<maven.compiler.source>8</maven.compiler.source>
	<maven.compiler.target>8</maven.compiler.target>
	<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
	<logback.version>1.3.14</logback.version>
	<slf4j.version>2.0.7</slf4j.version>
</properties>

总结

  1. maven 的传递依赖是个很强大的功能,以后碰到那种引入一个依赖而带入了超多依赖的情况,不要再有疑问

  2. maven 依赖优先级遵循两个原则


    第一原则:最短路径优先

    第二原则:最先声明优先

    第一原则处理不了的情况才会采用第二原则;自定义属性同样遵循这两个原则

前言

在一些特殊的场景中(比如低代码、减少小程序包体积、类似于APP的热更新),我们需要从服务端动态加载
.vue
文件,然后将动态加载的远程vue组件渲染到我们的项目中。今天这篇文章我将带你学会,在vue3中如何去动态加载远程组件。

欧阳写了一本开源电子书
vue3编译原理揭秘
,这本书初中级前端能看懂。完全免费,只求一个star。

defineAsyncComponent
异步组件

想必聪明的你第一时间就想到了
defineAsyncComponent
方法。我们先来看看官方对
defineAsyncComponent
方法的解释:

定义一个异步组件,它在运行时是懒加载的。参数可以是一个异步加载函数,或是对加载行为进行更具体定制的一个选项对象。

defineAsyncComponent
方法的返回值是一个异步组件,我们可以像普通组件一样直接在template中使用。和普通组件的区别是,只有当渲染到异步组件时才会调用加载内部实际组件的函数。

我们先来简单看看使用
defineAsyncComponent
方法的例子,代码如下:

import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent(() => {
  return new Promise((resolve, reject) => {
    // ...从服务器获取组件
    resolve(/* 获取到的组件 */)
  })
})
// ... 像使用其他一般组件一样使用 `AsyncComp`

defineAsyncComponent
方法接收一个返回 Promise 的回调函数,在Promise中我们可以从服务端获取vue组件的code代码字符串。然后使用
resolve(/* 获取到的组件 */)
将拿到的组件传给
defineAsyncComponent
方法内部处理,最后和普通组件一样在template中使用
AsyncComp
组件。

从服务端获取远程组件

有了
defineAsyncComponent
方法后事情从表面上看着就很简单了,我们只需要写个方法从服务端拿到vue文件的code代码字符串,然后在
defineAsyncComponent
方法中使用
resolve
拿到的vue组件。

第一步就是本地起一个服务器,使用服务器返回我们的vue组件。这里我使用的是
http-server
,安装也很简单:

npm install http-server -g

使用上面的命令就可以全局安装一个http服务器了。

接着我在项目的public目录下新建一个名为
remote-component.vue
的文件,这个vue文件就是我们想从服务端加载的远程组件。
remote-component.vue
文件中的代码如下:

<template>
  <p>我是远程组件</p>
  <p>
    当前远程组件count值为:<span class="count">{{ count }}</span>
  </p>
  <button @click="count++">点击增加远程组件count</button>
</template>

<script setup>
import { ref } from "vue";
const count = ref(0);
</script>

<style>
.count {
  color: red;
}
</style>

从上面的代码可以看到远程vue组件和我们平时写的vue代码没什么区别,有
template

ref
响应式变量、
style
样式。

接着就是在终端执行
http-server ./public --cors
命令启动一个本地服务器,服务器默认端口为
8080
。但是由于我们本地起的vite项目默认端口为
5173
,所以为了避免跨域这里需要加
--cors

./public
的意思是指定当前目录的
public
文件夹。

启动了一个本地服务器后,我们就可以使用
http://localhost:8080/remote-component.vue
链接从服务端访问远程组件啦,如下图:
remote-component

从上图中可以看到在浏览器中访问这个链接时触发了下载远程vue组件的操作。

defineAsyncComponent
加载远程组件

const RemoteChild = defineAsyncComponent(async () => {
  return new Promise(async (resolve) => {
    const res = await fetch("http://localhost:8080/remote-component.vue");
    const code = await res.text();
    console.log("code", code);
    resolve(code);
  });
});

接下来我们就是在
defineAsyncComponent
方法接收的 Promise 的回调函数中使用fetch从服务端拿到远程组件的code代码字符串应该就行啦,代码如下:

同时使用
console.log("code", code)
打个日志看一下从服务端过来的vue代码。

上面的代码看着已经完美实现
动态加载远程组件
了,结果不出意外在浏览器中运行时报错了。如下图:
error

在上图中可以看到从服务端拿到的远程组件的代码和我们的
remote-component.vue
的源代码是一样的,但是为什么会报错呢?

这里的报错信息显示
加载异步组件报错
,还记得我们前面说过的
defineAsyncComponent
方法是在回调中
resolve(/* 获取到的组件 */)
。而我们这里拿到的
code
是一个组件吗?

我们这里拿到的
code
只是组件的源代码,也就是常见的单文件组件SFC。而
defineAsyncComponent
中需要的是由源代码编译后拿的的vue组件对象,我们将组件源代码丢给
defineAsyncComponent
当然会报错了。

看到这里有的小伙伴有疑问了,我们平时在父组件中import子组件不是也一样在template就直接使用了吗?

子组件
local-child.vue
代码:

<template>
  <p>我是本地组件</p>
  <p>
    当前本地组件count值为:<span class="count">{{ count }}</span>
  </p>
  <button @click="count++">点击增加本地组件count</button>
</template>

<script setup>
import { ref } from "vue";
const count = ref(0);
</script>

<style>
.count {
  color: red;
}
</style>

父组件代码:

<template>
  <LocalChild />
</template>

<script setup lang="ts">
import LocalChild from "./local-child.vue";
console.log("LocalChild", LocalChild);
</script>

上面的import导入子组件的代码写了这么多年你不觉得怪怪的吗?

按照常理来说要import导入子组件,那么在子组件里面肯定要写export才可以,但是在子组件
local-child.vue
中我们没有写任何关于export的代码。

答案是在父组件import导入子组件触发了
vue-loader
或者
@vitejs/plugin-vue
插件的钩子函数,在钩子函数中会将我们的源代码
单文件组件SFC
编译成一个普通的js文件,在js文件中
export default
导出编译后的vue组件对象。

这里使用
console.log("LocalChild", LocalChild)
来看看经过编译后的vue组件对象是什么样的,如下图:
import-comp

从上图可以看到经过编译后的vue组件是一个对象,对象中有
render

setup
等方法。
defineAsyncComponent方法接收的组件就是这样的vue组件对象
,但是我们前面却是将vue组件源码丢给他,当然会报错了。

最终解决方案
vue3-sfc-loader

从服务端拿到远程vue组件源码后,我们需要一个工具将拿到的vue组件源码编译成vue组件对象。幸运的是优秀的vue不光暴露出一些常见的API,而且还将一些底层API给暴露了出来。比如在
@vue/compiler-sfc
包中就暴露出来了
compileTemplate

compileScript

compileStyleAsync
等方法。

如果你看过我写的
vue3编译原理揭秘
开源电子书,你应该对这几个方法觉得很熟悉。

  • compileTemplate
    方法:用于处理单文件组件SFC中的template模块。

  • compileScript
    方法:用于处理单文件组件SFC中的script模块。

  • compileStyleAsync
    方法:用于处理单文件组件SFC中的style模块。


vue3-sfc-loader
包的核心代码就是调用
@vue/compiler-sfc
包的这些方法,将我们的vue组件源码编译为想要的vue组件对象。
下面这个是改为使用
vue3-sfc-loader
包后的代码,如下:

import * as Vue from "vue";
import { loadModule } from "vue3-sfc-loader";

const options = {
  moduleCache: {
    vue: Vue,
  },
  async getFile(url) {
    const res = await fetch(url);
    const code = await res.text();
    return code;
  },
  addStyle(textContent) {
    const style = Object.assign(document.createElement("style"), {
      textContent,
    });
    const ref = document.head.getElementsByTagName("style")[0] || null;
    document.head.insertBefore(style, ref);
  },
};

const RemoteChild = defineAsyncComponent(async () => {
  const res = await loadModule(
    "http://localhost:8080/remote-component.vue",
    options
  );
  console.log("res", res);
  return res;
});

loadModule
函数接收的第一个参数为远程组件的URL,第二个参数为
options
。在
options
中有个
getFile
方法,获取远程组件的code代码字符串就是在这里去实现的。

我们在终端来看看经过
loadModule
函数处理后拿到的vue组件对象是什么样的,如下图:
compiler-remote-comp

从上图中可以看到经过
loadModule
函数的处理后就拿到来vue组件对象啦,并且这个组件对象上面也有熟悉的
render
函数和
setup
函数。其中
render
函数是由远程组件的template模块编译而来的,
setup
函数是由远程组件的script模块编译而来的。

看到这里你可能有疑问,远程组件的style模块怎么没有在生成的vue组件对象上面有提现呢?

答案是style模块编译成的css不会塞到vue组件对象上面去,而是单独通过
options
上面的
addStyle
方法传回给我们了。
addStyle
方法接收的参数
textContent
的值就是style模块编译而来css字符串,在
addStyle
方法中我们是创建了一个style标签,然后将得到的css字符串插入到页面中。

完整父组件代码如下:

<template>
  <LocalChild />
  <div class="divider" />
  <button @click="showRemoteChild = true">加载远程组件</button>
  <RemoteChild v-if="showRemoteChild" />
</template>

<script setup lang="ts">
import { defineAsyncComponent, ref, onMounted } from "vue";
import * as Vue from "vue";
import { loadModule } from "vue3-sfc-loader";
import LocalChild from "./local-child.vue";

const showRemoteChild = ref(false);

const options = {
  moduleCache: {
    vue: Vue,
  },
  async getFile(url) {
    const res = await fetch(url);
    const code = await res.text();
    return code;
  },
  addStyle(textContent) {
    const style = Object.assign(document.createElement("style"), {
      textContent,
    });
    const ref = document.head.getElementsByTagName("style")[0] || null;
    document.head.insertBefore(style, ref);
  },
};

const RemoteChild = defineAsyncComponent(async () => {
  const res = await loadModule(
    "http://localhost:8080/remote-component.vue",
    options
  );
  console.log("res", res);
  return res;
});
</script>

<style scoped>
.divider {
  background-color: red;
  width: 100vw;
  height: 1px;
  margin: 20px 0;
}
</style>

在上面的完整例子中,首先渲染了本地组件
LocalChild
。然后当点击“加载远程组件”按钮后再去渲染远程组件
RemoteChild
。我们来看看执行效果,如下图:
full

从上面的gif图中可以看到,当我们点击“加载远程组件”按钮后,在network中才去加载了远程组件
remote-component.vue
。并且将远程组件渲染到了页面上后,通过按钮的点击事件可以看到远程组件的响应式依然有效。

vue3-sfc-loader
同时也支持在远程组件中去引用子组件,你只需在
options
额外配置一个
pathResolve
就行啦。
pathResolve
方法配置如下:

const options = {
  pathResolve({ refPath, relPath }, options) {
    if (relPath === ".")
      // self
      return refPath;

    // relPath is a module name ?
    if (relPath[0] !== "." && relPath[0] !== "/") return relPath;

    return String(
      new URL(relPath, refPath === undefined ? window.location : refPath)
    );
  },
  // getFile方法
  // addStyle方法
}

其实
vue3-sfc-loader
包的核心代码就300行左右,主要就是调用vue暴露出来的一些底层API。如下图:
vue3-sfc-loader

总结

这篇文章讲了在vue3中如何从服务端加载远程组件,首先我们需要使用
defineAsyncComponent
方法定义一个异步组件,这个异步组件是可以直接在template中像普通组件一样使用。

但是由于
defineAsyncComponent
接收的组件必须是编译后的vue组件对象,而我们从服务端拿到的远程组件就是一个普通的vue文件,所以这时我们引入了
vue3-sfc-loader
包。
vue3-sfc-loader
包的作用就是在运行时将一个vue文件编译成vue组件对象,这样我们就可以实现从服务端加载远程组件了。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

另外欧阳写了一本开源电子书
vue3编译原理揭秘
,这本书初中级前端能看懂。完全免费,只求一个star。

技术背景

前面我们写过几篇关于Cython的文章,例如
Cython计算谐振势

Cython与C语言的结合

Cython调用CUDA Kernel函数
。Cython有着非常Pythonic的编程范式,又具备着接近于C语言的性能,因此在很多对于性能有要求的Python软件中都会使用到Cython的性能优化。Cython的基本工作流程是,先将
*.pyx
文件转换为
*.c
的C语言代码,然后再使用gcc编译成一个
*.so
动态链接库文件,供Python或者其他语言的代码调用。

这里我们考虑一个较为特殊的场景:将Python端常用的Numpy数组,转换为C语言的结构体,然后执行相应的任务或者计算。

Cython结构体

在Cython中可以使用
ctypedef struct
来定义一个可以跟C语言通用的结构体,例如,我们可以定义一个原子坐标的结构体:

ctypedef struct CRD:
    double x, y, z

这是一个三维空间中的原子坐标,在Python中就相当于一个shape为
(3,)
的数组。如果这是C++,那我们可以通过dynamic_cast等转换方式在数组和结构体之间进行转换。而C语言和Cython则是直接通过变量类型来进行转换,以下是一个完整的Cython示例:

ctypedef struct CRD:
    double x, y, z

cpdef int trans(double[:] crd):
    cdef:
        CRD* crd_s = <CRD*>&crd[0]
    print (crd_s.y)
    return 1

在这个示例中,我们输入给函数
trans()
的是一个shape为
(3,)
的数组,然后在
cdef
中将这个原子坐标转换为结构体的形式,最后在Cython中打印输出该原子的y坐标。我们用Cython编译这个文件之后,可以在Python中调用:

In [1]: from trans import trans

In [2]: import numpy as np

In [3]: a = np.array([1,2,3], dtype=np.float64)

In [4]: trans(a)
2.0
Out[4]: 1

需要注意的是,
这种转换方式并不安全
,这里我们仅仅演示一下这种转换方式的使用方法。

多原子坐标结构体

其实从C语言的角度还蛮好理解的,我们通过单个结构体可以定义一个原子的空间坐标,那么用一个指针,就可以定义多个原子的空间坐标。这里我们定义多原子坐标的结构体为:

ctypedef struct PATH:
    CRD crds

其实这里还是表示一个单原子的空间坐标,但是从一维指向变成了二维指向,此时我们可以通过PATH来构造一个多原子坐标的指针:

ctypedef struct CRD:
    double x, y, z

ctypedef struct PATH:
    CRD crds

cpdef int trans(double[:] crd, double[:, :] path):
    cdef:
        int atoms = path.shape[0]
        int i
        CRD* crd_s = <CRD*>&crd[0]
        PATH* path_s = <PATH*>&path[0][0]
    print (crd_s.y)
    for i in range(atoms):
        print (path_s[i].crds.x, path_s[i].crds.y, path_s[i].crds.z)
    return 1

同样的,我们可以在Cython编译之后,通过Python来调用这个trans函数:

from trans import trans
import numpy as np

a = np.array([1,2,3], dtype=np.float64)
b = np.arange(12).reshape((4,3)).astype(np.float64)

print (trans(a, b))

输出结果为:

2.0
0.0 1.0 2.0
3.0 4.0 5.0
6.0 7.0 8.0
9.0 10.0 11.0
1

当然,这里还是需要提醒一下,
这种指针转换的方式并不安全
,需要谨慎使用。

总结概要

这篇文章介绍了在Cython中定义结构体,并在Python的Numpy数组/MemoryView和自定义结构体之间进行数据转换的方法。Cython有着非常Pythonic的编程范式,又具有接近于C语言的性能,对于Python开发者而言确实是一个很棒的工具。

版权声明

本文首发链接为:
https://www.cnblogs.com/dechinphy/p/cython-struct.html

作者ID:DechinPhy

更多原著文章:
https://www.cnblogs.com/dechinphy/

请博主喝咖啡:
https://www.cnblogs.com/dechinphy/gallery/image/379634.html