Tensorflow Rust实战下篇[整合actix-web提供http服务]

SongpingWang 2020-11-13 11:00:31
Tensorflow 实战 rust 整合 下篇


上一篇我写的文章Tensorflow Rust实战上篇. 这一次我们看看使用tensorflow建立了什么,并通过http接口提供服务。随着Actix Web1.0版本发布,我认为用它构建一些东西将是一个很好的时机。

本文假设您对Futures及其运作方式有一定的了解。我将尽量用更简单的术语解释,但理解Futures生态系统将非常有效地帮助阅读本文。为此,我建议你从tokio开始。

有些人建议在深入Futures之前等待async/await和friends功能发布。我认为你现在应该亲自动手:异步编程总是很有挑战性。

您可以在actix-web分支上找到参考代码:https://github.com/cetra3/mtcnn/tree/actix-web

一、API定义

这里的API非常简单。我们想模仿我们在命令行上所做的事情:提交一张图片,返回结果是一张图片。为了使事情变得有趣,我们将提供一种方式:将边界框以JSON数组返回。

关于通过http协议提交二进制数据,我首先想到了几种选择:

  • 只需提交原始数据即可
  • 使用multipart/form-data
  • 序列化为JSON格式提交

我认为最简单是原始数据,所以让我们这样做! multipart/form-data可能ok,但是你必须处理多个图像的时候呢? JSON格式似乎有点浪费,因为您不可避免地必须使用base64或类似的方式转换二进制数据。

所以我们的API是这样的:

  • 提交POST请求作为原始文件提交
  • 运行会话,通过MTCNN算法以提取人脸
  • 将边界框作以JSON格式返回;或者命令行示例一样,将图像叠加以JPEG格式返回。

二、MTCNN的结构体(struct)

在我们上一篇博客中,我们只是简单地使用main函数来执行所有操作,但我们必须一些重构才能与actix一起使用。我们希望将MTCNN行为封装为结构,可以传递和转移。最终目标是在应用程序状态下使用它。

2.1结构体(struct)定义

让我们将结构包含我们想要的一切:

  • 图片
  • 会话
  • 一些多个请求中共用的Tensor框架的输入参数

首先,我们创建一个新文件mtcnn.rs并加上结构体定义。

use tensorflow::{
Graph, Session, Tensor};
pub struct Mtcnn {

graph: Graph,
session: Session,
min_size: Tensor<f32>,
thresholds: Tensor<f32>,
factor: Tensor<f32>
}

然后,现在我们只是用new方法填充初始化内容。由于其中一些值的初始化并非绝对可靠,我们将返回Result:

pub fn new() -> Result<Self, Box<dyn Error>> {

let model = include_bytes!("mtcnn.pb");
let mut graph = Graph::new();
graph.import_graph_def(&*model, &ImportGraphDefOptions::new())?;
let session = Session::new(&SessionOptions::new(), &graph)?;
let min_size = Tensor::new(&[]).with_values(&[40f32])?;
let thresholds = Tensor::new(&[3]).with_values(&[0.6f32, 0.7f32, 0.7f32])?;
let factor = Tensor::new(&[]).with_values(&[0.709f32])?;
Ok(Self {

graph,
session,
min_size,
thresholds,
factor
})
}

2.2Run方法

我将在这里开始加快节奏,所以如果你遇到困难或不确定发生了什么,请查看
Tensorflow Rust实战上篇,以解释这里发生的事情。

我们已经添加了所有需要跑一个会话的东西。让我们创建一个需要API做什么的方法:提交一张图片,响应一些边界框(框出人脸的位置):

pub fn run(&self, img: &DynamicImage) -> Result<Vec<BBoxes>, Status> {

...
}

再一次,我们响应了一个Result类型,因为在某些情况下run方法会失败。我们使用Status类型来表示响应错误的类型。

像我们先前的main方法,我们需要压平图片的输入:

let input = {

let mut flattened: Vec<f32> = Vec::new();
for (_x, _y, rgb) in img.pixels() {

flattened.push(rgb[2] as f32);
flattened.push(rgb[1] as f32);
flattened.push(rgb[0] as f32);
}
Tensor::new(&[img.height() as u64, img.width() as u64, 3])
.with_values(&flattened)?
};

