iT邦幫忙

2023 iThome 鐵人賽

DAY 16
0

先來替rust 的 http 加上 s

http 加上 s 不是很多(複數)的意思,加上的s是 Secure的意思,詳見https是什麼

新增https專案

我們後端來加上https的協定,我們之前在第4篇有提到在cargo.toml裡設定另一個執行檔的方法,這裡介紹使用另一種方式,我們可以按照cargo的慣例,在專案底下新增一個bin的資料夾,然後開一個https的檔案,裡面放一個fn main,檔案路徑結構如下:

web/src/
├── bin
│   └── https.rs
└── main.rs
// web/src/bin/https.rs
fn main() {
    println!("Hello, world!");
}

雖然也可以把httphttps寫在同一個執行檔裡,但是會增加一些些複雜度,我們先用另一個檔案的方式,後面有機會再寫合併。

開發前置準備

這時候我們要修改我們的批次檔,大家如果跟到現在應該很習慣享受cargo watch帶來的開發便利吧,因為我們在web專案裡加了不同的可執行檔,所以除了新加的https要指定bin,之前舊的web http也要指定執行檔為同名的專案執行檔:

@@ run.ps1 @@
 Write-Host "6) [app]: 執行 tauri 前端 UI"
+Write-Host "7) [web]: 執行 WebApi Server: HTTPS"
...
 } elseif ($opt -eq 3) {
+    cargo watch -q -c -w ./web -w ./service -w ./core -x 'run -p web --bin web'
-    cargo watch -q -c -w ./web -w ./service -w ./core -x 'run -p web'
...
+} elseif ($opt -eq 7) {
+    cargo watch -q -c -w ./web -w ./service -w ./core -x 'run -p web --bin https'
}

@@ run.sh @@
 echo 6: [tauri] dev
+echo 7: [web] run web api server with HTTPS
...
+  cargo watch -q -c -w ./web -w ./service -w ./core -x 'run -p web --bin web'
-  cargo watch -q -c -w ./web -w ./service -w ./core -x 'run -p web'
...
+elif [[ $VAR -eq 7 ]]
+  then
+  cargo watch -q -c -w ./web -w ./service -w ./core -x 'run -p web --bin https'
fi

改好後,我們直接執行批次檔./run.ps1./run.sh把https跑起來,就可以開始開發了:

https服務執行檔建立完成

引入config設定

先把原本web main的東西copy過來,一項一項來,免得問題太多一次會措手不及,我們先加config

// web/src/bin/https.rs
fn main() {
    config::init();
    println!("Hello, world!");
}

rust抱怨引入未設定的模組config

在這裡我們沒辦法引用web執行檔的內容,因為我們https是另一個獨立的執行檔,所以我們只能先加一個lib.rs,代表在web這個package裡面放一個共用的library:

在rust裡面,一個package只能有一個lib,所以src/lib.rs作為這個package裡可以被引用的程式庫,而在同package裡可以放很多個可執行檔(Binary),執行檔的寫法如同本篇一開頭所提。

web/src/
├── lib.rs
└── main.rs
// web/src/lib.rs
pub mod config;     // 記得要加 pub 才可以被引用
// web/src/bin/https.rs
use web::config;    // 使用時用package name web作為引用的根節點
fn main() {
    config::init(); // 這時候就可以正確讀到config了
    //...
}

順便把我們之前的main改一下,原本為子模組mod改為use引入lib裡的mod

@@ web/src/lib.rs @@
 pub mod config;
+pub mod logger;
+pub mod tic_tac_toe;
+pub mod error;
@@ web/src/main.rs @@
+use web::{config, error, tic_tac_toe, logger};
-mod logger;
-mod config;
-mod tic_tac_toe;
-mod error;

引入logger設定

成功後,接下來我們加logger:

// web/src/bin/https.rs
use web::config;
use web::logger;

fn main() {
    config::init();
    let file_appender = tracing_appender::rolling::daily("./logs", "log");
    let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
    logger::register_tracing(non_blocking);
}

