http 加上 s 不是很多(複數)的意思,加上的s是 Secure的意思,詳見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!");
}
雖然也可以把http
和https
寫在同一個執行檔裡,但是會增加一些些複雜度,我們先用另一個檔案的方式,後面有機會再寫合併。
這時候我們要修改我們的批次檔,大家如果跟到現在應該很習慣享受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跑起來,就可以開始開發了:
先把原本web main
的東西copy過來,一項一項來,免得問題太多一次會措手不及,我們先加config
:
// web/src/bin/https.rs
fn main() {
config::init();
println!("Hello, world!");
}
在這裡我們沒辦法引用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:
// 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
}
執行成功畫面:
讀取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);
再來是把核心的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分呢,可以很方便地幫我們建立憑證,並加到系統信任清單裡。依官方說明,整理各系統安裝方式如下:
$ brew install mkcert
$ brew install nss # if you use Firefox
$ sudo apt install libnss3-tools
-or-
$ sudo yum install nss-tools
-or-
$ sudo pacman -S nss
-or-
$ sudo zypper install mozilla-nss-tools
$ 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
PS> Get-ExecutionPolicy // 如果顯示 Restricted 執行以下任一行
PS> Set-ExecutionPolicy AllSigned
PS> Set-ExecutionPolicy Bypass -Scope
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'))
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
以後遇到敏感(?)的東西記得要加進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加上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 }
下面在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,外部不可見
}
}
}
以上有幾點新的知識點可以關注一下:
Vec::new()
,增加元素使用.push()
,指派新的一串資料可用vec!巨集。#[allow(dead_code)]
builder
,最後build()
才會產出最終物件。match
本身也是表達式(這應該知道了?)&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 會出現不受信任的警告頁面:
或是在開發人員工具的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