然后我们将提供所有相关输入。这与我们之前的main方法相同,但我们只是从self中借用值,而不是为每次运行创建它们:

let mut args = SessionRunArgs::new();
args.add_feed(
&self.graph.operation_by_name_required("min_size")?,
0,
&self.min_size,
);
args.add_feed(
&self.graph.operation_by_name_required("thresholds")?,
0,
&self.thresholds,
);
args.add_feed(
&self.graph.operation_by_name_required("factor")?,
0,
&self.factor,
);
args.add_feed(&self.graph.operation_by_name_required("input")?, 0, &input);

接下来,我们抓住我们想要的输出:

let bbox = args.request_fetch(&self.graph.operation_by_name_required("box")?, 0);
let prob = args.request_fetch(&self.graph.operation_by_name_required("prob")?, 0);

2.3会话(running in session)

现在我们设置了所有参数,我们可以跑session了:

&self.session.run(&mut args)?;

噢哦!我们得到一个编译器错误:

error[E0596]: cannot borrow `self.session` as mutable, as it is behind a `&` reference
--> src/mtcnn.rs:68:10
|
36 | pub fn run(&self, img: &DynamicImage) -> Result<DynamicImage, Box<dyn Error>> {

| ----- help: consider changing this to be a mutable reference: `&mut self`
...
68 | &self.session.run(&mut args)?;
| ^^^^^^^^^^^^ `self` is a `&` reference, so the data it refers to cannot be borrowed as mutable

事实证明,Session::run()方法采用&mut self。我们可以做些什么来解决这个问题:

  • 使我们的run方法拿到 &mut self
  • 做一些棘手的内部可变性
  • 提交issue给tensorflow-rust crate,看看Session是否真的需要&mut self

我们选择了第三种方式!
更新你的 Cargo.toml,指定git而不是cargo里的crate版本号:

tensorflow = {
 git = "https://github.com/tensorflow/rust"}

2.4获取边界框(人脸位置)

自从我们的main方法以来,这一点都没有改变。我们获取边界框,将它们放入我们的BBox结构中:

//Our bounding box extents
let bbox_res: Tensor<f32> = args.fetch(bbox)?;
//Our facial probability
let prob_res: Tensor<f32> = args.fetch(prob)?;
//Let's store the results as a Vec<BBox>
let mut bboxes = Vec::new();
let mut i = 0;
let mut j = 0;
//While we have responses, iterate through
while i < bbox_res.len() {

//Add in the 4 floats from the `bbox_res` array.
//Notice the y1, x1, etc.. is ordered differently to our struct definition.
bboxes.push(BBox {

y1: bbox_res[i],
x1: bbox_res[i + 1],
y2: bbox_res[i + 2],
x2: bbox_res[i + 3],
prob: prob_res[j], // Add in the facial probability
});
//Step `i` ahead by 4.
i += 4;
//Step `i` ahead by 1.
j += 1;
}
debug!("BBox Length: {}, BBoxes:{:#?}", bboxes.len(), bboxes);
Ok(bboxes)

到此,我们的run方法完成了。

2.5BBox结构的JSON格式

我们打算响应代表BBox结构体的JSON,所以添加serde_derive中的Serialize(序列化相关模块):

use serde_derive::Serialize;
#[derive(Copy, Clone, Debug, Serialize)]
pub struct BBox {

pub x1: f32,
pub y1: f32,
pub x2: f32,
pub y2: f32,
pub prob: f32,
}

2.6绘制输出的图片

我们将要添加一个方法,输入一张图片和一个边界框数组,响应输出的图片:

pub fn overlay(img: &DynamicImage, bboxes: &Vec<BBox>) -> DynamicImage

这里也没有多大的变化,只是响应了一张图片而不是保存一个文件:

//Let's clone the input image
let mut output_image = img.clone();
//Iterate through all bounding boxes
for bbox in bboxes {

//Create a `Rect` from the bounding box.
let rect = Rect::at(bbox.x1 as i32, bbox.y1 as i32)
.of_size((bbox.x2 - bbox.x1) as u32, (bbox.y2 - bbox.y1) as u32);
//Draw a green line around the bounding box
draw_hollow_rect_mut(&mut output_image, rect, LINE_COLOUR);
}
output_image

好的,我们已经完成了我们的Mtcnn结构体和方法!我们可以进一步吗?是的,绝对可以!但就目前而言,我认为这就是我们所需要的。我们已经封装了行为并创建了一个很好用的几个函数。

三、新main方法

我们不再将它用作命令行程序,而是用作自托管的Web应用程序。因为我们不再有输入和输出文件,所以我们需要更改应用程序所需的参数。
我认为我们最初应该拿到的唯一参数是监听地址,即使这样我们也应该使用合理的默认值。所以让我们通过structopt的帮助来制作这个非常小的demo:

#[derive(StructOpt)]
struct Opt {

#[structopt(
short = "l",
long = "listen",
help = "Listen Address",
default_value = "127.0.0.1:8000"
)]
listen: String,
}

