【Rust】Bruno我來了,一起來打網路API…

前言

根據尼爾森的不正確調查,上一期文章的收視率高達了87%,因應廣大網友的要求,筆者只好再緊急加映一集…這集就進階一點,來做做網路API吧,把CSV文件當成簡單的資料來源,讀取文件後,轉成JSON格式輸出,達到隔空抓藥的效果…

作業環境

項目 版本
macOS Sequoia 15.5
Visual Studio Code 1.100.3
Rust 1.87.0
Bruno 2.4.0

Rust套件

套件 版本 功能
actix-web 4.4.0 是一個高效能、非常靈活的 Rust 網頁框架。可以建立網頁伺服器、客戶端
actix-multipart 0.6 actix-web的多檔案下載功能套件
serde 1.0 是Rust生態系統中最受歡迎的序列化框架之一,提供了高效能且易於使用的JSON處理功能
serde_json 1.0 是用於處理JSON資料的Serde的一部分
colored 3.0 為終端機輸出添加顏色
local-ip-address 0.6.5 取得本地端的區網IP位置
csv 1.3.1 快速處理可變動的CSV文件
futures 0.3.31 非同步功能實現 - Future、Stream、Sink
chrono 0.4 處理日期 / 時間 / 時區的套件

安裝Rust套件

  • 這裡先用指令建立一個名叫rust_csv_api的專案名稱,注意要用小寫喲…
cargo new rust_csv_api
  • 然後更新Cargo.toml的內容,填入後面要用到的套件們…
[dependencies]
actix-web = "4.4.0"
actix-multipart = "0.6"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }
csv = "1.3.1"
futures = "0.3.31"
chrono = { version = "0.4", features = ["serde"] }
colored = "3.0"
local-ip-address = "0.6.5"
  • 之後在終端機執行以下的指令就會下載安裝了,你會看到會多出一個名叫target的資料夾,容量很大…
  • 當然,要用到的兩筆CSV文件也就順便放在document的目錄之中了…
cargo check
  • 當然,要清掉的話也是可以的…
cargo clean                     # 清理當前編譯產物
cargo clean --target debug		# 清理當前編譯產物 for debug環境
cargo clean --target release	# 清理當前編譯產物 for release環境
cargo clean -p <package>		# 清理該指名套件
  • 也可以用tree指令來看看到底裝了什麼?
cargo tree --duplicate

建立列印小工具 - ww_print

#[macro_export]
macro_rules! ww_print {
    ($message:expr) => {
        println!("\n[{file} - {line}]\n{message}",
            file = file!().green().bold(),
            line = format!("line.{}", line!().to_string()).yellow().bold(),
            message = $message
        )
    };
}
  • 然後我們在main.rs中使用一下…
  • 記得一定要先裝colored套件,不然會有錯誤喲…
use colored::Colorize;

mod library;

fn main() {
    ww_print!("Hello, world!");
}
  • 然後執行一下以下的指令,有沒有看到有顏色的檔名跟行數啊,是不是很好用啊…

建立API

建立第一支API

  • 首先先建立兩個檔案,一個叫config.rs,另一個叫model.rs…
  • config.rs是放公用的常數設定檔…
pub const DEFAULT_PORT: u16 = 8080;
pub const LOCALHOST: &str = "127.0.0.1";
pub const CSV_FOLDER_PATH: &str = "document";
  • model.rs是放參數模型,在rust常常使用模型來處理變數,反而不會直接去添加文字Key值來處理…
use serde::{Serialize};

#[derive(Serialize)]
pub struct Message {
    pub message: String,
    pub timestamp: i64,
    pub date: String,
}
  • 我們要生成一個名叫/first的API,使用actix_web
  • 接下來就是,該引用的引用,該抄的抄一抄,程式碼應該很好理解…
  • 產生的結果就是顯示文字跟時間 / 日期,先能動再說…
use std::io::{Error};

use colored::Colorize;
use actix_web::{get};
use actix_web::{App, HttpServer, Responder, web};
use chrono::{Utc};
use local_ip_address::{local_ip};

mod library;
mod model;
mod config;

use crate::model::{Message};
use crate::config::*;

#[get("/first")]
async fn get_first_action() -> impl Responder {

    let now = Utc::now();

    let response = Message {
        message: "安安你好嗎?".to_string(),
        timestamp: now.timestamp_millis(),
        date: now.to_rfc3339(),
    };

    web::Json(response)
}

async fn register_service() -> Result<(), Error> {

    let current_ip = local_ip().unwrap_or_else(|_| LOCALHOST.parse().unwrap());

    ww_print!(format!(
        "Starting server at {localhost}:{port}\nStarting server at {ip}:{port}",
        ip = current_ip,
        port = DEFAULT_PORT,
        localhost = LOCALHOST
    ));

    HttpServer::new(|| {
        App::new().service(get_first_action)
    })
    .bind(format!("{}:{}", current_ip, DEFAULT_PORT))?
    .bind(format!("{}:{}", LOCALHOST, DEFAULT_PORT))?
    .run()
    .await
}

