thunbor 图片服务器的 Rust 实现,可以提供一系列的图片转换服务。
我们需要使用一种简洁且可扩展的方式,来描述对图片进行的一系列 有序操作。这些操作的数据结构如下:
// 解析出来的图片处理的参数
struct ImageSpec {
specs: Vec<Spec>
}
// 每个参数的是我们支持的某种处理方式
enum Spec {
Resize(Resize),
Crop(Crop),
...
}
// 处理图片的 resize
struct Resize {
width: u32,
height: u32
}有了数据结构,那我们如何设计一个任何客户端可以使用的、体现在 URL 上的接口,使其能够解析成我们设计的数据结构呢?
将一系列 Spec 放在一个 Vec 中,使用 protobuf 将其序列化为字节流,再使用 base64 转码放在 URL 中,得到可扩展的图片处理参数
image spec 的 protobuf 消息定义:
message ImageSpec { repeated Spec specs = 1; }
message Spec {
oneof data {
Resize resize = 1;
Crop crop = 2;
...
}
}
...得到的 url 类似:
http://localhost:3000/image/CgoKCAjYBBCgBiADCgY6BAgUEBQKBDICCAM/<encoded origin url>可优化的一点:为原始图片的获取过程添加一个 LRU (Least Recently Used)缓存。
在项目根目录创建 abi.proto 文件,内容如下:
syntax = "proto3";
package abi;
// 一个 ImageSpec 是一组图片处理方式的集合,服务器按照 spec 的顺序处理
message ImageSpec { repeated Spec specs = 1; }
// 一个 spec 可以是以下处理方式之一
message Spec {
oneof data {
Resize resize = 1;
Crop crop = 2;
FlipHorizontal fliph = 3;
FlipVertical flipv = 4;
Contrast contrast = 5;
Filter filter = 6;
Watermark watermark = 7;
}
}
// 图片改变大小
message Resize {
uint32 width = 1;
uint32 height = 2;
enum Mode {
MODE_UNSPECIFIED = 0;
MODE_NORMAL = 1;
MODE_SEAM_CARVE = 2;
}
Mode mode = 3;
enum SampleFilter {
SAMPLE_FILTER_UNSPECIFIED = 0;
SAMPLE_FILTER_NEAREST = 1;
SAMPLE_FILTER_TRIANGLE = 2;
SAMPLE_FILTER_CATMULL_ROM = 3;
SAMPLE_FILTER_GAUSSIAN = 4;
SAMPLE_FILTER_LANCZOS3 = 5;
}
SampleFilter filter = 4;
}
// 图片截取
message Crop {
uint32 x = 1;
uint32 y = 2;
uint32 width = 3;
uint32 height = 4;
}
// 水平翻转
message FlipHorizontal {}
// 垂直翻转
message FlipVertical {}
// 对比度调整
message Contrast { float contrast = 1; }
// 图片滤镜
message Filter {
enum Filter {
FILTER_UNSPECIFIED = 0;
FILTER_OCEANIC = 1;
FILTER_ISLANDS = 2;
FILTER_MARINE = 3;
// more: https://docs.rs/photon-rs/latest/photon_rs/filters/fn.filter.html
}
Filter filter = 1;
}
// 图片水印
message Watermark {
uint32 x = 1;
uint32 y = 2;
}编译 protobuf 文件需要引入两个 crate,在 Cargo.toml 里加入(或使用 cargo add xxx)
[dependencies]
prost = "0.14"
[build-dependencies]
anyhow = "1"
prost-build = "0.14"同时在根目录创建一个 build.rs 文件,这里引入了 anyhow 这个 crate(放在 [build-dependencies] 下)
fn main() -> anyhow::Result<()> {
let out_dir = "src/pb";
println!("cargo::rerun-if-changed=abi.proto");
std::fs::create_dir_all(out_dir)?;
prost_build::Config::new()
.out_dir(out_dir)
.compile_protos(&["abi.proto"], &["."])?;
Ok(())
}编译后可以在 src/pb 目录里找到 abi.rs 文件,文件名与 proto 文件里定义的 package 名一致。
接下来在 src 下的 pb 目录同级创建 pb.rs ,引用 abi.rs ,并为 ImageSpec 提供转换支持或快速创建
// src/pb.rs
use base64::prelude::*;
mod abi;
pub use abi::*;
use prost::Message;
impl ImageSpec {
pub fn from_specs(specs: Vec<Spec>) -> Self {
Self { specs }
}
}
// 让 ImageSpec 可以生成一个字符串
impl From<&ImageSpec> for String {
fn from(image_spec: &ImageSpec) -> Self {
let data = image_spec.encode_to_vec();
BASE64_URL_SAFE_NO_PAD.encode(data)
}
}
// 让 ImageSpec 可以通过一个字符串创建
impl TryFrom<&str> for ImageSpec {
type Error = anyhow::Error;
fn try_from(str: &str) -> Result<Self, Self::Error> {
let data = BASE64_URL_SAFE_NO_PAD.decode(str)?;
let image_spec = Self::decode(&data[..])?;
Ok(image_spec)
}
}
impl Spec {
pub fn new_resize_seam_carve(width: u32, height: u32) -> Self {
Self {
data: Some(spec::Data::Resize(Resize {
width,
height,
mode: resize::Mode::SeamCarve as i32,
filter: resize::SampleFilter::Unspecified as i32,
})),
}
}
pub fn new_resize(width: u32, height: u32, filter: resize::SampleFilter) -> Self {
Self {
data: Some(spec::Data::Resize(Resize {
width,
height,
mode: resize::Mode::Normal as i32,
filter: filter as i32,
})),
}
}
pub fn new_filter(filter: filter::Filter) -> Self {
Self {
data: Some(spec::Data::Filter(Filter {
filter: filter as i32,
})),
}
}
pub fn new_watermark(x: u32, y: u32) -> Self {
Self {
data: Some(spec::Data::Watermark(Watermark { x, y })),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encoded_spec_should_be_decoded() {
let spec1 = Spec::new_resize(300, 300, resize::SampleFilter::Gaussian);
let spec2 = Spec::new_filter(filter::Filter::Oceanic);
let spec3 = Spec::new_watermark(100, 100);
let image_spec = ImageSpec::from_specs(vec![spec1, spec2, spec3]);
let s: String = (&image_spec).into();
assert_eq!(image_spec, s.as_str().try_into().unwrap());
}
}这里需要引入 photon-rs 提供图片效果和 base64 支持 base64 编解码,还需要 anyhow 快速处理错误。
引入 axum 作为服务器,serde 将参数反序列化,tokio 提供异步支持,flexi_logger 和 log 用于输出日志,percent-encoding 提供 URL 编解码。
[dependencies]
anyhow = "1"
axum = "0.8"
base64 = "0.22"
flexi_logger = "0.31"
log = "0.4"
percent-encoding = "2" # URL编码/解码
photon-rs = "0.3" # 图片效果
prost = "0.14"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
[build-dependencies]
anyhow = "1"
prost-build = "0.14"要使用 tokio 提供的异步功能,main 函数需要改为:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
...
}添加 #[tokio::main] 标注,并定义为 async 函数,并使用 anyhow::Result<()> 统一处理错误,也可以使用 Result<(), dyn Box<std::error::Error>>。
main 函数中首先初始化日志,这里使用了 flexi_logger 配合官方的 log,也可以使用其它日志库。
接下来构建路由,路由的路径为:"/image/{spec}/{url}",并使用 generate_handler 处理 get 请求。
最后我们创建 TcpListener 绑定 localhost,监听 3000 端口,启动服务器。
创建一个参数 Params 类型,并派生宏 Deserialize,axum 会自动解析
最终 main.rs 代码如下:
use std::net::SocketAddr;
use axum::{Router, extract::Path, http::StatusCode, routing};
use flexi_logger::Logger;
use log::{debug, info};
use serde::Deserialize;
use tokio::net::TcpListener;
#[derive(Deserialize)]
struct Params {
spec: String,
url: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 初始化日志
Logger::try_with_env_or_str("debug")?.start()?;
// 构建路由
let router = Router::new().route("/image/{spec}/{url}", routing::get(generate_handler));
// 启动 Web 服务器
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
let listener = TcpListener::bind(addr).await?;
info!("Listening on http://{}", addr);
// 处理服务器关机
let shutdown_signal = async {
tokio::signal::ctrl_c().await.unwrap();
info!("Server shutting down...");
};
axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal)
.await?;
Ok(())
}
async fn generate_handler(Path(Params { spec, url }): Path<Params>) -> Result<String, StatusCode> {
debug!("{}", spec);
debug!("{}", url);
let url = percent_encoding::percent_decode_str(&url).decode_utf8_lossy();
let spec: thunbor::ImageSpec = spec
.as_str()
.try_into()
.map_err(|_| StatusCode::BAD_REQUEST)?;
Ok(format!("url:{}\nspec:{:#?}", url, spec))
}generate_handler 中目前我们只是将参数解析出来。
另外写了一个 example 用于测试,快速验证编解码的正确性,获得编码后的图片 url 和 specs,使用的图片 url 链接为:https://images.pexels.com/photos/2470905/pexels-photo-2470905.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260
// examples/image_spce.rs
use thunbor::*;
fn main() -> anyhow::Result<()> {
let image_spec = ImageSpec::from_specs(vec![
Spec::new_resize(600, 800, resize::SampleFilter::CatmullRom),
Spec::new_filter(filter::Filter::Marine),
Spec::new_watermark(20, 20),
]);
let url = "https://images.pexels.com/photos/2470905/pexels-photo-2470905.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260";
let encoded_specs_str: String = (&image_spec).into();
let encoded_url =
percent_encoding::utf8_percent_encode(url, percent_encoding::NON_ALPHANUMERIC).to_string();
println!("encoded specs:\n{}", encoded_specs_str);
println!("encoded url:\n{}\n", encoded_url);
let decoded_specs: ImageSpec = encoded_specs_str.as_str().try_into()?;
let decoded_url = percent_encoding::percent_decode_str(&encoded_url).decode_utf8_lossy();
// println!("decoded specs:\n{:#?}", decoded_specs);
// println!("decoded url:\n{}", decoded_url);
assert_eq!(decoded_specs, image_spec);
assert_eq!(decoded_url, url);
Ok(())
}输出结果如下:
encoded specs:
CgwKCgjYBBCgBhgBIAMKBDICCAMKBjoECBQQFA
encoded url:
https%3A%2F%2Fimages%2Epexels%2Ecom%2Fphotos%2F2470905%2Fpexels%2Dphoto%2D2470905%2Ejpeg%3Fauto%3Dcompress%26cs%3Dtinysrgb%26dpr%3D2%26h%3D750%26w%3D1260启动服务器,使用 example 中的数据作为 spec 和 url,使用 httpie 工具验证功能:
httpie get "http://localhost:3000/image/CgwKCgjYBBCgBhgBIAMKBDICCAMKBjoECBQQFA/https%3A%2F%2Fimages%2Epexels%2Ecom%2Fphotos%2F2470905%2Fpexels%2Dphoto%2D2470905%2Ejpeg%3Fauto%3Dcompress%26cs%3Dtinysrgb%26dpr%3D2%26h%3D750%26w%3D1260"应该能正常返回解析后的 ImageSpec ,并与我们 example 中保持一致。
需要引入 LRU cache(使用 lru crate) 来缓存源图,axum 中我们在创建路由后使用 layer 添加全局状态,这个全局状态目前就是 LRU cache,在内存中缓存网络请求获得的源图。
use lru::LruCache;
// 添加 `bytes` crate
type Cache = Arc<Mutex<LruCache<u64, bytes::Bytes>>>;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
//...
let cache: Cache = Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(1024).unwrap())));
// 构建路由
let router = Router::new()
.route("/image/{spec}/{url}", routing::get(generate_handler))
.layer(Extension(cache));
//...
}添加一个 retrieve_image 函数,对于图片的网络请求,先对 URL 做个哈希,在 LRU 缓存中查找,找不到才用 reqwest 发送请求。
async fn retrieve_image(url: &str, cache: Cache) -> anyhow::Result<bytes::Bytes> {
let mut hasher = DefaultHasher::new();
url.hash(&mut hasher);
let key = hasher.finish();
let guard = &mut cache.lock().await;
let data = match guard.get(&key) {
Some(v) => {
info!("Match cache: {}", key);
v.to_owned()
}
None => {
info!("Retrieve url");
// 添加 `reqwest` crate
let response = reqwest::get(url).await?;
let data = response.bytes().await?;
guard.put(key, data.clone());
data
}
};
Ok(data)
}修改 generate_handler 函数,注意 Extension(cache): Extension<Cache> 参数的写法。
async fn generate_handler(
Path(Params { spec, url }): Path<Params>,
Extension(cache): Extension<Cache>,
) -> Result<(HeaderMap, Vec<u8>), StatusCode> {
let url: &str = &percent_encoding::percent_decode_str(&url).decode_utf8_lossy();
let _spec: thunbor::ImageSpec = spec
.as_str()
.try_into()
.map_err(|_| StatusCode::BAD_REQUEST)?;
let data = retrieve_image(url, cache)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
// TODO: 处理图片
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("image/jpeg"));
Ok((headers, data.to_vec()))
}再来写一个辅助测试用的函数,运行后服务器打印一个 test url,点击在浏览器打开可以得到一张和源图一模一样的图片:
fn print_test_url(url: &str) {
let image_spec = thunbor::ImageSpec::from_specs(vec![
thunbor::Spec::new_resize(600, 800, thunbor::resize::SampleFilter::CatmullRom),
thunbor::Spec::new_filter(thunbor::filter::Filter::Marine),
thunbor::Spec::new_watermark(20, 20),
]);
let specs_str: String = (&image_spec).into();
let test_image = percent_encode(url.as_bytes(), percent_encoding::NON_ALPHANUMERIC).to_string();
println!(
"test url: http://localhost:3000/image/{}/{}",
specs_str, test_image
);
}同时,目前的日志输出太过精简,使用了自定义的日志格式,显示时间戳,并使用 colored crate 提供了颜色支持:
fn init_logger() -> anyhow::Result<()> {
Logger::try_with_env_or_str("info")?
.format(|w, now, record| {
let level = match record.level() {
log::Level::Error => record.level().to_string().red(),
log::Level::Warn => record.level().to_string().yellow(),
log::Level::Info => record.level().to_string().green(),
log::Level::Debug => record.level().to_string().blue(),
log::Level::Trace => record.level().to_string().purple(),
};
write!(
w,
"[{}] {} [{}] {}",
now.format("%Y-%m-%d %H:%M:%S%.3f"),
level,
record.module_path().unwrap_or("<unnamed>"),
&record.args()
)
})
.start()?;
Ok(())
}最终的 main 函数如下:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 初始化日志
init_logger()?;
let cache: Cache = Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(1024).unwrap())));
// 构建路由
let router = Router::new()
.route("/image/{spec}/{url}", routing::get(generate_handler))
.layer(Extension(cache));
// 启动 Web 服务器
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
let listener = TcpListener::bind(addr).await?;
print_test_url(
"https://images.pexels.com/photos/2470905/pexels-photo-2470905.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260",
);
info!("Listening on http://{}", addr);
// 处理服务器关机
let shutdown_signal = async {
tokio::signal::ctrl_c().await.unwrap();
info!("Server shutting down...");
};
axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal)
.await?;
Ok(())
}Cargo.toml 文件内容如下:
[dependencies]
anyhow = "1"
axum = "0.8"
base64 = "0.22"
bytes = "1"
colored = "3"
flexi_logger = "0.31"
log = "0.4"
lru = "0.16"
percent-encoding = "2" # URL编码/解码
photon-rs = "0.3" # 图片效果
prost = "0.14"
reqwest = "0.12"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
[build-dependencies]
anyhow = "1"
prost-build = "0.14"我们准备使用 photon 库,photon 是一个 Rust 实现的图片处理的库(GitHub),但为了以后能够扩展使用其它图片处理库,我们设计一个 Engine trait,它提供两个方法:apply 方法对 engine 按照 specs 进行一系列有序的处理,generate 方法从 engine 中生成目标图片:
// Engine trait:未来可以添加更多的 engine,主流程只需要替换 engine
pub trait Engine {
// 对 engine 按照 specs 进行一系列有序的处理
fn apply(&mut self, specs: &[Spec]);
// 从 engine 中生成目标图片,注意这里用的是 self,而非 self 的引用
fn generate(self, format: ImageOutputFormat) -> Vec<u8>;
}对于 apply 方法的实现,我们可以再设计一个 trait,这样可以为每个 Spec 生成对应处理:
// SpecTransform:未来如果添加更多的 spec,只需要实现它即可
pub trait SpecTransform<T> {
// 对图片使用 op 做 transform
fn transform(&mut self, op: T);
}首先创建 src/engien.rs ,添加 trait 的定义:
// src/engien.rs
use image::ImageFormat;
use crate::pb::Spec;
/// 图片处理引擎
pub trait Engine {
/// 对 engine 按照 specs 进行一系列有序处理
fn apply(&mut self, specs: &[Spec]);
/// 从 engine 中生成目标图片
fn generate(self, format: ImageFormat) -> Vec<u8>;
}
/// 为每个 Spce 生成相应处理,所有 spec 实现它即可
pub trait SpecTransform<T> {
/// 对图片使用 op 做 transform
fn transform(&mut self, op: T);
}然后创建 src/engine/photon.rs ,对 Photon 实现 Engine trait,并为目前的Spec实现 SpecTransform trait:
// src/engine/photon.rs
use std::io::Cursor;
use std::sync::LazyLock;
use image::{DynamicImage, ImageBuffer, ImageFormat};
use photon_rs::{PhotonImage, effects, filters, multiple, transform};
use crate::engine::{Engine, SpecTransform};
use crate::pb::*;
/// 预加载的水印文件
static WATERMARK: LazyLock<PhotonImage> = LazyLock::new(|| {
let data = include_bytes!("../../assets/rust-logo.png");
let watermark = photon_rs::native::open_image_from_bytes(data).unwrap();
transform::resize(&watermark, 64, 64, transform::SamplingFilter::Nearest)
});
// 将我们的 SampleFilter 转换为 photon 的 SamplingFilter
impl From<resize::SampleFilter> for transform::SamplingFilter {
fn from(filter: resize::SampleFilter) -> Self {
match filter {
resize::SampleFilter::Unspecified => transform::SamplingFilter::Nearest,
resize::SampleFilter::Nearest => transform::SamplingFilter::Nearest,
resize::SampleFilter::Triangle => transform::SamplingFilter::Triangle,
resize::SampleFilter::CatmullRom => transform::SamplingFilter::CatmullRom,
resize::SampleFilter::Gaussian => transform::SamplingFilter::Gaussian,
resize::SampleFilter::Lanczos3 => transform::SamplingFilter::Lanczos3,
}
}
}
// photon-rs 相应方法里需要字符串
impl filter::Filter {
pub fn as_str(&self) -> Option<&'static str> {
match self {
filter::Filter::Unspecified => None,
filter::Filter::Oceanic => Some("oceanic"),
filter::Filter::Islands => Some("islands"),
filter::Filter::Marine => Some("marine"),
}
}
}
/// Photon engine
pub struct Photon(PhotonImage);
// 从 Bytes 转换成 Photon 结构
impl TryFrom<bytes::Bytes> for Photon {
type Error = anyhow::Error;
fn try_from(bytes: bytes::Bytes) -> Result<Self, Self::Error> {
Ok(Self(PhotonImage::new_from_byteslice(bytes.to_vec())))
}
}
// 为 Photon engine 实现 Engine trait
impl Engine for Photon {
fn apply(&mut self, specs: &[Spec]) {
for spec in specs {
match spec.data {
Some(spec::Data::Resize(ref v)) => self.transform(v),
Some(spec::Data::Crop(ref v)) => self.transform(v),
Some(spec::Data::Fliph(ref v)) => self.transform(v),
Some(spec::Data::Flipv(ref v)) => self.transform(v),
Some(spec::Data::Contrast(ref v)) => self.transform(v),
Some(spec::Data::Filter(ref v)) => self.transform(v),
Some(spec::Data::Watermark(ref v)) => self.transform(v),
// 对于目前不认识的 spec,不做任何处理
_ => {}
}
}
}
fn generate(self, format: ImageFormat) -> Vec<u8> {
image_to_buf_with_format(self.0, format)
}
}
// 为 Photon engine 实现支持的 Spec
impl SpecTransform<&Resize> for Photon {
fn transform(&mut self, op: &Resize) {
self.0 = match op.mode() {
resize::Mode::Unspecified => panic!("Unsupported resize mode"),
resize::Mode::Normal => {
transform::resize(&self.0, op.width, op.height, op.filter().into())
}
resize::Mode::SeamCarve => transform::seam_carve(&self.0, op.width, op.height),
};
}
}
impl SpecTransform<&Crop> for Photon {
fn transform(&mut self, op: &Crop) {
self.0 = transform::crop(&self.0, op.x1, op.y1, op.x2, op.y2);
}
}
impl SpecTransform<&FlipHorizontal> for Photon {
fn transform(&mut self, _op: &FlipHorizontal) {
transform::fliph(&mut self.0);
}
}
impl SpecTransform<&FlipVertical> for Photon {
fn transform(&mut self, _op: &FlipVertical) {
transform::flipv(&mut self.0);
}
}
impl SpecTransform<&Contrast> for Photon {
fn transform(&mut self, op: &Contrast) {
effects::adjust_contrast(&mut self.0, op.contrast);
}
}
impl SpecTransform<&Filter> for Photon {
fn transform(&mut self, op: &Filter) {
let filter = op.filter();
match filter {
filter::Filter::Unspecified => panic!("Unsupported filter"),
_ => filters::filter(&mut self.0, filter.as_str().unwrap()),
}
}
}
impl SpecTransform<&Watermark> for Photon {
fn transform(&mut self, op: &Watermark) {
multiple::watermark(&mut self.0, &WATERMARK, op.x, op.y);
}
}
// 将 Image 按照指定格式进行转换
fn image_to_buf_with_format(image: PhotonImage, format: ImageFormat) -> Vec<u8> {
let raw_pixels = image.get_raw_pixels();
let img_width = image.get_width();
let img_height = image.get_height();
let img_buffer = ImageBuffer::from_vec(img_width, img_height, raw_pixels).unwrap();
let dyn_image = DynamicImage::ImageRgba8(img_buffer);
let mut writer = Cursor::new(Vec::with_capacity(dyn_image.as_bytes().len()));
dyn_image.write_to(&mut writer, format).unwrap();
writer.into_inner()
}最后,修改 main.rs 中的 generate_handler 函数,处理之前留下的 TODO:
async fn generate_handler(
Path(Params { spec, url }): Path<Params>,
Extension(cache): Extension<Cache>,
) -> Result<(HeaderMap, Vec<u8>), StatusCode> {
let url: &str = &percent_encoding::percent_decode_str(&url).decode_utf8_lossy();
let spec: pb::ImageSpec = spec
.as_str()
.try_into()
.map_err(|_| StatusCode::BAD_REQUEST)?;
let data = retrieve_image(url, cache)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
// 使用 Image engine 处理图片
let mut engine: Photon = data
.try_into()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
engine.apply(&spec.specs);
let image = engine.generate(image::ImageFormat::Jpeg);
info!("Finished processing image, size: {}", image.len());
let mut headers = HeaderMap::new();
headers.insert("content-type", HeaderValue::from_static("image/jpeg"));
Ok((headers, image))
}print_test_url("https://images.pexels.com/photos/1562477/pexels-photo-1562477.jpeg?auto=compress&cs=tinysrgb&dpr=3&h=750&w=1260");最终项目结构:
.
└── abi.proto
└── assets
└── rust-logo.png
└── thunbor.webp
└── build.rs
└── Cargo.toml
└── examples
└── image_spce.rs
└── LICENSE-MIT.txt
└── README.md
└── src
└── engine
└── photon.rs
└── engine.rs
└── lib.rs
└── main.rs
└── pb
└── abi.rs
└── pb.rs