3.1日志框架

Actix Web使用log crate来显示errors和debug message。
让我们使用log替代println!。我喜欢使用pretty_env_logger,因为它将不同的级别打印为不同的颜色,并且我们可以使用有用的时间戳。
pretty_env_logger仍然使用环境变量。那就让我们设置环境变量RUST_LOG,然后启动我们的logger。

//Set the `RUST_LOG` var if none is provided
if env::var("RUST_LOG").is_err() {

env::set_var("RUST_LOG", "mtcnn=DEBUG,actix_web=DEBUG");
}
//Create a timestamped logger
pretty_env_logger::init_timed();

这为我们的app和actix web设置了DEBUG级别日志,但允许我们通过环境变量更改日志级别。

四、Actix and 状态(State)

我们需要将一些状态传递给actix使用:Mtcnn结构体和run方法。你可以通过多种方式传递状态提供actix,但最简单的方法应该是App::data方法。当我们正在进入一个多线程世界时,我们将不得不考虑Send/Sync。

好的,那么我们如何在线程之间分享数据呢?好吧,作为第一步,我会看看std::sync。由于我们知道mtcnn的run函数不需要可变引用,只需要不可变self引用,我们可以将它包装在Arc中。如果我们不得不使用可变引用,那么可能也需要Mutex,但是如果我们使用tensorflow-rust的主分支,可以避免这种情况。

那么让我们创建一个Arc:

let mtcnn = Arc::new(Mtcnn::new()?);

现在可以实例化服务:

HttpServer::new(move || {

App::new()
//Add in our mtcnn struct, we clone the reference for each worker thread
.data(mtcnn.clone())
//Add in a logger to see the requests coming through
.wrap(middleware::Logger::default())
// Add in some routes here
.service(
...
)
})
.bind(&opt.listen)? // Use the listener from the command arguments
.run()

总结一下我们已完成的事情:

  • 首先构建一个HttpServer
  • 这需要一个返回App的闭包。此App是为每个http服务器运行的线程实例化的
  • 使用data方法添加Arc,并为每个线程侦听器clone它
  • 添加了一个日志框架
  • 用service方法设置了一些route
  • bind到一个监听地址并运行

五、处理请求

Actix Web是一个异步框架,使用tokio。我们的function是同步,需要一些时间才能处理完成。换句话说,我们的请求是阻塞的。我们可以混合使用同步和异步,当然,处理起来有点麻烦。

5.1方法定义与提取器(Extractors)

Actix 1.0大量使用Extractors,Extractors为方法定义提供完全不同形式。您指定希望接口接收的内容,actix将为您进行串联起来。请注意:这确实意味着在运行之前不能发现错误。我在web::Data参数中使用了错误的类型签名时的一个示例。

那么我们需要从我们的请求中提取什么?request body的bytes和mtcnn:

fn handle_request(
stream: web::Payload,
mtcnn: web::Data<Arc<Mtcnn>>,
) -> impl Future<Item = HttpResponse, Error = ActixError> {

...
}

我们将在mtcnn中使用这种类型(web::Data<Arc>),因此让我们为它创建一个类型别名:

type WebMtcnn = web::Data<Arc<Mtcnn>>;

六、从Payload中获取图像

注:这里的payload指的是http请求中header后面的部分。

我们需要一种从payload中检索图像并返回Future的方法。 web::Payload结构体实现了Stream将Item设置为Bytes