#[actix_web::main]
async fn main() -> Result<(), Error> {
    register_service().await
}
cargo run

  • 在這邊要先說明的是Macro - 巨集的功能,就是#[get("/first")]#[actix_web::main]這兩個巨集…
  • 這個功能在Rust很常用到,很像Swift的property wrapper功能,簡化了很多樣板代碼的撰寫…
  • #[get("/first")]就是指使用GET執行get_first_action()這個函數…
  • #[actix_web::main]是指讓main()在編譯時運行非同步程式碼…
  • 另外就是閉包的問題 - Closure,居然是以「||」來表示的,其實就是下面這個樣子啦…
fn  add_one_v1   (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };

&str / String / &String

  • 建議使用原則
  • 函數參數優先使用 &str (字串切片 - stack / 固定大小,不可變)
  • 需要修改時使用 String (所有權 - heap / 可變大小)
  • 需要共享時使用 &String (引用 - stack / 固定大小,不可變)

看門狗?

  • 有沒有想過,每一次改寫程式碼後,都要關閉服務器,再重新啟動呢?有沒有自動會重新啟動功能呢?
  • 有…它就是cargo-watch
  • 安裝好之後,執行下列的指令,它就會…
  • -c: 每次重新執行前清除控制台
  • -q: 使輸出更簡潔
  • -d 2: 設置 2 秒的延遲,避免過於頻繁的重新編譯
  • -x run: 執行 run 指令
cargo install cargo-watch
cargo watch -c -q -d 2 -x run

Rust沒有Defer怎麼辦?那就自己做一個吧…

  • 相信寫過Swift的同學們,應該很常用defer的功能吧?一種延時呼叫的功能,至少我個人很滿常用的,但是…Rust沒有,所以要自己做一個…
  • Rust也有類似defer功能,可以使用std::mem::drop來實現…
  • 跟一般建立struct大同小異,主要是實現一個Drop trait,可以把它當成是一個swift的protocol,就先這樣類比吧…
  • Drop trait的fn drop()的功能是當struct被drop時,再執行closure,有點像deinit的行為,可以讓我們在struct被drop時,執行一些清理工作,例如關閉檔案、釋放資源等。
pub struct Defer<F: FnMut()> {
    closure: F,
}

impl<F: FnMut()> Defer<F> {
    pub fn new(closure: F) -> Self {
        Self { closure }
    }
}

impl<F: FnMut()> Drop for Defer<F> {
    fn drop(&mut self) {
        (self.closure)();
    }
}
  • 這裡我們可以試試看結果有沒有跟我們要的功能一致?
  • 要注意的是,一定要給一個變數記下來,不然就沒有變數可以drop,也就沒有執行Defer closure的機會了。

...
use crate::model::{Message, Defer};
...

#[get("/first")]
async fn get_first_action() -> impl Responder {

    let _defer = Defer::new(|| {
        ww_print!("執行Defer closure");
    });

    ww_print!("執行get_first_action");
    ...
}

美化一下API的輸出Log

  • 為了讓API的輸出Log更易讀,我們可以使用enum的方法,順便加一些顏色 / 類型轉換的功能,方便辨識。
  • model.rs再加一個名叫HttpMethod的enum…
  • &self參數的是一般函數 (.方法),沒有的是static函數 (::方法)…
#[derive(Debug)]
#[allow(dead_code)]
pub enum HttpMethod {
    Get,
    Post,
}

impl HttpMethod {

    pub fn as_str(&self) -> &str {
        match self {
            HttpMethod::Get => "Get",
            HttpMethod::Post => "Post",
        }
    }

    pub fn on_custom_color(&self) -> CustomColor {
        match self {
            HttpMethod::Get => CustomColor::new(24, 226, 18),
            HttpMethod::Post => CustomColor::new(255, 250, 250),
        }
    }
}
  • 細節就不多說了,主要是仿一些主流的Web框架的Log輸出,例如ginflask等。
  • 建立一個新的library,名叫utility.rs,記錄一下小工具,程式碼如下,記得要在mod.rs加入它喲…
  • 這裡唯一要說明的東西,因為status_code會比_defer內的closure的生命週期短,需要宣告成Cell,然後使用get方法獲取值,也就是指標,當然也是要為了單線程安全
/// library/utility.rs
use std::cell::Cell;

use colored::Colorize;
use actix_web::http::StatusCode;
use actix_web::{HttpRequest};
use chrono::{Local};

use crate::model::{HttpMethod};

