关于 Rust程序设计语言-构建多线程 Web服务器 一章的一些问题

前言

最近在跟着《Rust 程序设计语言》一书学习Rust,在学习最后一章构建多线程 Web 服务器的最后两节时,我遇到了一些问题,并尝试进行解释,接下来分享给大家。

将单线程服务器变为多线程服务器

按照20.2.将单线程服务器变为多线程服务器 一节的指引,编写多线程TCP服务如下:

use std::net::{TcpListener, TcpStream}; // TcpListener 用于监听 TCP 连接
use std::io::prelude::*; // 引入读写流所需的特定 trait,比如 Read trait
use std::fs;
use std::thread;
use std::time::Duration;
use std::sync::mpsc;
use std::sync::Arc;
use std::sync::Mutex;type Job = Box<dyn FnOnce() + Send + 'static>;fn main() {let listener = TcpListener::bind("127.0.0.1:7878").expect("Failed to bind to address");println!("Server listening on port 7878...");// 实例化线程池,包含4个子线程let pool = ThreadPool::new(4);for stream in listener.incoming() {match stream {Ok(stream) => {pool.execute(|| {handle_connection(stream); // 处理请求});}Err(e) => {eprintln!("Error: {}", e);}}}
}fn handle_connection(mut stream: TcpStream) {let mut buffer = [0; 1024];stream.read(&mut buffer).expect("Failed to bind to read data"); let get = b"GET / HTTP/1.1\r\n";let sleep = b"GET /sleep HTTP/1.1\r\n";let response = if buffer.starts_with(get) { // 如果请求根目录,返回一个HTML文件let contents = fs::read_to_string("./html/hello.html").unwrap();format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",contents.len(),contents)} else if buffer.starts_with(sleep) { // 如果访问 /sleep 路径,则模拟执行一个耗时5s的慢操作,再返回一个HTML文件thread::sleep(Duration::from_secs(5));let contents = fs::read_to_string("./html/hello.html").unwrap();format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",contents.len(),contents)} else { // 其他路径一律返回404"HTTP/1.1 404 Not Found\r\n\r\n404 Not Found".to_string()};stream.write(response.as_bytes()).unwrap();stream.flush().unwrap();
}// 线程池
pub struct ThreadPool {workers: Vec<Worker>,sender: mpsc::Sender<Job>,
}impl ThreadPool {pub fn new(size: usize) -> ThreadPool {assert!(size > 0);// 跨进程传递消息let (sender, receiver) = mpsc::channel();// Rust 所提供的通道实现是多生产者,单消费者,所以需要共享消费者let receiver = Arc::new(Mutex::new(receiver));// with_capacity用于创建一个预先分配了指定容量的空 Vec,避免动态分配内存和复制数据的开销let mut workers = Vec::with_capacity(size);for id in 0..size {workers.push(Worker::new(id, Arc::clone(&receiver)));}ThreadPool {workers,sender,}}pub fn execute<F>(&self, f: F)whereF: FnOnce() + Send + 'static// FnOnce()表示闭包可以被调用一次。在这个多线程的环境下,每个任务只需要被执行一次,所以需要这个约束// Send表示闭包所有权可以在线程间传递// 'static表示这个闭包可以存活整个程序的生命周期。这是因为无法预知任务何时会被执行完,所以需要保证它在任何时候都是有效的{// f在编译时不知道具体的大小,所以需要用Box包装一层,以在堆上分配内存?let job = Box::new(f);// 将处理请求的函数发送给子线程self.sender.send(job).unwrap();}
}// 子线程
struct Worker {id: usize,thread: thread::JoinHandle<()>,
}impl Worker {fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {let thread = thread::spawn(move || {loop { // 一直循环以监听主线程是否有发送消息let job = receiver.lock().unwrap().recv().unwrap();println!("Worker {} got a job; executing.", id);job(); // job 就是 handle_connection}});Worker {id,thread,}}
}

这个程序预期的执行结果是,当TCP服务启动之后,先在浏览器中访问 http://localhost:7878/sleep 触发一个慢操作,然后立即新开tab页访问 http://localhost:7878,可以发现对根目录的访问并没有因为慢操作而被阻塞,这也是本节要展示的将单线程服务器变为多线程服务器后的优势。
最关键的代码在 Worker 的 impl 块中 ,子线程通过 loop 循环不断尝试获取 receiver 的锁,以监听主线程发送的数据,并完成对当前请求的处理。
现在对 Worker 的实现进行如下两种修改:
改法一:loop循环内部使用一个变量保存 receiver.lock().unwrap() 返回的值

impl Worker {fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {let thread = thread::spawn(move || {loop {let rx = receiver.lock().unwrap();let job = rx.recv().unwrap()println!("Worker {} got a job; executing.", id);job();}});Worker {id,thread,}}
}

改法二:loop 循环改成 while let

impl Worker {fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {let thread = thread::spawn(move || {while let Ok(job) = receiver.lock().unwrap().recv() {println!("Worker {} got a job; executing.", id);job();}});Worker {id,thread,}}
}

修改之后,再到浏览器中进行相同的操作,会发现先进行的慢操作对后续的访问形成阻塞。
我们查看书中的说明:

在学习了第 18 章的 while let 循环之后,你可能会好奇为何不能如此编写 worker 线程,如示例 20-21 所示:…
这段代码可以编译和运行,但是并不会产生所期望的线程行为:一个慢请求仍然会导致其他请求等待执行。其原因有些微妙:Mutex 结构体没有公有 unlock 方法,因为锁的所有权依赖 lock 方法返回的 LockResult<MutexGuard> 中 MutexGuard 的生命周期。这允许借用检查器在编译时确保绝不会在没有持有锁的情况下访问由 Mutex 守护的资源,不过如果没有认真的思考 MutexGuard 的生命周期的话,也可能会导致比预期更久的持有锁。因为 while 表达式中的值在整个块一直处于作用域中,job() 调用的过程中其仍然持有锁,这意味着其他 worker 不能接收任务。

相反通过使用 loop 并在循环块之内而不是之外获取锁和任务,lock 方法返回的 MutexGuard 在 let job 语句结束之后立刻就被丢弃了。这确保了 recv 调用过程中持有锁,而在 job() 调用前锁就被释放了,这就允许并发处理多个请求了。

但估计看完这段文字大家可能还是不明所以,英文原版反而能给出更多的信息:

The code in Listing 20-20 that uses let job = receiver.lock().unwrap().recv().unwrap(); works because with let, any temporary values used in the expression on the right hand side of the equals sign are immediately dropped when the let statement ends. However, while let (and if let and match) does not drop temporary values until the end of the associated block. In Listing 20-21, the lock remains held for the duration of the call to job(), meaning other workers cannot receive jobs.

翻译一下就是:

示例 20-20 中使用 let job=receiver.lock().unwrap().recv().uwrap();之所以有效,是因为使用 let,当let语句结束时,等号右侧表达式中使用的任何临时值都会立即删除。但是 while let(以及if let和match)直到关联块结束时才丢弃临时值。在示例 20-21中,锁在对 job() 的调用期间保持不变,这意味着其他 Worker 无法接收任务。

三种写法的效果不同是由于Rust对不同变量生命周期的处理不同所导致的
对于临时变量,只在当前语句内有效,当前语句执行结束就失效了。具体来说在 let job = receiver.lock().unwrap().recv().unwrap();中等号右边所产生的所有中间值都只是临时变量,则在当期语句结束后就会被释放,由此当这行代码执行完成,即子线程从主线程中接受到任务之后,对 receiver 的锁就会被释放掉,其他线程才可以拿到 receiver 的锁,进而处理后续的请求。这就是原写法能够正常工作的原因。

但在第一种改法中,我们使用了一个变量 rx 保存了 receiver.lock().unwrap() 的返回值,这就导致在 loop 循环中,当前线程对 receiver 的锁会在本次循环结束后被释放,其他线程才有机会获取到 receiver 的锁,这实际就将对请求的处理改成串行了,如果上一个请求需要执行慢操作,自然会阻塞下一个请求的处理。

在第二种改法中,由于在 while let,if let,match 语句会在自己的作用域内一直持有临时变量,导致 while let Ok(job) = receiver.lock().unwrap().recv()中,虽然 job 只是一个临时变量,但会在本次循环中一直保留,结果与第一种改法一样。

优雅停机与清理

第二个点是在20.3.优雅停机与清理一节中,按照书中指引,可得到代码如下:

use std::net::{TcpListener, TcpStream}; // TcpListener 用于监听 TCP 连接
use std::io::prelude::*; // 引入读写流所需的特定 trait,比如 Read trait
use std::fs;
use std::thread;
use std::time::Duration;
use std::sync::mpsc;
use std::sync::Arc;
use std::sync::Mutex;type Job = Box<dyn FnOnce() + Send + 'static>;fn main() {let listener = TcpListener::bind("127.0.0.1:7878").expect("Failed to bind to address");println!("Server listening on port 7878...");// 实例化线程池let pool = ThreadPool::new(4);for stream in listener.incoming().take(1) { // 仅循环一次match stream {Ok(stream) => {pool.execute(|| {handle_connection(stream);});}Err(e) => {eprintln!("Error: {}", e);}}}println!("Shutting down.");// 当循环执行完成后,代码运行到此处时,pool将会被丢弃,触发drop方法// drop方法向所有子线程发送终止命令
}fn handle_connection(mut stream: TcpStream) {let mut buffer = [0; 1024];stream.read(&mut buffer).expect("Failed to bind to read data");// 同样的路径处理let get = b"GET / HTTP/1.1\r\n";let sleep = b"GET /sleep HTTP/1.1\r\n";let response = if buffer.starts_with(get) {let contents = fs::read_to_string("./html/hello.html").unwrap();format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",contents.len(),contents)} else if buffer.starts_with(sleep) {thread::sleep(Duration::from_secs(10));let contents = fs::read_to_string("./html/hello.html").unwrap();format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",contents.len(),contents)} else {"HTTP/1.1 404 Not Found\r\n\r\n404 Not Found".to_string()};stream.write(response.as_bytes()).unwrap();stream.flush().unwrap();
}enum Message {NewJob(Job),Terminate,
}// 线程池
pub struct ThreadPool {workers: Vec<Worker>,sender: mpsc::Sender<Message>,
}impl ThreadPool {pub fn new(size: usize) -> ThreadPool {assert!(size > 0);// 跨进程传递消息let (sender, receiver) = mpsc::channel();// Rust 所提供的通道实现是多生产者,单消费者,所以需要共享消费者let receiver = Arc::new(Mutex::new(receiver));// with_capacity用于创建一个预先分配了指定容量的空 Vec,避免动态分配内存和复制数据的开销let mut workers = Vec::with_capacity(size);for id in 0..size {workers.push(Worker::new(id, Arc::clone(&receiver)));}ThreadPool {workers,sender,}}pub fn execute<F>(&self, f: F)whereF: FnOnce() + Send + 'static{let job = Box::new(f);self.sender.send(Message::NewJob(job)).unwrap();}
}impl Drop for ThreadPool {fn drop(&mut self) {println!("Sending terminate message to all workers.");for _ in &mut self.workers {self.sender.send(Message::Terminate).unwrap();}println!("Shutting down all workers.");for worker in &mut self.workers {println!("Shutting down worker {}", worker.id);// join需要thread的所有权,通过调用 Option 上的 take 将 thread 移动出 workerif let Some(thread) = worker.thread.take() {thread.join().unwrap();println!("worker {} joined.", worker.id);}}}
}struct Worker {id: usize,thread: Option<thread::JoinHandle<()>>,
}impl Worker {fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Message>>>) ->Worker {let thread = thread::spawn(move ||{loop {let message = receiver.lock().unwrap().recv().unwrap();match message {Message::NewJob(job) => {println!("Worker {} got a job; executing.", id);job();},Message::Terminate => {println!("Worker {} was told to terminate.", id);break;},}}});Worker {id,thread: Some(thread),}}
}

这个程序预期的运行结果是,当TCP服务启动之后,在浏览器中访问 http://localhost:7878/sleep 触发一次慢处理,只要有一次请求,程序就会关闭所有的子线程,终端中的打印类似于:

Server listening on port 7878...
# 这里发起请求
Shutting down.
Sending terminate message to all workers.
Worker 2 got a job; executing.
Worker 0 was told to terminate.
Shutting down all workers.
Shutting down worker 0
Worker 3 was told to terminate.
Worker 1 was told to terminate.
worker 0 joined.
Shutting down worker 1
worker 1 joined.
Shutting down worker 2
Worker 2 was told to terminate.
worker 2 joined.
Shutting down worker 3
worker 3 joined.

第一个需要注意的点是,处理请求的子线程不一定是线程池中的第一个子线程,但一定是第一个获取 receiver锁 的子线程。所以 Worker 2 got a job; executing. 这行具体打印的是第几个 Worker 不是固定的。

第二个需要注意的点是在 ThreadPool 实现 Drop 的块中,通过一个 for 循环向所有的子线程发送终止命令:

for _ in &mut self.workers {self.sender.send(Message::Terminate).unwrap();}

这行代码其实没有什么问题,但无论是英文原版还是中文翻译,对这段代码的解释都是“向每个 Worker发送一个 Terminate 消息”,这会造成一个误解——每个 Worker 是按顺序收到终止命令的。
但 send 方法其实没有指定发给哪个子线程,只有当前拥有 receiver 锁的子线程才能收到本次循环发送的消息,进而退出 loop 循环,不再继续在 loop 中尝试获取 receiver 的锁以监听消息,因此后续的终止命令就由其他子线程接收。同时,消息的发送的先后顺序和接受的先后顺序没有关系,可能先发送的消息比后发送的消息更晚被接收,这就导致虽然 for 循环是按顺序遍历的, 但 Worker [n] was told to terminate. 的打印顺序并不是按遍历顺序打印的。

不过有一个顺序是固定的,那就是 Shutting down worker [n]. 和 worker [n] joined. 这两行一定是按照顺序打印的。这很好理解,因为这两个打印是在另外一个 for 循环中按顺序执行的:

for worker in &mut self.workers {// 按顺序调用join(),阻塞主线程继续执行// 阻塞的时长主要取决于当前执行任务的线程什么时候执行完,可以访问/sleep路径验证println!("Shutting down worker {}", worker.id);// join需要thread的所有权,通过调用 Option 上的 take 将 thread 移动出 workerif let Some(thread) = worker.thread.take() {thread.join().unwrap();println!("worker {} joined.", worker.id);}
}

但问题来了,为什么这里要使用两个 for 循环,一个发送终止命令,另一个调用 join 方法,不能在一个 for 循环中发送终止命令后马上调用 join 方法吗?
我们对代码进行改造:

impl Drop for ThreadPool {fn drop(&mut self) {println!("Sending terminate message to all workers.");for worker in &mut self.workers {self.sender.send(Message::Terminate).unwrap();println!("Sended terminate message to worker {}.", worker.id);if let Some(thread) = worker.thread.take() {thread.join().unwrap();}println!("worker {} joined.", worker.id);}println!("Shutting down all workers.");}
}

发现当我们在浏览器中发起请求后,程序并没有关闭所有的子线程并退出,控制台中的打印类似于:

Server listening on port 7878...
Shutting down.
Sending terminate message to all workers.
Sended terminate message to worker 0.
Worker 0 got a job; executing.
Worker 1 was told to terminate.

这里奇怪的地方不止程序没有按预期关闭所有的子线程,还有为什么 Sended terminate message to worker 0. 之后打印的是 Worker 1 was told to terminate. ?
其实前面提到的两个需要注意的点就已经可以解释在这个现象了。处理任务的是 Worker 0,在执行 job 时 Worker 0 已经失去receiver的锁,所以它收不到终止命令,转而由其他子线程接收。这里是 Worker 1 收到终止命令,所以虽然 for 循环中是按顺序发送终止命令,但首先被终止的是 Worker 1 而非 Worker 0。

接下来我们再解释为什么要使用两个 for 循环,书中有说明如下:

现在遍历了 worker 两次,一次向每个 worker 发送一个 Terminate 消息,一个调用每个 worker 线程上的 join。如果尝试在同一循环中发送消息并立即 join 线程,则无法保证当前迭代的 worker 是从通道收到终止消息的 worker。
为了更好的理解为什么需要两个分开的循环,想象一下只有两个 worker 的场景。如果在一个单独的循环中遍历每个 worker,在第一次迭代中向通道发出终止消息并对第一个 worker 线程调用 join。如果此时第一个 worker 正忙于处理请求,那么第二个 worker 会收到终止消息并停止。我们会一直等待第一个 worker 结束,不过它永远也不会结束因为第二个线程接收了终止消息。死锁!

这段话可能不太好理解,但牢记前面提到的两个需要注意的点,如果我们在发送终止命令之后,马上调用 join 方法阻塞主线程,则主线程将在暂停循环,直到子线程结束后再接着执行下一次循环。但通过之前的解释我们知道,当前 for 循环中的 Worker 可能正在执行慢处理,收不到终止命令,反而是其他空闲的子线程收到命令,然后结束自身 loop 循环。但 join 方法却是在当前 Worker 上调用的,对于正在执行任务的 Worker 来说,当前任务完成后,由于没有收到终止消息,它会继续loop循环,所以主线程的等到遥遥无期,for 循环无法继续往下执行。

但换成使用两个 for 循环的方案,第一次 for 循环按照子线程的数量发送终止命令,即使正在执行任务的子线程一开始收不到消息,但等到其处理完成,其他子线程已经结束了,它可以从容的获取到 receiver 锁,收到最后一次发送的终止命令,进而结束自己的 loop 循环。第二个 for 循环按顺序等待子线程执行完毕,如果恰好遍历到了正在执行任务的子线程也没有关系,因为按照刚才所述,这个子线程最终也会收到终止命令,只不过这段阻塞的时长就取决于当前任务什么时候执行完,然后主线程才能继续等待其他子线程结束,虽然剩下的子线程早就收到终止命令进而结束执行了。

参考

Rust 临时变量的生命周期
Rust 关于 let 语句中以下划线变量名需要注意的一个点, _, _var, var 三者在生命周期上的区别

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/666320.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Oracle常用命令

创建用户&#xff1a; create user 用户名 identified by 密码 default tablespace zhijie; grant dba,resource,connect to 用户名; CREATE OR REPLACE TYPE DOMAIN_NAMES_COLTYPE AS TABLE OF varchar2(255); CREATE OR REPLACE TYPE TYPE_SPLIT is table of varchar2(40…

刷题11 双指针

一、同向双指针 209. 长度最小的子数组 给定一个含有 n 个正整数的数组和一个正整数 target 。 找出该数组中满足其总和大于等于 target 的长度最小的 连续子数组 [numsl, numsl1, ..., numsr-1, numsr] &#xff0c;并返回其长度。如果不存在符合条件的子数组&#xff0c;返回…

面试 JavaScript 框架八股文十问十答第二期

面试 JavaScript 框架八股文十问十答第二期 作者&#xff1a;程序员小白条&#xff0c;个人博客 相信看了本文后&#xff0c;对你的面试是有一定帮助的&#xff01;关注专栏后就能收到持续更新&#xff01; ⭐点赞⭐收藏⭐不迷路&#xff01;⭐ 1&#xff09;其他值到字符串的…

两个重要极限【高数笔记】

【第一个&#xff1a;lim &#xff08;sinx / x&#xff09; 1, x -- > 0】 1.本质&#xff1a; lim &#xff08;sin‘&#xff1f;’ / ‘&#xff1f;’&#xff09; 1, ‘&#xff1f;’ -- > 0&#xff1b;保证‘&#xff1f;’ -- > 0,与趋向无关 2.例题&#x…

【ELK】logstash快速入门

1.概述 1.1.什么是logstash&#xff1f; 之前我们聊了es&#xff0c;并且用docker搭建了一个eskibana的环境。es目前最普遍的用法是用来存储日志的&#xff0c;然后结合kibana对日志做一些可视化的工作。既然要收集日志&#xff0c;就面临着一个问题&#xff1a; 各个系统的…

口味多样的碱水贝果面包,香气饱满松松软软

这两天在吃一款碱趣贝果面包&#xff0c;感觉味道很不错&#xff0c;它是一种加热一下就可以吃的手工面包&#xff0c;口感十分独特。这款面包有着清香有韧性的表皮&#xff0c;里面松软可口&#xff0c;加热后更是香气四溢。 除了标准的原味全麦之外&#xff0c;碱趣贝果面包还…

Python爬虫某云音乐歌手及下载其免费音乐

import os import re import threading import timefrom lxml import etreeimport requests from bs4 import BeautifulSoup from database import MyDataBase from utils import make_user_agent 注意&#xff1a;database和utils是自己写的。没有注释&#xff0c;不懂就问 先…

【大厂AI课学习笔记】1.4 算法的进步(3)关于Hinton

Geoffrey Hinton&#xff1a;深度学习之父的传奇人生与杰出贡献 在人工智能领域&#xff0c;有一位科学家的名字如同星辰般闪耀&#xff0c;他就是Geoffrey Hinton。作为深度学习的奠基人之一&#xff0c;Hinton的生涯充满了创新、突破和对未知的不懈探索。他的贡献不仅重塑了…

SpringBoot数据访问复习

SpringBoot数据访问复习 数据访问准备 引入jdbc所需要的依赖 <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jdbc</artifactId></dependency> 原理分析 导入的启动器引入了两个…

【年度盛会征稿】第二届全国精准营养与生命健康创新大会(PNHIC 2024)

第二届全国精准营养与生命健康创新大会&#xff08;PNHIC 2024&#xff09; 【高层次专家齐聚交流&#xff0c;年度盛会&#xff01;】 近年来&#xff0c;人们对营养与健康愈加关注&#xff0c;精准营养学研究也成为一个快速发展的热点领域。“精准营养”研究借助多组学、可…

Qt程序设计-运行脚本文件.bat

Qt程序设计-运行脚本文件.bat 本文演示如何实现Qt运行脚本文件.bat 创建一个脚本文件 在D盘,新建一个test.txt文件,将下面的拷入,然后更改后缀名称为bat @echo off set filename=newfile.txt if not exist %filename% (echo This is a new file > %filename% ) else …

Android Studio开发配置(gradle配置)

文章目录 plugin:com.android.applicationgradle换源gradle下载AVD启动电脑蓝屏 刚安装android studio的话&#xff0c;如果直接创建项目&#xff0c;基本gradle编译不过去&#xff0c;会报错。 plugin:com.android.application 最开始我一直报错找不到插件&#xff0c;因为我…

MySQL进阶45讲【13】为什么表数据删掉一半,表文件大小不变?

1 前言 有些小伙伴在删数据库数据时&#xff0c;会产生一个疑问&#xff0c;我的数据库占用空间大&#xff0c;我把一个最大的表删掉了一半的数据&#xff0c;怎么表文件的大小还是没变&#xff1f; 那么这篇文章&#xff0c;就介绍一下数据库表的空间回收&#xff0c;看看如…

【链表】-Lc146-实现LRU(巧妙借助LinkedHashMap)

写在前面 最近想复习一下数据结构与算法相关的内容&#xff0c;找一些题来做一做。如有更好思路&#xff0c;欢迎指正。 目录 写在前面一、场景描述二、具体步骤1.环境说明2.代码 写在后面 一、场景描述 运用你所掌握的数据结构&#xff0c;设计和实现一个 LRU (Least Recently…

Linux Rootkit:内核 5.7+ 的新方法

Linux Rootkit&#xff1a;内核 5.7 的新方法 文章目录 [Linux Rootkit&#xff1a;内核 5.7 的新方法](https://xcellerator.github.io/posts/linux_rootkits_11/)这是怎么回事&#xff1f;ProcFS 更改Kallsyms 问题系统调用名称问题就这样…… 这是怎么回事&#xff1f; 早在…

如何把vue项目打包成桌面程序 electron-builder

引入 我们想要把我们写的vue项目,打包成桌面程序&#xff0c;我们需要使用electron-builder这个库 如何使用 首先添加打包工具 vue add electron-builder 选择最新版本 下载完毕 我们可以看到我们的package.json中多了几行 electron:build&#xff1a;打包我们的可执行程序 e…

vue实现二维数组表格渲染

在Vue中渲染二维数组表格可以采用嵌套的<template>和v-for指令。 写法一 <template> <table> <thead> <tr> <th v-for"(header, index) in headers" :key"index">{{ header }}</th> </tr> </t…

在 iOS 上安装自定企业级应用

了解如何安装您的组织创建的自定应用并为其建立信任。 本文适用于学校、企业或其他组织的系统管理员。 您的组织可以使用 Apple Developer Enterprise Program 创建和分发企业专用的 iOS 应用&#xff0c;以供内部使用。您必须先针对这些应用建立信任后&#xff0c;才能将其打…

服装品牌如何利用数字化工具提升商品管理效率

随着科技的快速发展&#xff0c;数字化工具在商品管理中的应用越来越广泛。数字化工具不仅可以提高商品管理的效率&#xff0c;还可以帮助企业更好地满足客户需求&#xff0c;提升市场竞争力。本文将探讨如何利用数字化工具提升商品管理效率。 一、建立数字化管理系统 数字化…

备战蓝桥杯---搜索(应用基础1)

话不多说&#xff0c;直接看题&#xff1a; 显然&#xff0c;我们直接用深搜&#xff0c;我们可以先把空位用结构体存&#xff0c;然后打表存小方块&#xff0c;再用数组存行列。 下面是AC代码&#xff1a; #include<bits/stdc.h> using namespace std; int a[12][12];…