从流中获得单个字节是没有意义的,我们想要获得整个批次并对图像进行解码!因此,让我们将Stream转换为Future,并将我们将要获得的所有单个字节合并到一个大的字节桶中。听起来很复杂,但幸运的是Stream有一个方法:concat2

concat2是一个非常强大的组合器,它允许我们将单个Stream轮询的结果加入到一个集合中,如果该项实现了Extend(以及一些其它的trait),Bytes就会支持扩展。
因此就像这样:

stream.concat2().and_then(....)

6.1图像解码 和 web::block

我们需要解决的第二件事是:如果我们要解码出图像,那么会阻止线程直到解码完成。如果它是一个巨大的图像,它可能需要几毫秒!因此,我们希望确保在发生这种情况时我们不会发生阻塞。幸运的是,actix web有一种方法可以将阻塞代码包装为future:

stream.concat2().and_then(move |bytes| {

web::block(move || {

image::load_from_memory(&bytes)
})
})

我们采用stream,将其转换为 future 和 bytes,然后使用 web::block 将字节解码为后台线程中的图像并返回结果。load_from_memory 函数返回了一个Result,这意味着我们可以将其用作返回类型。

6.2平衡错误类型

因此,我们的 Item 被转换为 Bytes 再到 DynamicImage,但我们还没有处理错误类型,无法编译通过。我们的错误类型应该是什么?让我们使用 actix_web::Error 作为 ActixError:

use actix_web::{
Error as ActixError}
fn get_image(stream: web::Payload) -> impl Future<Item = DynamicImage, Error = ActixError> {

stream.concat2().and_then(move |bytes| {

web::block(move || {

image::load_from_memory(&bytes)
})
})
}

好吧,当我们尝试编译时,出现了错误:

error[E0271]: type mismatch resolving `<impl futures::future::Future as futures::future::IntoFuture>::Error == actix_http::error::PayloadError`
--> src/main.rs:67:22
|
67 | stream.concat2().and_then(move |bytes| {

| ^^^^^^^^ expected enum `actix_threadpool::BlockingError`, found enum `actix_http::error::PayloadError`
|
= note: expected type `actix_threadpool::BlockingError<image::image::ImageError>`
found type `actix_http::error::PayloadError`
还有一些未列出的内容...

当您组合 stream 时,将它们映射为 future,以及尝试从这些组合器获得一些输出时,您实际上处理的是Item类型 和 Error类型 。
处理多种类型的响应结果会使代码变得丑陋,这里不像 Result类型可以使用问号(?)自动调整到正确的错误。当 ops::Try 和 async/await语法变得稳定的时候,事情可能变得简单,但是现在,我们必须想办法处理这些错误类型。

我们可以使用 from_err() 方法。作用跟问号(?)基本相同,区别是from_err作用于future。我们有两个正在处理的future:来自stream的字节数组 和 来自阻塞闭包的图像。我们有3种错误类型:the Payload error, the Image load from memory error, and the blocking error:

fn get_image(stream: web::Payload)
-> impl Future<Item = DynamicImage, Error = ActixError> {

stream.concat2().from_err().and_then(move |bytes| {

web::block(move || {

image::load_from_memory(&bytes)
}).from_err()
})
}

七、从图像中获得边界框

最重要的是,我们需要run起来:

mtcnn.run(&img)

但是我们想要在一个线程池里跑起来:

web::block(|| mtcnn.run(&img))

让我们看看函数声明。至少我们需要图像和mtcnn结构体。然后我们想要返回BBox的Vec。我们保持错误类型相同,因此我们将使用ActixError类型。

函数声明如下:

fn get_bboxes(img: DynamicImage, mtcnn: WebMtcnn)
-> impl Future<Item = Vec<BBox>, Error = ActixError>

我们需要在 web::block 上使用 from_err() 来转换错误类型,使用move来将图像提供给闭包:

fn get_bboxes(img: DynamicImage, mtcnn: WebMtcnn) -> impl Future<Item = Vec<BBox>, Error = ActixError> {

web::block(move || mtcnn.run(&img)).from_err()
}

但还是会发生了编译错误:

error[E0277]: `*mut tensorflow_sys::TF_Status` cannot be sent between threads safely
--> src/main.rs:75:5
|
75 | web::block(move || mtcnn.run(&img)).from_err()
| ^^^^^^^^^^ `*mut tensorflow_sys::TF_Status` cannot be sent between threads safely
|
= help: within `tensorflow::Status`, the trait `std::marker::Send` is not implemented for `*mut tensorflow_sys::TF_Status`
= note: required because it appears within the type `tensorflow::Status`
= note: required by `actix_web::web::block`

tensorflow::Status,它是错误类型,不能在线程之间发送。

快捷方式是将error转换成String:

fn get_bboxes(img: DynamicImage, mtcnn: WebMtcnn) -> impl Future<Item = Vec<BBox>, Error = ActixError> {

web::block(move || mtcnn.run(&img).map_err(|e| e.to_string())).from_err()
}

因为String实现了Send,因此允许跨越线程间发送Result。

八、返回JSON对象 BBoxes

好的,我们有2个函数,一个用于从请求中获取图像,另一个用于获取边界框。我们要返回回json HttpResponse:

fn return_bboxes(
stream: web::Payload,
mtcnn: WebMtcnn,
) -> impl Future<Item = HttpResponse, Error = ActixError> {

// Get the image from the input stream
get_image(stream)
// Get the bounding boxes from the image
.and_then(move |img| get_bboxes(img, mtcnn))
// Map the bounding boxes to a json HttpResponse
.map(|bboxes| HttpResponse::Ok().json(bboxes))
}

接着,在App里添接口定义:

HttpServer::new(move || {

App::new()
.data(mtcnn.clone())
.wrap(middleware::Logger::default())
// our new API service
.service(web::resource("/api/v1/bboxes").to_async(return_bboxes))
})
.bind(&opt.listen)?
.run()

run起来,用 curl 来提交一个请求:

$ curl --data-binary @rustfest.jpg http://localhost:8000/api/v1/bboxes
[{
"x1":471.4591,"y1":287.59888,"x2":495.3053,"y2":317.25327,"prob":0.9999908}....

使用 jmespath 来获取120张脸:

$ curl -s --data-binary @rustfest.jpg http://localhost:8000/api/v1/bboxes | jp "length(@)"
120

九、返回叠加图像

我们想要的另一个API调用是返回一个覆盖了边界框的图像。 这不是一个很大的延伸,但在图像上绘制框肯定是一个阻塞动作,所以我们将其发送到线程池中运行。
让我们包装叠加函数,将其转换为future:

fn get_overlay(img: DynamicImage, bboxes: Vec<BBox>)
-> impl Future<Item = Vec<u8>, Error = ActixError> {

web::block(move || {

let output_img = overlay(&img, &bboxes);
...
}).from_err()
}

我们想要返回一个u8字节的Vec,这样我们就可以在返回体中使用它。 所以我们需要分配缓冲区并以JPEG格式写入:

let mut buffer = vec![];
output_img.write_to(&mut buffer, JPEG)?; // write out our buffer
Ok(buffer)

将目前为止的函数尝试编译一次:

fn get_overlay(img: DynamicImage, bboxes: Vec<BBox>)
-> impl Future<Item = Vec<u8>, Error = ActixError> {

web::block(move || {

let output_img = overlay(&img, &bboxes);
let mut buffer = Vec::new();
output_img.write_to(&mut buffer, JPEG)?; // write out our buffer
Ok(buffer)
}).from_err()
}

还差一点, 我们缺少一个类型注解:

error[E0282]: type annotations needed
--> src/main.rs:82:5
|
82 | web::block(move || {

| ^^^^^^^^^^ cannot infer type for `E`

为什么这里是类型问题?关联到这一行:

Ok(buffer) // What's the `Error` type here?

目前,唯一的错误类型来自write_to方法,即ImageError。 但是这一行没有错误类型,可能是任何东西。
我想到三种方法处理这个问题:

方法一:在web::block中声明错误

web::block::<_,_,ImageError>

这看上去有点凌乱,但可以编译通过。

方法二:使用 as 声明 Result 类型:

Ok(buffer) as Result<_, ImageError>

方法三:使用map在成功时返回一个buffer:

output_img.write_to(&mut buffer, JPEG).map(|_| buffer)

我认为为了可读性,#2可能是最简单的。 web::block函数需要3个类型的参数,这些参数在第一次阅读代码时可能会引起混淆。 #3也不错,但我觉得它看起来有点奇怪。

最终我的选择:

fn get_overlay(img: DynamicImage, bboxes: Vec<BBox>)
-> impl Future<Item = Vec<u8>, Error = ActixError> {

web::block(move || {

let output_img = overlay(&img, &bboxes);
let mut buffer = Vec::new();
output_img.write_to(&mut buffer, JPEG)?;
// Type annotations required for the `web::block`
Ok(buffer) as Result<_, ImageError>
}).from_err()
}

9.1API调用

好的,我们拥有了一些返回future的方法,future返回边界框和叠加图像。 让我们将它们拼接在一起并返回一个HttpResponse:

fn return_overlay(
stream: web::Payload,
mtcnn: WebMtcnn,
) -> impl Future<Item = HttpResponse, Error = ActixError> {

//... magic happens here
}

第一步是从字节流中获取图像:

get_image(stream)

然后我们想要获取边界框:

get_image(stream).and_then(move |img| {

get_bboxes(img, mtcnn)
})

9.2如何使用image对象

现在我们想要获得叠加图像。 我们有一个问题,如何使用image? get_bboxes返回future的图像,然后计算image上的人脸返回一个边界框数组。 这里有几个选择。 当我们将image传递给get_bboxes时,我们可以克隆image,但这会发生内存拷贝。 我们可以等待 Pin 和 async/await 语法完成,然后可能更容易处理它。
或者我们可以调整我们的get_bboxes方法:

fn get_bboxes(
img: DynamicImage,
mtcnn: WebMtcnn,
) -> impl Future<Item = (DynamicImage, Vec<BBox>), Error = ActixError> {

web::block(move || {

mtcnn
.run(&img)
.map_err(|e| e.to_string())
//Return both the image and the bounding boxes
.map(|bboxes| (img, bboxes))
})
.from_err()
}

记录把 return_bboxes 方法也修改了:

fn return_bboxes(
stream: web::Payload,
mtcnn: WebMtcnn,
) -> impl Future<Item = HttpResponse, Error = ActixError> {

get_image(stream)
.and_then(move |img| get_bboxes(img, mtcnn))
.map(|(_img, bboxes)| HttpResponse::Ok().json(bboxes))
}

9.3 获取叠加层

如果rust可以将元组变成命令参数,那就太好了。 不幸的是不适合我们,所以我们需要创建一个闭包:

//Create our image overlay
.and_then(|(img, bbox)| get_overlay(img, bbox))
.map(|buffer| {

// Return a `HttpResponse` here
})

9.4 创建响应

我们的 HttpResponse 需要将 buffer 包装到一个body:

HttpResponse::with_body(StatusCode::OK, buffer.into())

将 Content-Type设置为jpeg:

let mut response = HttpResponse::with_body(StatusCode::OK, buffer.into());
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("image/jpeg"));

获取叠加层的最终实现:

fn return_overlay(
stream: web::Payload,
mtcnn: WebMtcnn,
) -> impl Future<Item = HttpResponse, Error = ActixError> {

get_image(stream)
.and_then(move |img| {

get_bboxes(img, mtcnn)
})
.and_then(|(img, bbox) | get_overlay(img, bbox))
.map(|buffer| {

let mut response = HttpResponse::with_body(StatusCode::OK, buffer.into());
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("image/jpeg"));
response
})
}

在App注册此接口:

HttpServer::new(move || {

App::new()
.data(mtcnn.clone()) //Add in our data handler
//Add in a logger to see the requets coming through
.wrap(middleware::Logger::default())
//JSON bounding boxes
.service(web::resource("/api/v1/bboxes").to_async(return_bboxes))
//Image overlay
.service(web::resource("/api/v1/overlay").to_async(return_overlay))
}

run一下:

$ curl --data-binary @rustfest.jpg http://localhost:8000/api/v1/bboxes > output.jpg

结果:
测试结果

十、总结

我们逐步将CLI应用程序转换为HTTP服务,并尝试了异步编程。如您所见,actix web是一个非常通用的Web框架。 我对它的兴趣来自于拥有构建Web应用程序所需的所有功能:多组件,线程池,高效率。虽然actix写异步还不是很优雅,但未来可期,因为我认为很多开发人员都在努力解决这个问题。

如果您正在寻找更多的actix示例,这个示例仓库是您最好的选择:https://github.com/actix/exam…

我期待看到社区未来的建设!

​ 原文作者:誉儿
​ 原文地址: https://segmentfault.com/a/1190000019616388
​ 本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。

版权声明
本文为[SongpingWang]所创,转载请带上原文链接,感谢
https://wangsp.blog.csdn.net/article/details/109580315

  1. [front end -- JavaScript] knowledge point (IV) -- memory leakage in the project (I)
  2. This mechanism in JS
  3. Vue 3.0 source code learning 1 --- rendering process of components
  4. Learning the realization of canvas and simple drawing
  5. gin里获取http请求过来的参数
  6. vue3的新特性
  7. Get the parameters from HTTP request in gin
  8. New features of vue3
  9. vue-cli 引入腾讯地图(最新 api,rocketmq原理面试
  10. Vue 学习笔记(3,免费Java高级工程师学习资源
  11. Vue 学习笔记(2,Java编程视频教程
  12. Vue cli introduces Tencent maps (the latest API, rocketmq)
  13. Vue learning notes (3, free Java senior engineer learning resources)
  14. Vue learning notes (2, Java programming video tutorial)
  15. 【Vue】—props属性
  16. 【Vue】—创建组件
  17. [Vue] - props attribute
  18. [Vue] - create component
  19. 浅谈vue响应式原理及发布订阅模式和观察者模式
  20. On Vue responsive principle, publish subscribe mode and observer mode
  21. 浅谈vue响应式原理及发布订阅模式和观察者模式
  22. On Vue responsive principle, publish subscribe mode and observer mode
  23. Xiaobai can understand it. It only takes 4 steps to solve the problem of Vue keep alive cache component
  24. Publish, subscribe and observer of design patterns
  25. Summary of common content added in ES6 + (II)
  26. No.8 Vue element admin learning (III) vuex learning and login method analysis
  27. Write a mini webpack project construction tool
  28. Shopping cart (front-end static page preparation)
  29. Introduction to the fluent platform
  30. Webpack5 cache
  31. The difference between drop-down box select option and datalist
  32. CSS review (III)
  33. Node.js学习笔记【七】
  34. Node.js learning notes [VII]
  35. Vue Router根据后台数据加载不同的组件(思考-&gt;实现-&gt;不止于实现)
  36. Vue router loads different components according to background data (thinking - & gt; Implementation - & gt; (more than implementation)
  37. 【JQuery框架,Java编程教程视频下载
  38. [jQuery framework, Java programming tutorial video download
  39. Vue Router根据后台数据加载不同的组件(思考-&gt;实现-&gt;不止于实现)
  40. Vue router loads different components according to background data (thinking - & gt; Implementation - & gt; (more than implementation)
  41. 【Vue,阿里P8大佬亲自教你
  42. 【Vue基础知识总结 5,字节跳动算法工程师面试经验
  43. [Vue, Ali P8 teaches you personally
  44. [Vue basic knowledge summary 5. Interview experience of byte beating Algorithm Engineer
  45. 【问题记录】- 谷歌浏览器 Html生成PDF
  46. [problem record] - PDF generated by Google browser HTML
  47. 【问题记录】- 谷歌浏览器 Html生成PDF
  48. [problem record] - PDF generated by Google browser HTML
  49. 【JavaScript】查漏补缺 —数组中reduce()方法
  50. [JavaScript] leak checking and defect filling - reduce() method in array
  51. 【重识 HTML (3),350道Java面试真题分享
  52. 【重识 HTML (2),Java并发编程必会的多线程你竟然还不会
  53. 【重识 HTML (1),二本Java小菜鸟4面字节跳动被秒成渣渣
  54. [re recognize HTML (3) and share 350 real Java interview questions
  55. [re recognize HTML (2). Multithreading is a must for Java Concurrent Programming. How dare you not
  56. [re recognize HTML (1), two Java rookies' 4-sided bytes beat and become slag in seconds
  57. 【重识 HTML ,nginx面试题阿里
  58. 【重识 HTML (4),ELK原来这么简单
  59. [re recognize HTML, nginx interview questions]
  60. [re recognize HTML (4). Elk is so simple