pub fn log_message(request: &HttpRequest, status_code: &Cell<StatusCode>, method: HttpMethod) -> String {

    let connection_info = request.connection_info().clone();
    let client_ip = connection_info.peer_addr().unwrap_or("unknown");
    let request_url = request.uri().to_string();

    let status = match status_code.get() {
        code if (500..600).contains(&code.as_u16()) => status_code.get().to_string().red(),
        StatusCode::OK => status_code.get().to_string().green(),
        _ => status_code.get().to_string().yellow(),
    };

    let fix_method = format!("{:^5}", method.as_str());

    format!(
        "{ip} - [{date}] {method} {url}, {status}",
        ip = client_ip.bold(),
        date = Local::now().format("%d/%b/%Y %H:%M:%S"),
        url = request_url.yellow(),
        method = fix_method.on_custom_color(method.on_custom_color()),
        status = status,
    )
}
/// library/mod.rs
pub mod macros;
pub mod utility;
  • 現在我們就來試看看吧,是不是很有feel啊?
mod library;
...

use crate::library::utility::log_message;
...
#[get("/first")]
async fn get_first_action(request: HttpRequest) -> impl Responder {

    let status_code = Cell::new(StatusCode::OK);
    let now = Utc::now();

    let _defer = Defer::new(|| {
        ww_print!(log_message(&request, &status_code, HttpMethod::Get));
    });

    let response = Message {
        message: "安安你好嗎?".to_string(),
        timestamp: now.timestamp_millis(),
        date: now.to_rfc3339(),
    };

    web::Json(response)
}

取得Query變數

  • 一般常常看到網址的尾端會加上?id=123&name=William的字樣,這就是要取得的Query變數,可以把這些數值傳給Server端。
  • 接下來我們就來試做一個簡單的Demo,加強它的功能…
  • 先在model.rs新增一個struct,用來儲存Query變數的值,就是含有id和name兩個欄位,是可填可不填的Option欄位。
use serde::{Deserialize};

#[derive(Deserialize)]
pub struct Params {
    pub id: Option<i32>,
    pub name: Option<String>,
}
  • 然後新增一個/query的API來取得Query變數的值,程式碼跟/first的差不多,只是多了一個處理query的值…
  • 這裡使用match來處理query的值,因為是可選的,所以會有四種可能…
  • 最後要記得註冊get_query_action喲…
...
use crate::model::{Message, Defer, HttpMethod, Params};
...

#[get("/query")]
async fn get_query_action(request: HttpRequest, query: web::Query<Params>) -> impl Responder {

    let status_code = Cell::new(StatusCode::OK);
    let now = Utc::now();

    let _defer = Defer::new(|| {
        ww_print!(log_message(&request, &status_code, HttpMethod::Get));
    });

    let message = match (&query.name, query.id) {
        (Some(name), Some(id)) => format!("[Hello, {} (ID: {})!", name, id),
        (Some(name), None) => format!("Hello, {}!", name),
        (None, Some(id)) => format!("Hello, User {}!", id),
        (None, None) => String::from("Hello, World!"),
    };

    let response = Message {
        message: message.to_string(),
        timestamp: now.timestamp_millis(),
        date: now.to_rfc3339(),
    };

    web::Json(response)
}

...
async fn register_service() -> Result<(), Error> {
...
    HttpServer::new(|| {
            App::new()
                .service(get_first_action)
                .service(get_query_action)
        })
        .bind(format!("{}:{}", current_ip, DEFAULT_PORT))?
        .bind(format!("{}:{}", LOCALHOST, DEFAULT_PORT))?
        .run()
        .await
}
  • 最後,大家可以試試看http://localhost:8080/query?id=987987&name=William這個API喲…

取得Body的值

  • 這裡主要是要取得Body的值,因為GET規定是沒有Body的,所以我們選用POST來做示範…
  • 新增一個/json的API來取得Body的值,程式碼跟/query的差不多…
  • 這裡我們直接使用JSON字串來當輸出值,使用serde_json::from_str()來把JSON字串轉成Value,一個可輸出的值…
  • 這裡的unwrap(),其實就是Swift的!,強制解出其值…
  • 最後還是要記得註冊post_json_action喲…
...
use serde_json::{Value};
...