這裡log寫法要佔三行,跟之前在main裡寫的好重覆,是不是有更好的作法,我們試著把這些改到logger裡。現在我們對生命週期稍微比較有熟悉了,試著先看一下之前的寫法,觀察到以下現象:

  • filer_appender在傳給non_blocking()之後就被move
  • (non_blocing, _guard)前面的non_blocking也在傳遞給register_tracing()時被move了
  • main程式區塊裡最後只剩下_guard還活著
  • register_tracing()回傳的結果是(),其實等於沒有回傳

可以推得_guard這個變數,需要存活在整個main中,不然可能會看不到log紀錄檔。

這裡加一個底線_prefix,在rust裡的慣例是說,有一個變數沒被後面寫的程式使用到,compiler會警告我們這個變數未被用到,但是我們其實意圖是要保留這個變數的,就加前綴_告訴編譯器,請它先不要警告,和無名稱的_變數有所不同,單純的_是在指派後就被拋棄,後面將不覆存在該變數。

試著改寫重覆的代碼到logger裡:

// web/src/logger.rs
use tracing_appender::non_blocking::{NonBlocking, WorkerGuard};
// 略

pub fn init() -> WorkerGuard {                 // guard 的 type 是 WorkGuard
    let file_appender = tracing_appender::rolling::daily("./logs", "log");
    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
    register_tracing(non_blocking);
    guard                                      // 把 guard 傳出去
}

pub fn tracing_filter() -> filter::EnvFilter {
    // ...略
    let dir4 = format!("https={}", level);    // 加上我們執行檔https的名字
    // ...略
    .add_directive(dir4.parse().unwrap())
}

接著修改https裡面的main

// web/src/bin/https.rs
fn main() {
    config::init();
    let _logger = logger::init();                // 之前logger初始化收斂成一行
    tracing::debug!("https server is starting"); // 測試是否在有輸出log
}

執行成功畫面:

https執行檔正確加入logger

讀取log檔案:

~/demo-app$ cat ./logs/log.2023-09-29 
2023-09-29T14:28:37.237669Z DEBUG https: https server is starting

以上正確work,好奇寶寶可以把main裡的_logger改成_看看會發生什麼事情,或是把logger::init()的回傳值改為()看看會發生什麼事。

原本http的main也記得一起修改:

@@ web/src/main.rs @@
+let _logger = logger::init();
-let file_appender = tracing_appender::rolling::daily("./logs", "log");
-let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
-logger::register_tracing(non_blocking);

抽取router路由

再來是把核心的api掛上來,在main裡的router太大包了,整個複製貼上很沒效率,是時候重構了,先從lib裡新增一個router檔案:

// web/src/lib.rs
pub mod routers;

在原本的 main 檔案裡有cors和一堆router,我們先抽cors:

// web/src/routers.rs
use warp::cors::Builder;
fn cors_config() -> Builder {
    warp::cors()
        .allow_any_origin()
        .allow_methods(vec!["GET", "PUT", "POST", "DELETE"])
}

接下來把其他router相關的程式也全貼過來:

use warp::{Filter, Rejection, Reply};
use service::tic_tac_toe::TicTacToeService;
use crate::{error, tic_tac_toe};

pub fn all_routers() -> impl Filter<Extract= impl Reply, Error=Rejection> + Clone {

    let hello = warp::path("hello")
        .and(warp::get())
        .map(|| {
            tracing::info!("saying hello...");
            "Hello, World!"
        });

    let game_service = service::tic_tac_toe::InMemoryTicTacToeService::new();
    game_service.new_game().unwrap();
    let api_games = tic_tac_toe::router_games(game_service);

    hello
        .or(api_games)
        .recover(error::handle_rejection)
        .with(cors_config())
        .with(warp::trace::request())
}

這個all_routers()具體要回傳什麼類別呢,可以搜尋一下rust warp router type找答案,就是使用更general的泛型類別。有時候如果直接照IDE的建議,自動實作要回傳的型別,會出現很奇怪的東西,因為我們在這裡一直用and/or串接 fn,就像鐵道一樣,所以讓IDE判斷的類別也跟著變成一大串。(雖然現在要過中秋,但程式碼不該像烤肉一樣串一大串。)我貼在下面讓大家感覺一下,以後如果追加更多API,就會變的無敵冗,完全無法使用啊啊啊。

