【CXX】2 CXX blobstore客户端说明
本示例演示了一个调用blobstore服务的C++客户端的Rust应用程序。事实上,我们会看到两个方向的调用:Rust到C++以及C++到Rust。对于您自己的用例,您可能只需要其中一个方向。
示例中涉及的所有代码都显示在此页面上,但它也以可运行的形式提供在demo目录中https://github.com/dtolnay/cxx.要直接尝试,请从该目录运行cargo run。
共享结构、不透明类型和函数已经在上一篇文章中叙述,不清楚的可以先去看一下。
一、创建项目
我们在命令行中创建一个空白的Cargo项目:
cargo new cxx-demo
编辑Cargo.toml文件,添加对cxx的依赖:
[dependencies]
cxx = "1.0"
二、定义语言边界
CXX依赖于对每种语言向另一种语言公开的函数签名的描述。您可以在Rust模块中使用extern块提供此描述,该模块用#[cxx::bridge]属性宏注释。
我们在项目的main.rs文件的顶部添加该内容:
#[cxx::bridge]
mod ffi {
}
该内容将是FFI边界双方需要达成一致的所有内容。
三、从Rust调用C++函数
让我们获取一个C++blobstore客户端的实例,一个在C++中定义的类blobstore client。
我们将把BlobstreClient视为CXX分类中的不透明类型,这样Rust就不需要对其实现做出任何假设,甚至不需要对它的大小或对齐方式做出任何假设。一般来说,C++类型可能有一个与Rust的move语义不兼容的move构造函数,或者可能包含Rust的借用系统无法建模的内部引用。尽管有其他选择,但在FFI边界上不关心任何此类事情的最简单方法是将其视为不透明,不需要了解类型。
不透明类型只能在间接后面操作,如引用&、Rust Box或UniquePtr(std::unique_ptr的Rust绑定)。我们将添加一个函数,通过该函数,C++可以向Rust返回std::unique_ptr。
// src/main.rs#[cxx::bridge]
mod ffi {unsafe extern "C++" {include!("cxx-demo/include/blobstore.h");type BlobstoreClient;fn new_blobstore_client() -> UniquePtr<BlobstoreClient>;}
}fn main() {let client = ffi::new_blobstore_client();
}
即使CXX自动执行静态断言,确保签名与C++中声明的完全匹配,我们仍然需要确保键入的签名是准确的。比如new_blobstore_client函数如果会发生意外(如内存错误)必须用unsafe标记。这次是在一个安全的extern“C++”块中,因为程序员不再需要对签名进行任何安全声明。
四、添加C++代码
在CXX与Cargo的集成中,默认情况下,所有#include路径都以单元包(crate)名称开头。这就是为什么我们看到 include!(“cxx-demowj/include/blobstore.h”) ——我们将把C++头文件放在Rust单元包内的相对路径include/blostore.h处。如果根据Cargo.toml中的name字段,你的crate的名称不是cxx-demo,那么在本教程中,你需要在所有地方使用这个名称来代替cxx-demo。
// include/blobstore.h#pragma once
#include <memory>class BlobstoreClient {
public:BlobstoreClient();
};std::unique_ptr<BlobstoreClient> new_blobstore_client();// src/blobstore.cc#include "cxx-demo/include/blobstore.h"BlobstoreClient::BlobstoreClient() {}std::unique_ptr<BlobstoreClient> new_blobstore_client() {return std::unique_ptr<BlobstoreClient>(new BlobstoreClient());
}
使用std::make_unique也可以,只要你将std(“c++14”)传递给c++编译器,如稍后所述。
include/和src/中的位置并不重要;只要在整个项目中使用正确的路径,就可以将C++代码放置在单元包中的任何其他位置。
请注意,CXX不会查看这些文件中的任何一个。你可以自由地在这里放任意的C++代码, #include你自主的库等等。CXX库所做的就是针对您在头文件中提供的内容发出静态断言。
五、用Cargo编译C++代码
Cargo有一个适合编译非Rust代码的构建脚本功能。
我们需要在Cargo.toml中引入对CXX的C++代码生成器的新的构建时依赖:
# Cargo.toml[dependencies]
cxx = "1.0"[build-dependencies]
cxx-build = "1.0"
然后在Cargo.toml旁边添加一个build.rs构建脚本,以运行cxx构建代码生成器和C++编译器。相关参数是包含cxx::bridge语言边界定义的Rust源文件的路径,以及在Rust crate构建过程中要编译的任何其他C++源文件的道路。
// build.rsfn main() {cxx_build::bridge("src/main.rs").file("src/blobstore.cc").compile("cxx-demo");println!("cargo:rerun-if-changed=src/main.rs");println!("cargo:rerun-if-changed=src/blobstore.cc");println!("cargo:rerun-if-changed=include/blobstore.h");
}
他的build.rs也是您设置C++编译器标志的地方,例如,如果您想从C++14访问std::make_unique。
cxx_build::bridge("src/main.rs").file("src/blobstore.cc").std("c++14").compile("cxx-demo");
尽管还没有做任何有用的事情,该项目现在应该能够成功构建和运行。命令行输入命令如下:
<cxx-demo路径提示符> cargo runCompiling cxx-demo v0.1.0Finished dev [unoptimized + debuginfo] target(s) in 0.34sRunning `target/debug/cxx-demo`<cxx-demo路径提示符>
六、从C++调用Rust函数
我们的C++blobstore支持不连续缓冲区上传的put操作。例如,我们可能正在上传一个循环缓冲区的快照,该缓冲区往往由2个部分组成,或者由于其他原因(如绳索数据结构)而分散在内存中的文件片段。
我们将通过在连续的借用块上传递迭代器来表达这一点。这与广泛使用的字节箱的Buf特性的API非常相似。在put过程中,我们将让C++回调到Rust中,以获取上传的连续块(所有块都没有在语言边界上进行复制或分配)。实际上,C++客户端可能包含一些复杂的块批处理或并行上传,所有这些都与之相关。
// src/main.rs#[cxx::bridge]
mod ffi {extern "Rust" {type MultiBuf;fn next_chunk(buf: &mut MultiBuf) -> &[u8];}unsafe extern "C++" {include!("cxx-demo/include/blobstore.h");type BlobstoreClient;fn new_blobstore_client() -> UniquePtr<BlobstoreClient>;fn put(&self, parts: &mut MultiBuf) -> u64;}
}
任何具有self参数的签名(等同C++的this)都被认为是一个方法/非静态成员函数。如果周围的extern块中只有一个类型,则它将是该类型的方法。如果有多个类型,您可以通过在参数列表中编写self:&BlostreClient来区分方法属于哪一个。
像往常一样,现在我们需要提供extern“Rust”块声明的所有内容的Rust定义,以及extern“C++”块宣布的新签名的C++定义。
// src/main.rs// An iterator over contiguous chunks of a discontiguous file object. Toy
// implementation uses a Vec<Vec<u8>> but in reality this might be iterating
// over some more complex Rust data structure like a rope, or maybe loading
// chunks lazily from somewhere.
pub struct MultiBuf {chunks: Vec<Vec<u8>>,pos: usize,
}pub fn next_chunk(buf: &mut MultiBuf) -> &[u8] {let next = buf.chunks.get(buf.pos);buf.pos += 1;next.map_or(&[], Vec::as_slice)
}
// include/blobstore.hstruct MultiBuf;class BlobstoreClient {
public:BlobstoreClient();uint64_t put(MultiBuf &buf) const;
};
在blobstre.cc中,我们可以调用Rust next_chunk函数,该函数通过CXX代码生成器生成的头部文件main.rs.h暴露给C++。在CXX的Cargo集成中,这个生成的头文件有一个包含crate名称、crate中Rust源文件的相对路径和.rs.h扩展名的路径。
// src/blobstore.cc#include "cxx-demo/include/blobstore.h"
#include "cxx-demo/src/main.rs.h"
#include <functional>
#include <string>// Upload a new blob and return a blobid that serves as a handle to the blob.
uint64_t BlobstoreClient::put(MultiBuf &buf) const {// Traverse the caller's chunk iterator.std::string contents;while (true) {auto chunk = next_chunk(buf);if (chunk.size() == 0) {break;}contents.append(reinterpret_cast<const char *>(chunk.data()), chunk.size());}// Pretend we did something useful to persist the data.auto blobid = std::hash<std::string>{}(contents);return blobid;
}
现在可以使用了
// src/main.rsfn main() {let client = ffi::new_blobstore_client();// Upload a blob.let chunks = vec![b"fearless".to_vec(), b"concurrency".to_vec()];let mut buf = MultiBuf { chunks, pos: 0 };let blobid = client.put(&mut buf);println!("blobid = {}", blobid);
}
运行信息如下:
cxx-demo$ cargo runCompiling cxx-demo v0.1.0Finished dev [unoptimized + debuginfo] target(s) in 0.41sRunning `target/debug/cxx-demo`blobid = 9851996977040795552
七、插曲:产生了什么?
对于好奇的人来说,很容易了解CXX为使这些函数调用工作所做的幕后工作。在CXX的正常使用过程中,您不需要这样做,但就本教程而言,这可能具有教育意义。
CXX包含两个代码生成器:一个Rust生成器(即CXX::bridge属性过程宏)和一个C++生成器。
Rust生成的代码
通过cargo-expand可以最容易地查看程序宏的输出。然后运行cargo expand ::ffi宏展开mod ffi模块。
cxx-demo$ cargo install cargo-expand
cxx-demo$ cargo expand ::ffi
您将看到一些非常令人不快的代码,涉及#[repr(C)]、#[repr©], #[link_name] 和 #[export_name].。
八、C++生成的代码
为了调试方便,cxx_build将所有生成的C++代码链接到Cargo在target/cxxbridge/下的目标目录中。
cxx-demo$ exa -T target/cxxbridge/
target/cxxbridge
├── cxx-demo
│ └── src
│ ├── main.rs.cc -> ../../../debug/build/cxx-demo-11c6f678ce5c3437/out/cxxbridge/sources/cxx-demo/src/main.rs.cc
│ └── main.rs.h -> ../../../debug/build/cxx-demo-11c6f678ce5c3437/out/cxxbridge/include/cxx-demo/src/main.rs.h
└── rust└── cxx.h -> ~/.cargo/registry/src/github.com-1ecc6299db9ec823/cxx-1.0.0/include/cxx.h
在这些文件中,您将看到语言边界中存在的任何CXX Rust类型的声明或模板(如Rust::Slicefor&[T])以及与extern函数对应的extern“C”签名。
如果CXX C++代码生成器更适合您的工作流程,它也可以作为一个独立的可执行文件提供,将生成的代码输出到stdout。
cxx-demo$ cargo install cxxbridge-cmd
cxx-demo$ cxxbridge src/main.rs
九、共享数据结构
到目前为止,上述两个方向的调用只使用了不透明类型,而不是共享结构。
共享结构是数据结构,其完整定义对两种语言都是可见的,从而可以通过值跨语言传递它们。共享结构转换为C++聚合初始化兼容结构,与Rust结构的布局完全匹配。
作为此演示的最后一步,我们将使用共享结构体BlobMetadata在Rust应用程序和C++blobstore客户端之间传递有关blob的元数据。
// src/main.rs#[cxx::bridge]
mod ffi {struct BlobMetadata {size: usize,tags: Vec<String>,}extern "Rust" {// ...}unsafe extern "C++" {// ...fn tag(&self, blobid: u64, tag: &str);fn metadata(&self, blobid: u64) -> BlobMetadata;}
}fn main() {let client = ffi::new_blobstore_client();// Upload a blob.let chunks = vec![b"fearless".to_vec(), b"concurrency".to_vec()];let mut buf = MultiBuf { chunks, pos: 0 };let blobid = client.put(&mut buf);println!("blobid = {}", blobid);// Add a tag.client.tag(blobid, "rust");// Read back the tags.let metadata = client.metadata(blobid);println!("tags = {:?}", metadata.tags);
}
// include/blobstore.h#pragma once
#include "rust/cxx.h"struct MultiBuf;
struct BlobMetadata;class BlobstoreClient {
public:BlobstoreClient();uint64_t put(MultiBuf &buf) const;void tag(uint64_t blobid, rust::Str tag) const;BlobMetadata metadata(uint64_t blobid) const;private:class impl;std::shared_ptr<impl> impl;
};// src/blobstore.cc#include "cxx-demo/include/blobstore.h"
#include "cxx-demo/src/main.rs.h"
#include <algorithm>
#include <functional>
#include <set>
#include <string>
#include <unordered_map>// Toy implementation of an in-memory blobstore.
//
// In reality the implementation of BlobstoreClient could be a large
// complex C++ library.
class BlobstoreClient::impl {friend BlobstoreClient;using Blob = struct {std::string data;std::set<std::string> tags;};std::unordered_map<uint64_t, Blob> blobs;
};BlobstoreClient::BlobstoreClient() : impl(new class BlobstoreClient::impl) {}// Add tag to an existing blob.
void BlobstoreClient::tag(uint64_t blobid, rust::Str tag) const {impl->blobs[blobid].tags.emplace(tag);
}// Retrieve metadata about a blob.
BlobMetadata BlobstoreClient::metadata(uint64_t blobid) const {BlobMetadata metadata{};auto blob = impl->blobs.find(blobid);if (blob != impl->blobs.end()) {metadata.size = blob->second.data.size();std::for_each(blob->second.tags.cbegin(), blob->second.tags.cend(),[&](auto &t) { metadata.tags.emplace_back(t); });}return metadata;
}
运行命令:
cxx-demo$ cargo runRunning `target/debug/cxx-demo`blobid = 9851996977040795552
tags = ["rust"]
现在您已经看到了本教程中涉及的所有代码。它可以在演示目录中以可运行的形式一起使用https://github.com/dtolnay/cxx.您可以直接运行它,而无需从该目录运行cargo run来完成上述步骤。
十、结束语
CXX的主要贡献是它为你提供了Rust与C++的互操作性,在这种互操作性中,你编写的所有Rust端代码看起来都像是在写普通的Rust,而C++端看起来也像是在编写普通的C++。
在文中,您已经看到,所涉及的代码都不像C,也不像通常危险的“FFI胶水”,容易发生泄漏或内存安全缺陷。
由不透明类型、共享类型和关键标准库类型绑定组成的表达系统使API能够在语言边界上进行设计,从而获取接口的正确所有权和借用契约。
CXX发挥了Rust类型系统和C++类型系统的优势以及程序员的直觉。一个在没有Rust背景的C++端或没有C++背景的Rust端工作的人,将能够运用他们对语言开发的所有常见直觉和最佳实践来维护正确的FFI。
相关文章:
【CXX】2 CXX blobstore客户端说明
本示例演示了一个调用blobstore服务的C客户端的Rust应用程序。事实上,我们会看到两个方向的调用:Rust到C以及C到Rust。对于您自己的用例,您可能只需要其中一个方向。 示例中涉及的所有代码都显示在此页面上,但它也以可运行的形式提…...

HTTP相关面试题
HTTP/1.1、HTTP/2、HTTP/3 演变 HTTP/1.1 相比 HTTP/1.0 提高了什么性能? HTTP/1.1 相⽐ HTTP/1.0 性能上的改进: 使⽤长连接的⽅式改善了 HTTP/1.0 短连接造成的性能开销。⽀持管道(pipeline)网络传输,只要第⼀个请…...
关于XML映射器的基本问题
前言 XML 映射器是 MyBatis 中用于定义 SQL 语句及其与 Java 对象映射关系的 XML 文件。它通过 XML 配置将数据库操作与 Java 代码分离,使 SQL 语句更易维护和管理。 主要作用 定义 SQL 语句:在 XML 中编写 SQL 查询、插入、更新和删除操作。 映射结果…...

【MyBatis】预编译SQL与即时SQL
目录 1. 以基本类型参数为例测试#{ }与${ }传递参数的区别 1.1 参数为Integer类型 1.2 参数为String类型 2. 使用#{ }传参存在的问题 2.1 参数为排序方式 2.2 模糊查询 3. 使用${ }传参存在的问题 3.1 SQL注入 3.2 对比#{ } 与 ${ }在SQL注入方面存在的问题 3.3 预编译…...
Python--正则表达式
1. 日志打印与终端颜色控制 1.1 使用 loguru 打印日志 from loguru import loggerlogger.debug("调试信息") logger.info("普通信息") logger.warning("警告信息") logger.error("错误信息") logger.success("成功信息"…...
【java面试】线程篇
1.什么是线程? 线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。 2.线程和进程有什么区别? 线程是进程的子集,一个进程可以有很多线程,每条线程并行执行不同的任…...

分布式光纤传感:为生活编织“感知密网”
分布式光纤测温技术虽以工业场景为核心,但其衍生的安全效益已逐步渗透至日常生活。 分布式光纤测温技术(DTS)作为一种先进的线型温度监测手段,近年来在多个领域展现了其独特的优势。虽然其核心应用场景主要集中在工业、能源和基础…...
cmake Qt Mingw windows构建
今天教大家怎么在windows构建qt应用使用cmd命令行,而不是一键通过QtCreator一键构建。首先我们用qtcreator创建一个模板程序(PS:记得在安装qt时要悬着mingw套件,如果安装太慢可以换源) 输入以下的命令: mkdir build …...

无人机信号调制技术原理
一、调制技术的必要性 频谱搬移:将低频的基带信号搬移到高频的载波上,便于天线辐射和传播。 信道复用: 利用不同的载波频率或调制方式,实现多路信号同时传输,提高信道利用率。 抗干扰: 通过选择合适的调…...
书评与笔记:《如何有效报告Bug》
文章目录 书评笔记核心原则1. 首要目标:让程序员亲眼看到问题2. 次要目标:详细描述问题3. 保持冷静,避免误操作4. 提供额外信息5. 清晰、准确地表达 实用建议不要自作聪明地诊断问题类比:看医生时的症状描述程序员的心理 总结 原文…...
3.【线性代数】——矩阵乘法和逆矩阵
三 矩阵乘法和逆矩阵 1. 矩阵乘法1.1 常规方法1.2 列向量组合1.3 行向量组合1.4 单行和单列的乘积和1.5 块乘法 2. 逆矩阵2.1 逆矩阵的定义2.2 奇异矩阵2.3 Gauss-Jordan 求逆矩阵2.3.1 求逆矩阵 ⟺ \Longleftrightarrow ⟺解方程组2.3.2 Gauss-Jordan求逆矩阵 1. 矩阵乘法 1.…...
[JVM篇]虚拟机性能监控、故障处理工具
虚拟机性能监控、故障处理工具 基础故障处理工具 jps(JVM Peocess Status Tool - 虚拟机进程状况工具) jstat(JVM Statistics Monitoring Too - 虚拟机统计信息监视工具) jinfo( Configuration info for Java - Java配置信息工具) jmap(Memory Map for…...

UniApp 中 margin 和 padding 属性的使用详解
margin 属性的作用与使用 margin 属性用于设置元素的外边距,也就是元素与其他元素之间的距离。它可以分别设置元素四个方向(上、右、下、左)的外边距,也支持使用简写形式来一次性设置多个方向的外边距。 <template><view…...
`fi` 是 Bash 脚本中用来结束 `if` 条件语句块的关键字
fi 是 Bash 脚本中 if 语句的结束标志,它用于结束一个 if 块。与其他编程语言(如 C、Java)中的 } 不同,Bash 使用 fi 来标识条件语句的结束。 语法示例: if [ condition ]; then# 如果条件为真时执行的代码echo &quo…...

cap2:1000分类的ResNet的TensorRT部署指南(python版)
《TensorRT全流程部署指南》专栏文章目录: cap1:TensorRT介绍及CUDA环境安装cap2:1000分类的ResNet的TensorRT部署指南(python版)cap3:自定义数据集训练ResNet的TensorRT部署指南(python版&…...
每日一题——把数字翻译成字符串
把数字翻译成字符串 题目描述示例示例1示例2 题解动态规划代码实现复杂度分析 总结 题目描述 有一种将字母编码成数字的方式:‘a’->1, ‘b’->2, … , ‘z’->26。 现在给一串数字,返回有多少种可能的译码结果。 数据范围:字符串…...

我们来学HTTP/TCP -- 三次握手?
三次握手 题记三次呼叫结语 题记 来,我们来演示下川普王和普京帝会面了 哎呦!你好你好,握手…哎嗨!侬好侬好,握手…欧嘿呦玛斯,握手… 抓狂啊!作孽啊!!! 不说人话啊! 关键的是,“三…...

多媒体软件安全与授权新范例,用 CodeMeter 实现安全、高效的软件许可管理
背景概述 Reason Studios 成立于 1994 年,总部位于瑞典斯德哥尔摩,是全球领先的音乐制作软件开发商。凭借创新的软件产品和行业标准技术,如 ReWire 和 REX 文件格式,Reason Studios 为全球专业音乐人和业余爱好者提供了一系列高质…...
SQL复习
SQL复习 MySQL MySQL MySQL有什么特点? MySQL 不支持全外连接。 安装 数据类型 MySQL中的数据类型分为哪些? MySQL中的数据类型主要分为三大类:数值类型、字符串类型、日期时间类型。 其中, 数值类型又分为七种:T…...
红队视角出发的k8s敏感信息收集——日志与监控系统
针对 Kubernetes 日志与监控系统 的详细攻击视角分析,聚焦 集群审计日志 和 Prometheus/Grafana 暴露 的潜在风险及利用方法 攻击链示例 1. 攻击者通过容器逃逸进入 Pod → 2. 发现未认证的 Prometheus 服务 → 3. 查询环境变量标签获取数据库密码 → 4. 通过审…...

SpringBoot-17-MyBatis动态SQL标签之常用标签
文章目录 1 代码1.1 实体User.java1.2 接口UserMapper.java1.3 映射UserMapper.xml1.3.1 标签if1.3.2 标签if和where1.3.3 标签choose和when和otherwise1.4 UserController.java2 常用动态SQL标签2.1 标签set2.1.1 UserMapper.java2.1.2 UserMapper.xml2.1.3 UserController.ja…...
Spring Boot 实现流式响应(兼容 2.7.x)
在实际开发中,我们可能会遇到一些流式数据处理的场景,比如接收来自上游接口的 Server-Sent Events(SSE) 或 流式 JSON 内容,并将其原样中转给前端页面或客户端。这种情况下,传统的 RestTemplate 缓存机制会…...

3.3.1_1 检错编码(奇偶校验码)
从这节课开始,我们会探讨数据链路层的差错控制功能,差错控制功能的主要目标是要发现并且解决一个帧内部的位错误,我们需要使用特殊的编码技术去发现帧内部的位错误,当我们发现位错误之后,通常来说有两种解决方案。第一…...
Cesium1.95中高性能加载1500个点
一、基本方式: 图标使用.png比.svg性能要好 <template><div id"cesiumContainer"></div><div class"toolbar"><button id"resetButton">重新生成点</button><span id"countDisplay&qu…...
深入浅出:JavaScript 中的 `window.crypto.getRandomValues()` 方法
深入浅出:JavaScript 中的 window.crypto.getRandomValues() 方法 在现代 Web 开发中,随机数的生成看似简单,却隐藏着许多玄机。无论是生成密码、加密密钥,还是创建安全令牌,随机数的质量直接关系到系统的安全性。Jav…...
线程与协程
1. 线程与协程 1.1. “函数调用级别”的切换、上下文切换 1. 函数调用级别的切换 “函数调用级别的切换”是指:像函数调用/返回一样轻量地完成任务切换。 举例说明: 当你在程序中写一个函数调用: funcA() 然后 funcA 执行完后返回&…...

Cloudflare 从 Nginx 到 Pingora:性能、效率与安全的全面升级
在互联网的快速发展中,高性能、高效率和高安全性的网络服务成为了各大互联网基础设施提供商的核心追求。Cloudflare 作为全球领先的互联网安全和基础设施公司,近期做出了一个重大技术决策:弃用长期使用的 Nginx,转而采用其内部开发…...
OpenLayers 分屏对比(地图联动)
注:当前使用的是 ol 5.3.0 版本,天地图使用的key请到天地图官网申请,并替换为自己的key 地图分屏对比在WebGIS开发中是很常见的功能,和卷帘图层不一样的是,分屏对比是在各个地图中添加相同或者不同的图层进行对比查看。…...
Web 架构之 CDN 加速原理与落地实践
文章目录 一、思维导图二、正文内容(一)CDN 基础概念1. 定义2. 组成部分 (二)CDN 加速原理1. 请求路由2. 内容缓存3. 内容更新 (三)CDN 落地实践1. 选择 CDN 服务商2. 配置 CDN3. 集成到 Web 架构 …...

[免费]微信小程序问卷调查系统(SpringBoot后端+Vue管理端)【论文+源码+SQL脚本】
大家好,我是java1234_小锋老师,看到一个不错的微信小程序问卷调查系统(SpringBoot后端Vue管理端)【论文源码SQL脚本】,分享下哈。 项目视频演示 【免费】微信小程序问卷调查系统(SpringBoot后端Vue管理端) Java毕业设计_哔哩哔哩_bilibili 项…...