#[post("/json")]
async fn post_json_action(request: HttpRequest, input: web::Json<Params>) -> impl Responder {

    let status_code = Cell::new(StatusCode::OK);
    let now = Utc::now();

    let _defer = Defer::new(|| {
        ww_print!(log_message(&request, &status_code, HttpMethod::Post));
    });

    let message = match (&input.name, input.id) {
        (Some(name), Some(id)) => format!("Hello, {} (ID: {})!", name, id),
        (Some(name), None) => format!("Hello, {}!", name),
        (None, Some(id)) => format!("Hello, User {}!", id),
        (None, None) => String::from("Hello, World!"),
    };

    let json = format!(
        r#"{{
            "message": "{}",
            "timestamp": {},
            "date": "{}"
        }}"#,
        message,
        now.timestamp_millis(),
        now.to_rfc3339()
    );

    let parsed: Value = serde_json::from_str(&json).unwrap();
    web::Json(parsed).customize().with_status(status_code.get())
}
...
  • 再來就請大家再測測看啦…

動態路由 - Dynamic Routing

  • Body的值可以很容易的增加,那Query的值可以嗎?
  • 可以的,Router也是可以設定成Path的值,例如/user/{id}/{name},這樣就可以根據不同的id / name來取得不同的使用者資訊了。
  • 比較要說明的是,我們直接使用path: web::Path<(String, String)>來接idname,再用into_inner()把值解開…
  • 如果沒有用到的值,可以使用__name來命名,不然Rust會一直提示說這個值沒用到…
#[post("/user/{id}/{name}")]
async fn post_router_action(request: HttpRequest, path: web::Path<(String, String)>) -> impl Responder {

    let status_code = Cell::new(StatusCode::OK);
    let _defer = Defer::new(|| {
        ww_print!(log_message(&request, &status_code, HttpMethod::Post));
    });

    let (id, _name) = path.into_inner();

    let response = serde_json::json!({
        "id": id,
        "message": "更新成功",
        "timestamp": Utc::now().timestamp_millis()
    });

    web::Json(parsed).customize().with_status(status_code.get())
}
let user: (i32, &str, bool) = (42, "hello", true);
let (name, age, is_active) = user;

println!("第一個值: {}", user.0);
println!("第二個值: {}", user.1);
println!("第三個值: {}", user.2);

動態Query

  • 其實動態Query也是可以的,特別適合用來測試用…
  • 這裡使用HashMap來處理Query的值,用iter()把值一個個疊代出來…
...
use actix_web::{App, HttpServer, HttpRequest, HttpResponse, Responder, web};
use std::collections::HashMap;
...

#[post("/dynamic")]
async fn dynamic_query(request: HttpRequest, query: web::Query<HashMap<String, String>>) -> impl Responder {

    let status_code = Cell::new(StatusCode::OK);
    let _defer = Defer::new(|| {
        ww_print!(log_message(&request, &status_code, HttpMethod::Post));
    });

    for (key, value) in query.iter() {
        println!("{}: {}", key, value);
    }

    HttpResponse::Ok().json(query.into_inner())
}

建立讀取CSV檔的API

讀取CSV檔

#[derive(Serialize, Deserialize, Debug)]
pub struct CsvRecord {
    pub name: String,
    pub notes: String,
    pub url: String,
    pub example: String,
    pub level: u8,
}
  • 然後使用csv套件的功能來讀取CSV檔…
  • 程式碼應該也很好理解,主要是利用泛型來加強功能,只處理符合DeserializeOwned - 可以解的Debug - 可列印trait約束,其實就是規範共同的行為
...
use std::fmt::Debug;
use csv::Reader;
use serde::de::DeserializeOwned;
...

pub fn parse_csv_file<T>(file_path: String) -> Result<Vec<T>, Error> where T: DeserializeOwned + Debug {

    let mut records: Vec<T> = Vec::new();

    let opened_file = match File::open(file_path) {
        Err(error) => return Err(error),
        Ok(file) => file,
    };

    let mut reader = Reader::from_reader(opened_file);

    for result in reader.deserialize() {
        match result {
            Err(error) => return Err(error.into()),
            Ok(record) => {
                ww_print!(format!("解析到記錄: {:?}", record));
                records.push(record)
            }
        }
    }

    Ok(records)
}
  • 我們先試看看讀不讀得出來CSV的文件檔,就建立一個/csv的API打看看…
  • 也可以把filename打錯,有做對應的錯誤處理,要更改status_code,要用.set(StatusCode::OK);才行喲…