pub fn all_routers() -> WithTrace<impl Fn(Info<'_>) -> Span + Clone + Sized, CorsFilter<Recover<Or<Map<And<Exact<Opaque<&'static str>>, impl Filter<Extract=(), Error=Rejection, Future=_> + Copy + Sized>, fn() -> &'static str>, impl Filter<Extract=(impl Reply + Sized, ), Error=Rejection, Future=_> + Clone + Sized>, fn(Rejection) -> impl Future<Output=Result<impl Reply + Sized, Infallible>> + Sized { handle_rejection }>>> {
    // 略
}

最後調整https的執行檔長的像下面這樣:

// web/src/bin/https.rs
use web::{config, routers, logger};  // 同樣的引用命名空間可以寫在一起

#[tokio::main]                       // warp::serve 方法是一個 Future
async fn main() {                    // 所以main要改成async,
    config::init();
    let _guard = logger::init();
    let routers = routers::all_routers();
    warp::serve(routers).run(([127, 0, 0, 1], 3031)).await;
}

原本的web也成一樣,只差在使用的port是3030。

加自簽憑證

https需要憑證,我們在localhost所以只能用自簽,直接用mkcert這個工具就對了,想挑戰的可以試試用openssl,估狗一下應該會有很多文章教學怎麼下openssl的指令,雖然我每次查到的指令長相好像都不太一樣(?),但一樣的是每次大概要都要花半天的時間,有了mkcert,考試都考100分呢,可以很方便地幫我們建立憑證,並加到系統信任清單裡。依官方說明,整理各系統安裝方式如下:

  • mac
    $ brew install mkcert
    $ brew install nss # if you use Firefox
    
  • linux
    • 先安裝
    $ sudo apt install libnss3-tools
        -or-
    $ sudo yum install nss-tools
        -or-
    $ sudo pacman -S nss
        -or-
    $ sudo zypper install mozilla-nss-tools
    
    • 再安裝mkcert
    $ curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64"
    $ chmod +x mkcert-v*-linux-amd64
    $ sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert
    
  • windows (記得用administrattor跑)
    PS> Get-ExecutionPolicy // 如果顯示 Restricted 執行以下任一行
    PS> Set-ExecutionPolicy AllSigned 
    PS> Set-ExecutionPolicy Bypass -Scope 
    
    • 設定powsershell執行權限
    PS> Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
    
    • 再安裝mkcert
    choco install mkcert
    

有了mkcert,使用方式:

~/demo-app$ mkcert localhost 127.0.0.1 ::1  # 產生憑證與私鑰
~/demo-app$ mkcert -install
Sudo password:
The local CA is now installed in the system trust store! ⚡The local CA is now installed in the Firefox and/or Chrome/Chromium trust store (requires browser restart)! 🦊

~/demo-app$ mv localhost+2.pem cert.pem
~/demo-app$ mv localhost+2-key.pem key.pem

設定 TLS