#[post("/csv")]
async fn csv_action(request: HttpRequest) -> impl Responder {

    let status_code = Cell::new(StatusCode::NOT_FOUND);
    let _defer = Defer::new(|| {
        ww_print!(log_message(&request, &status_code, HttpMethod::Post));
    });

    let filename = "Linux.csv";
    let file_path = format!("{}/{}", config::CSV_FOLDER_PATH, filename);

    let records: Vec<CsvRecord> = match parse_csv_file(file_path) {
        Err(error) => {`
            return web::Json(serde_json::json!({ "error": error.to_string() }))
                .customize()
                .with_status(status_code.get());
        }
        Ok(records) => records,
    };

    status_code.set(StatusCode::OK);
    web::Json(serde_json::json!({})).customize().with_status(status_code.get())
}

  • 不知道大家有沒有注意到,為什麼有return呢?之前不都沒有寫嗎?
  • Rust常用的是隱式回傳的寫法,就是沒分號的return,這是最後一行;而顯式回傳的寫法,就是一般的寫法,是不是很有趣啊?差一個分號就大~了…

轉成JSON格式輸出

#[post("/csv")]
async fn csv_action(request: HttpRequest) -> impl Responder {
    ...
    let response = serde_json::json!({ "result": records });
    status_code.set(StatusCode::OK);

    web::Json(serde_json::json!(response)).customize().with_status(status_code.get())
}

選擇CSV文件

  • 現在,我們要利用變數來選擇CSV文件,並根據選擇的文件名稱讀取相應的CSV文件內容。
  • 首先新增一個constant.rs檔案,並新增一個名叫CsvFileTypeenum,也把HttpMethod移來這邊,相關的地方也要修正一下…
  • 這個enum的功能就是要將Linux.csvCrossPlatform.csv這兩個檔名做分類,當然用1、2、3也是可以的…
  • 然後再加上一個自定義錯誤類型,因為有可能會轉錯嘛,然後再把std::fmt::Display的trait特徵加到CustomError中,實現fmt()方法…
  • 這個std::fmt::Display可以把它類比成Swift的CustomStringConvertible這個Protocol,自訂print輸出文字…
  • 在print!()方法中的參數都要實作這個fmt()才能被印出來,以後應該會很常用到它,它都叫Display了嘛,可以想象成print!("hello".fmt())
use colored::{CustomColor};

pub enum CustomError {
    EmptyString,
    ParseStringError,
    InvalidType(String),
    FileNotFound(String),
    UnknownError,
}

impl std::fmt::Display for CustomError {

    fn fmt(&self, format: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            CustomError::InvalidType(_type) => write!(format, "無效的檔案類型: {}", _type),
            CustomError::EmptyString => write!(format, "檔案類型不能為空"),
            CustomError::UnknownError => write!(format, "未知錯誤"),
            CustomError::ParseStringError => write!(format, "字串解析錯誤"),
            CustomError::FileNotFound(path) => write!(format, "檔案未找到: {}", path),
        }
    }
}

#[derive(Debug)]
#[allow(dead_code)]
pub enum HttpMethod {
    Get,
    Post,
}
...


#[derive(Debug)]
#[allow(dead_code)]
pub enum CsvFileType {
    Linux,
    CrossPlatform,
}
...

可選的CSV文件檔名

  • 我們來改造一下csv_action函數,使其能夠處理不同的CSV檔案類型,例如Linux和CrossPlatform,並根據不同的檔案類型進行不同的處理…
  • API路徑加上一個可變Path參數filename,接下來就把filename跟自定義錯誤印出來看看吧,這樣是不是更加清楚錯什麼了啊?
#[post("/csv/{filename}")]
async fn csv_action(request: HttpRequest, path: web::Path<String>) -> impl Responder {
    ...
    let filename = path.into_inner();
    let error = CustomError::ParseStringError;

    ww_print!(format!("CSV檔名: {}", filename));
    ww_print!(format!("自定義錯誤: {}", error));

  • 再來我們希望將filename轉成CsvFileType,除了防止打錯字之外,用enum的話,可以用match (switch..case)來處理,更加的方便…
  • 就是跟Swift的enum一樣,字串轉enum…
...
use std::str::FromStr;
...
use crate::constant::{HttpMethod, CsvFileType};
...

#[post("/csv/{filename}")]
async fn csv_action(request: HttpRequest, path: web::Path<String>) -> impl Responder {
    ...
    let filename = path.into_inner();
    // let file_type: CsvFileType = match filename.parse() {
    let file_type: CsvFileType = match CsvFileType::from_str(filename.as_str()) {
        Ok(_type) => _type,
        Err(error) => {
            ww_print!(format!("錯誤: {}. 預設為: Linux.csv", error));
            CsvFileType::Linux
        }
    };

    ww_print!(format!("轉換的類型: {:?}", file_type));
    ww_print!(format!("CSV檔名: {:?}", file_type.as_str()));
    ...
}
  • 執行之後會發現少了些什麼?是CsvFileType::from_str(),我加了from_str()
  • 這個是一個std::str::FromStr的trait的實現,就是一般會用來轉型的那個parse()方法…
  • 除了是這樣用 - <filename>.parse(),也可以這樣用 - CsvFileType::from_str(<filename>)
use colored::{Colorize, CustomColor};
use crate::ww_print;

impl std::str::FromStr for CsvFileType {

    type Err = CustomError;

    fn from_str(str: &str) -> Result<Self, Self::Err> {

        ww_print!(format!("輸入的文字為: {}", str));

        match str.to_lowercase().as_str() {
            "linux" => Ok(CsvFileType::Linux),
            "crossplatform" => Ok(CsvFileType::CrossPlatform),
            _ => Err(CustomError::InvalidType(str.to_string())),
        }
    }
}

  • 我們把加強後的程式碼再來跑看看 - http://127.0.0.1:8080/csv/windows
  • 是不是一模一樣,沒什麼變化啊?但是如果是用 - http://127.0.0.1:8080/csv/CrossPlatform呢?
#[post("/csv/{filename}")]
async fn csv_action(request: HttpRequest, path: web::Path<String>) -> impl Responder {

    let status_code = Cell::new(StatusCode::NOT_FOUND);
    let _defer = Defer::new(|| {
        ww_print!(log_message(&request, &status_code, HttpMethod::Post));
    });

    let filename = path.into_inner();
    // let file_type: CsvFileType = match filename.parse() {
    let file_type: CsvFileType = match CsvFileType::from_str(filename.as_str()) {
        Ok(_type) => _type,
        Err(error) => {
            ww_print!(format!("錯誤: {}. 預設為: Linux.csv", error));
            CsvFileType::Linux
        }
    };

    let file_path: String = format!("{}/{}", config::CSV_FOLDER_PATH, file_type.as_str());
    let records: Vec<CsvRecord> = match parse_csv_file(file_path) {
        Err(error) => {
            return web::Json(serde_json::json!({ "error": error.to_string() }))
                .customize()
                .with_status(status_code.get());
        }
        Ok(records) => records,
    };

    let response = serde_json::json!({ "result": records });
    status_code.set(StatusCode::OK);

    web::Json(serde_json::json!(response)).customize().with_status(status_code.get())
}

反序列化欄位不一怎麼辦?

  • 什麼?明明就有CrossPlatform.csv啊,為什麼會讀不出來呢?
  • 原來是因為欄位名稱不一致,沒有example這一列,導致反序列化失敗,那要怎麼辦呢?
{
  "error": "CSV deserialize error: record 1 (line: 2, byte: 33): missing field `example`"
}
  • 照一般的做法,應該會是CsvFileType的各類型,各去對一個struct,然後利用泛型去處理,但…serde有一個很有趣的處理方式…
  • 我們先把多出來的欄位也加到CsvRecord上…
#[derive(Serialize, Deserialize, Debug)]
pub struct CsvRecord {
    pub name: String,
    pub notes: String,
    pub url: String,
    pub level: u8,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub example: Option<String>,

    #[serde(deserialize_with = "deserialize_platform")]
    #[serde(default)]
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub platform: Vec<String>,
}
  • 其中#[serde(skip_serializing_if = "Option::is_none")]這個巨集,用來控制序列化時是否跳過None值的欄位,跟Go的套件gin有點像…
  • 也就是說,這一欄有就解出來,沒有也沒關係的意思啦,可以先單獨加上這行試試看,應該就可以沒問題了…
#[serde(skip_serializing_if = "Option::is_none")]
pub example: Option<String>,
  • 先說#[serde(skip_serializing_if = "Vec::is_empty")],就是它可有可無的意思…
  • 再來就是#[serde(default)],用人話說,就是有預設值,是Vec的Default::default() - 空Array…
  • 這個#[serde(deserialize_with = "deserialize_platform")]就是說,要反序列化時,要使用deserialize_platform函數來處理platform欄位的反序列化…
  • 因為platform的值是長得像Windows,macOS,Linux,Android,iOS這樣子的字串,所以要更進一步加工成Vec<String>的樣子…
#[serde(deserialize_with = "deserialize_platform")]
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub platform: Vec<String>,
  • 這個就是deserialize_with中要實作的函數,用來處理platform欄位的反序列化…
use serde::de::Deserializer;

fn deserialize_platform<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error> where D: Deserializer<'de> {
    let s: String = String::deserialize(deserializer)?;
    Ok(s.split(',').map(|s| s.trim().to_string()).collect())
}
  • 附上完整的model.rs的程式碼…
  • 這樣就完成了platform欄位的反序列化,我們再用brunopost一次看看吧…
http://127.0.0.1:8080/csv/CrossPlatform
http://127.0.0.1:8080/csv/linux
use serde::{Serialize, Deserialize};
use serde::de::Deserializer;

#[derive(Serialize)]
pub struct Message {
    pub message: String,
    pub timestamp: i64,
    pub date: String,
}

#[derive(Deserialize)]
pub struct Params {
    pub id: Option<i32>,
    pub name: Option<String>,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct CsvRecord {
    pub name: String,
    pub notes: String,
    pub url: String,
    pub level: u8,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub example: Option<String>,

    #[serde(deserialize_with = "deserialize_platform")]
    #[serde(default)]
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub platform: Vec<String>,
}

fn deserialize_platform<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error> where D: Deserializer<'de> {
    let str = String::deserialize(deserializer)?;
    Ok(str.split(',').map(|str| str.trim().to_string()).collect())
}

pub struct Defer<F: FnMut()> {
    closure: F,
}

impl<F: FnMut()> Defer<F> {
    pub fn new(closure: F) -> Self {
        Self { closure }
    }
}

impl<F: FnMut()> Drop for Defer<F> {
    fn drop(&mut self) {
        (self.closure)();
    }
}

整理程式碼

  • 最後,我們把main.rs內的程式包裝成一個service,就放在src/service/csv_service.rs底下…
  • 把程式跟Web路徑分開,看起來比較乾淨,日後也好維護…
use std::cell::Cell;
use std::collections::HashMap;
use std::str::FromStr;

use actix_web::http::StatusCode;
use actix_web::{HttpRequest, HttpResponse, Responder, web};
use chrono::{Utc};
use colored::Colorize;
use serde_json::{Value};

use crate::library::utility::{log_message, parse_csv_file};
use crate::model::*;
use crate::config;
use crate::constant::{HttpMethod, CsvFileType};
use crate::ww_print;

pub fn get_first_action(request: HttpRequest) -> impl Responder {

    let status_code = Cell::new(StatusCode::OK);
    let now = Utc::now();

    let _defer = Defer::new(|| {
        ww_print!(log_message(&request, &status_code, HttpMethod::Get));
    });

    let response = Message {
        message: "安安你好嗎?".to_string(),
        timestamp: now.timestamp_millis(),
        date: now.to_rfc3339(),
    };

    web::Json(response)
}

pub fn get_query_action(request: HttpRequest, query: web::Query<Params>) -> impl Responder {

    let status_code = Cell::new(StatusCode::OK);
    let now = Utc::now();

    let _defer = Defer::new(|| {
        ww_print!(log_message(&request, &status_code, HttpMethod::Get));
    });

    let message = match (&query.name, query.id) {
        (Some(name), Some(id)) => format!("[Hello, {} (ID: {})!", name, id),
        (Some(name), None) => format!("Hello, {}!", name),
        (None, Some(id)) => format!("Hello, User {}!", id),
        (None, None) => String::from("Hello, World!"),
    };

    let response = Message {
        message: message.to_string(),
        timestamp: now.timestamp_millis(),
        date: now.to_rfc3339(),
    };

    web::Json(response)
}

pub fn post_json_action(request: HttpRequest, input: web::Json<Params>) -> impl Responder {

    let status_code = Cell::new(StatusCode::OK);
    let now = Utc::now();

    let _defer = Defer::new(|| {
        ww_print!(log_message(&request, &status_code, HttpMethod::Post));
    });

    let message = match (&input.name, input.id) {
        (Some(name), Some(id)) => format!("Hello, {} (ID: {})!", name, id),
        (Some(name), None) => format!("Hello, {}!", name),
        (None, Some(id)) => format!("Hello, User {}!", id),
        (None, None) => String::from("Hello, World!"),
    };

    let json = format!(
        r#"{{
            "message": "{}",
            "timestamp": {},
            "date": "{}"
        }}"#,
        message,
        now.timestamp_millis(),
        now.to_rfc3339()
    );

    let parsed: Value = serde_json::from_str(&json).unwrap();
    web::Json(parsed).customize().with_status(status_code.get())
}

pub fn post_router_action(request: HttpRequest, path: web::Path<(String, String)>) -> impl Responder {

    let status_code = Cell::new(StatusCode::OK);
    let _defer = Defer::new(|| {
        ww_print!(log_message(&request, &status_code, HttpMethod::Post));
    });

    let (id, _name) = path.into_inner();

    let response = serde_json::json!({
        "id": id,
        "message": "更新成功",
        "timestamp": Utc::now().timestamp_millis()
    });

    web::Json(response).customize().with_status(status_code.get())
}

pub fn dynamic_query(request: HttpRequest, query: web::Query<HashMap<String, String>>) -> impl Responder {

    let status_code = Cell::new(StatusCode::OK);
    let _defer = Defer::new(|| {
        ww_print!(log_message(&request, &status_code, HttpMethod::Post));
    });

    for (key, value) in query.iter() {
        println!("{}: {}", key, value);
    }

    HttpResponse::Ok().json(query.into_inner())
}

pub fn read_csv_action(request: HttpRequest, path: web::Path<String>) -> impl Responder {

    let status_code = Cell::new(StatusCode::NOT_FOUND);
    let _defer = Defer::new(|| {
        ww_print!(log_message(&request, &status_code, HttpMethod::Post));
    });

    let filename = path.into_inner();
    // let file_type: CsvFileType = match filename.parse() {
    let file_type: CsvFileType = match CsvFileType::from_str(filename.as_str()) {
        Ok(_type) => _type,
        Err(error) => {
            ww_print!(format!("錯誤: {}. 預設為: Linux.csv", error));
            CsvFileType::Linux
        }
    };

    let file_path: String = format!("{}/{}", config::CSV_FOLDER_PATH, file_type.as_str());
    let records: Vec<CsvRecord> = match parse_csv_file(file_path) {
        Err(error) => {
            status_code.set(StatusCode::BAD_REQUEST);
            return web::Json(serde_json::json!({ "error": error.to_string() })).customize().with_status(status_code.get());
        }
        Ok(records) => records,
    };

    let response = serde_json::json!({ "result": records });
    status_code.set(StatusCode::OK);

    web::Json(serde_json::json!(response)).customize().with_status(status_code.get())
}
use std::io::{Error};
use std::collections::HashMap;

use colored::Colorize;
use actix_web::{get, post};
use actix_web::{App, HttpServer, HttpRequest, Responder, web};
use local_ip_address::{local_ip};

mod library;
mod model;
mod config;
mod constant;
mod service;

use crate::model::Params;
use crate::config::*;

use crate::{
    service::csv_service as _service_,
};

#[get("/first")]
async fn get_first_action(request: HttpRequest) -> impl Responder {
    _service_::get_first_action(request)
}

#[get("/query")]
async fn get_query_action(request: HttpRequest, query: web::Query<Params>) -> impl Responder {
    _service_::get_query_action(request, query)
}

#[post("/json")]
async fn post_json_action(request: HttpRequest, input: web::Json<Params>) -> impl Responder {
    _service_::post_json_action(request, input)
}

#[post("/user/{id}/{name}")]
async fn post_router_action(request: HttpRequest, path: web::Path<(String, String)>) -> impl Responder {
    _service_::post_router_action(request, path)
}

#[post("/dynamic")]
async fn dynamic_query(request: HttpRequest, query: web::Query<HashMap<String, String>>) -> impl Responder {
    _service_::dynamic_query(request, query)
}

#[post("/csv/{filename}")]
async fn csv_action(request: HttpRequest, path: web::Path<String>) -> impl Responder {
    _service_::read_csv_action(request, path)
}

async fn register_service() -> Result<(), Error> {

    let current_ip = local_ip().unwrap_or_else(|_| LOCALHOST.parse().unwrap());

    ww_print!(format!(
        "Starting server at {localhost}:{port}\nStarting server at {ip}:{port}",
        ip = current_ip,
        port = DEFAULT_PORT,
        localhost = LOCALHOST
    ));

    HttpServer::new(|| {
        App::new()
            .service(get_first_action)
            .service(get_query_action)
            .service(post_json_action)
            .service(dynamic_query)
            .service(post_router_action)
            .service(csv_action)
    })
    .bind(format!("{}:{}", current_ip, DEFAULT_PORT))?
    .bind(format!("{}:{}", LOCALHOST, DEFAULT_PORT))?
    .run()
    .await
}

#[actix_web::main]
async fn main() -> Result<(), Error> {
    register_service().await
}

編譯二進制檔案

  • 可以使用cargo build命令編譯二進制檔案,用cargo run命令執行二進制檔案。
  • 如果是編譯debug版的,檔案會在target/debug目錄下。
  • 如果是編譯release版的,檔案會在target/release目錄下。
cargo build                                             #編譯debug版
cargo run                                               #執行debug版
cargo build --release                                   #編譯release版
cargo run --release                                     #執行release版
cargo build --target aarch64-apple-darwin --release     #編譯aarch64-apple-darwin release版
cargo build --target x86_64-apple-darwin --release      #編譯x86_64-apple-darwin release版

範例程式碼下載

後記

  • 終於打完了,也感謝Copilot的幫忙,這篇應該筆者寫過最長的一篇吧?寫完之後,發現Rust還滿愛用巨集的,也許是為了速度吧,在運算符號上也為了分別,怕打錯字,也用了滿多其它語言沒有的,雖然說在Key的時候有點麻煩,但一眼就能看出是什麼意思。
  • 沒想到Rust還真的是麻煩啊,如果用Golang / Java / Python / Node.js,開發速度一定是快得多的多了,不過做為一個下一代的C++,連Windows 11的核心也用它重寫了,雖然語法很麻煩,但就是…安全,連Google開始以Rust重寫Android裸機元件,進一步強化記憶體安全性,還有新酷音輸入法也用Rust重寫了,除了Linux群之外,不過要無償的工程師去維護兩套程式碼,也是滿說不過去的啦…