以後遇到敏感(?)的東西記得要加進git忽略清單,雖然localhost好像還好,但是萬一之後有人不小心把正式環境或測試環境的key值也推上git,那麼,我們寫的東西等於是在網路上裸奔(?。接著我們把路徑檔名設為環境變數,順便把port號也加入設定值。噢對了,warp要開啟tls的功能才能使用tls:

@@ .gitignore @@
+key.pem
+cert.pem

@@ .env @@
+HTTPS_PORT=3031
+HTTP_PORT=3030
+TLS_CERT_PATH="./cert.pem"
+TLS_KEY_PATH="./key.pem"

@@ Cargo.toml @@
+warp = { version = "0.3", features = ["tls"] }
-warp = { version = "0.3" 

在設定config檔加入讀取設定值的getter:

// web/src/config.rs
pub fn https_port() -> u16 {
    match env::var("HTTPS_PORT") {
        Ok(v) => v.parse().unwrap_or(3031),
        _ => 3031,
    }
}

pub fn http_port() -> u16 {
    match env::var("HTTP_PORT") {
        Ok(v) => v.parse().unwrap_or(3030),
        _ => 3030,
    }
}

pub fn tls_cert_path() -> String {
    match env::var("TLS_CERT_PATH") {
        Ok(v) => v,
        _ => "./cert.pem".to_string(),
    }
}

pub fn tls_key_path() -> String {
    match env::var("TLS_KEY_PATH") {
        Ok(v) => v,
        _ => "./key.pem".to_string(),
    }
}

最後我們在warp啟動時加入tls及其設定值

// web/src/bin/https.rs
async fn main() {
    config::init();
    let _guard = logger::init();
    let routers = routers::all_routers();
    warp::serve(routers)
        .tls()
        .cert_path(config::tls_cert_path())
        .key_path(config::tls_key_path())
        .run(([0, 0, 0, 0], config::https_port()))
        .await;
}

這樣就完成了,如果照著剛剛的步驟一路上來,這時候開啟前端,記得把前端的設定修改為我們https的協定及新的埠號,就可以順利執行了。

# app/src-tauri/.env 
API_BASE_URL=https://localhost:3031
# API_BASE_URL=http://localhost:3030
# app/.env 
VITE_API_BASE_URL=https://localhost:3031
# VITE_API_BASE_URL=http://localhost:3030

TLS 連線是用rustls套件,warp幫我們整理好了,不過如果要自己實作同時接http/https,可能就要使用更底層的套件hyper去寫,在axum裡也可以選擇使使用rustls

幫 tauri 加點料

還有點篇幅,剛剛抽元件抽上癮了(?),接下來幫tauri也補一下妝,我們先幫tauri加上logger:

@@ app/src-tauri/Cargo.toml @@
 [dependencies]
...
+tracing = { workspace = true }
+tracing-appender = { workspace = true }
+tracing-subscriber = { workspace = true }

把web專案的logger複製一份過來

// app/src-tauri/src/logger.rs
use tracing_appender::non_blocking::{NonBlocking, WorkerGuard};
use tracing_subscriber::{filter, layer::SubscriberExt, util::SubscriberInitExt};

pub fn init() -> WorkerGuard {
    let file_appender = tracing_appender::rolling::daily("./logs", "log");
    let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
    register_tracing(non_blocking);
    guard
}

pub fn tracing_filter() -> EnvFilter {
    let level = std::env::var("LOG_LEVEL").unwrap_or_else(|_| "debug".to_string());
    let dir1 = format!("tauri={}", level);    // 修改這個
    let dir2 = format!("app={}", level);      // 這個
    let dir3 = format!("tracing={}", level);  // 和這個
    let dir4 = format!("reqwest={}", level);  // 還有這個
        filter::EnvFilter::from_default_env()
        .add_directive(dir1.parse().unwrap())
        .add_directive(dir2.parse().unwrap())
        .add_directive(dir3.parse().unwrap())
        .add_directive(dir4.parse().unwrap())
}

pub fn register_tracing(non_blocking: NonBlocking) {
    tracing_subscriber::registry()
        .with(tracing_filter())
        .with(tracing_subscriber::fmt::layer())
        .with(tracing_subscriber::fmt::layer()
            .with_ansi(false)
            .with_writer(non_blocking))
        .init()
}
@@ app/src-tauri/src/main.rs @@
+ mod logger;
...
 fn main() {
     dotenvy::dotenv().ok();
+    let _logger = logger::init();
+    tracing::info!("Starting tauri app");

等等,這樣code的重覆部分很多耶,好像違反了clean的精神(?),我們現在專案相依圖如下:

專案依賴圖

什麼!你說tauri只有相依core,沒有依賴service?沒關係,等等就讓它依賴(X。繼續回來我們的logger,tauri讀不到寫在web裡的logger,而且tauri如果直接讀取web變成依賴web也不合理。所以我們只好把logger往service提取。以上圖而言,core是我們的核心層,service是我們的應用層,web和tauri是展示層。那麼,把logger往service提取是不是合理?有時候改code會不小心改過頭,所以適時停下來想一下,避免改過頭又要改回來

logger屬於我們程式裡的infra(基礎設施),所以放應用層好像也滿合理的(?),那我們再copy一份logger到service裡,喂不是啦,Copy大家都會了,我來講點新的東西,Builder pattern(建造者模式)可以在這個情境使用,怎麼使用呢:

不知道Builder模式的,就把自己當一個建築工 (、礦工、伐伐伐伐伐木工 ,在蓋房子先畫藍圖,選擇要蓋什麼地板,選擇要放什麼桌子,放什麼家具然後依不同的選擇就有不同的蓋法(先不管家具是室內設計還是裝潢的事),最後一口氣依設定的藍圖自動建造。總之就是可以「依需」選擇你要的東西,最後拿到一包完整的東西。

先加入套件的引用,我們在這多加一個log套件,這個套件是rust裡log的façade,很多實作日誌的套件都是基於這個介面去實作,後面我們設level可以引用它的層級enum。

@@ Cargo.toml @@
 [workspace.dependencies]
 dotenvy = { version = "0.15" }
+log = { version = "0.4" }

@@ service/Cargo.toml @@
 [dependencies]
 core = { path = "../core" }
+log = { workspace = true }
+tracing = { workspace = true }
+tracing-appender = { workspace = true }
+tracing-subscriber = { workspace = true }

實作 builder

下面在service專案裡實作:

// service/src/lib.rs
pub mod logger;

基底物件結構部分:

// service/src/logger.rs
use std::str::FromStr;
use log::Level;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};

pub struct Logger {        // Logger 我們主要使用的物件
    #[allow(dead_code)]    // 因為我們的代碼沒呼叫到它,加這個關掉提醒
    guard: WorkerGuard,    // 還記得它嗎,就是要存活才能繼續寫log的東西
}

impl Logger {              
    pub fn builder() -> LoggerBuilder { // 取得 Builder
        LoggerBuilder::default()        // 等等要實作預設版本
    }
}

pub struct LoggerBuilder {  // 這位就是我們的建築工
    level: Level,           // log 的等級
    packages: Vec<String>,  // log 需要紀錄的套件名字,這次我們用 Vec
}

impl Default for LoggerBuilder { // 給建築工一個預設版本
    fn default() -> Self {
        Self {
            level: Level::Info,
            packages: vec![            // 這是巨集,幫我們展開初始化Vec內容
                String::from("core"),
                String::from("service"),
                String::from("web"),
                String::from("https"),
                String::from("app"),
                String::from("warp"),
                String::from("tauri"),
                String::from("reqwest"),
            ],
        }
    }
}

實作Builder部分

// service/src/logger.rs
impl LoggerBuilder {              // 實作建築工的工具箱,技能記得點好點滿(?)
    pub fn add_package(mut self, package: &str) -> Self {
        self.packages.push(package.to_string());  // vec用push新增元素
        self                      //  builder 的方法特性是回傳 self
    }

    pub fn remove_package(mut self, package: &str) -> Self {
        self.packages.retain(|p| p != package);    // 移除package名稱
        self
    }

    pub fn try_set_level(mut self, level: &str) -> Self {
        if let Ok(lv) = Level::from_str(level) {    // 如果字串符合 Level 值
            self.level = lv;                        // 設成該level
        }
        // 以上 if let 的寫法等同下面 match 寫法
        // match Level::from_str(level) {
        //     Ok(lv) => {
        //         self.level = lv;
        //     }
        //     _ => {}
        // }
        self
    }

    pub fn use_env(self) -> Self {              // 從我們的設定檔讀取
        match std::env::var("LOG_LEVEL") {  
            Ok(lv) => {
                self.try_set_level(&lv)         // try_set..也是回傳self
            }
            Err(_) => self                      // 直接回傳self
        }                                       // 整個 match 是回傳值
    }

    fn filter(&self) -> EnvFilter {             // 在這裡組合 warp=debug 等字串
        let mut filter = EnvFilter::from_default_env();
        for package in &self.packages {
            let dir = format!("{}={}", package, self.level);
            filter = filter.add_directive(dir.parse().unwrap());
        }
        filter
    }

    pub fn build(self) -> Logger {        // 建造方法,其他方法約等於藍圖,點這個開工
        let file_appender = tracing_appender::rolling::daily("./logs", "log");
        let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
        tracing_subscriber::registry()
            .with(self.filter())          // 依設定的package和level組篩選器
            .with(tracing_subscriber::fmt::layer())
            .with(tracing_subscriber::fmt::layer()
                .with_ansi(false)
                .with_writer(non_blocking))
            .init();                      // 以上幾行幾乎都和之前的一樣
        Logger {                          // 只有這個改回傳 Logger 物件
            guard                         // 這個欄位是private,外部不可見
        }
    }
}

以上有幾點新的知識點可以關注一下:

  1. if let是我們只關注match符合其中某項特定值才要處理的時候可以使用。
  2. Vec<T>是可變陣列,初始化使用Vec::new(),增加元素使用.push(),指派新的一串資料可用vec!巨集。
  3. 想關掉dead_code提醒可以使用#[allow(dead_code)]
  4. builder的方法回傳值還是builder,最後build()才會產出最終物件。
  5. match本身也是表達式(這應該知道了?)
  6. for (元素) in (集合):是遍歷的語法。
  7. &str先把它當作是字串的一種,這個有點小複雜之後有機會再講。

Builder的概念大約就是 建築工.穿上護具(+防20).裝備武器(+攻20).騎上坐騎(+速50).攻擊(),就是前面的方法都只是在準備,然後累加完之後,最後再全部用上這樣。

// web/src/bin/https.rs
use service::logger::Logger;
//  ... 略
let _logger = Logger::builder()
    .use_env()
    // .add_package("hyper")        // 這裡可以各種試
    // .try_set_level("warn")       // builder的方法回傳self
    // .remove_package("hyper")     // 所以可以一直 chaining
    .build();                       // 最後再建立

另外檔名和路徑也可以參數化,有興趣的可以自己試試看,接著我們回到使用的地方作相對應的修改:

@@ web/Cargo.toml @@
-tracing-appender = { workspace = true }
-tracing-subscriber = { workspace = true }

@@ web/src/logger.rs @@
刪除檔案

@@ web/src/lib.rs @@
-mod logger;

@@ web/src/main.rs @@
+use service::logger::Logger;
+use web::{config, routers};
-use web::{config, logger, routers};
...
+    let _logger = Logger::builder().use_env().build();
-    let _guard = logger::init();

@@ app/src-tauri/Cargo.toml @@
 [dependencies]
 my-core = { path = "../../core", package = "core" }
+service = { path = "../../service", package = "service" }
...
-tracing-appender = { workspace = true }
-tracing-subscriber = { workspace = true }

@@ app/src-tauri/src/logger.rs @@
刪除檔案

@@ app/src-tauri/src/main.rs @@
-mod logger;
...
+    let _logger = service::logger::Logger::builder().use_env().build();
-    let _guard = logger::init();

Done完成!

常見問題 - 憑證不受信任

tauri 如果出現以下畫面
主機連線失敗,因為憑證不受信任者提供信任

直接用瀏覽器開 https://localhost:3031/tic_tac_toe/1 會出現不受信任的警告頁面:
Chrome出現未受信任的警告

或是在開發人員工具的console顯示以下訊息
未受信任憑證的console訊息

會出現上面的問題,就表示沒有使用mkcert -install 把我們產出來的憑證加到電腦信任的憑證清單中,所以驗不過就失敗,只要補執行一次mkcert -install就好,如果是用openssl自己產生憑證檔的話,可能要好好找一下怎麼把它加到開發電腦受信任的清單裡面。

另一個解法只適用在tauri,不適合瀏覽器環境,因為之前有提到tauri是rust語言,所以不會被瀏覽器所限制,我們直接用reqwest的略過cert驗證的設定:

@@ app/src-tauri/src/context.rs @@
-let http_client = Client::new();
+let http_client = Client::builder()
+    .danger_accept_invalid_certs(true)
+    .build()
+    .unwrap()

通常在生產環境(production environment)中,是不建議使用不受信任的憑證啦,畢竟資安的題議就在那邊,所以依需自己衡量使用吧。

本系列專案源始碼放置於 https://github.com/kenstt/demo-app


上一篇
15 rust 生命週期變數
下一篇
17 親愛的,我把rust後端搬進前端裡了 (tauri/wasm)
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言