iT邦幫忙

2023 iThome 鐵人賽

DAY 9
0
Software Development

前端? 後端? 摻在一起做成全端就好了系列 第 9

09 我的rust環境我決定 Example, Logger, Env

  • 分享至 

  • xImage
  •  

繼續往下之前 ...

終於要開始寫API了嗎,可是我們要用的warp竟然不像RocketActix有專屬的網頁,也不像axum的代碼文件寫的很完整,warp的文件顯得陽春許多,這時候我們還是直接看範例比較快,所以只好到Example資料夾,最好是下載一份一邊對照著跑,想試試的朋友可以clone下來自己的環境跑。

往下繼續寫API之前我們先扎好馬步,點一下前置技能,有印象我們在第7篇跑axum, warp, salvo三個框架用的指令不太一樣嗎,雖然都是執行框架各自提供的helloworld代碼,但因為專案架構不同,所以執行方式也略為不同。往後我們要常常 抄襲 參考別人的範例代碼,所以先來了解一下怎麼看別人的example,下次要驗證別人提供的代碼就可以git clone下來,然後知道怎麼跑起來。我們現在還是小朋友在學習,等長大了以後,希望大家也可以取之社群,回饋社群,順便幫忙修一下bug,貢獻代碼,或是多參與社群活動 XDDD。

Example 範例程式碼

我們上次看過Rust的文檔註釋,可以使用markdown語法來寫文件說明,也可以用代碼區塊來demo案例,順便還可以測試,但只侷限在該fn。有時候我們需要一個比較完整一點的範例,rust有提供寫examples範例的方式,讓我們可以快速POC,或是提供範例給其他人參考。我們看一下Cargo package 的資料夾結構

.
├── Cargo.toml
├── src/
│   ├── lib.rs
│   ├── main.rs
├── examples/
│   ├── simple.rs
└── tests/
    └── some-integration-tests.rs

以上是簡化版的,原來就像看到的這麼簡單(?),我們只要開一個examples的資料夾,把要跑的檔案放裡面就可以,以下是warp放examples裡的hello.rs

// ~/warp/examples/hello.rs
#![deny(warnings)]
use warp::Filter;

#[tokio::main]
async fn main() {
    // Match any request and return hello world!
    let routes = warp::any().map(|| "Hello, World!");

    warp::serve(routes).run(([127, 0, 0, 1], 3030)).await;
}

有一個main是程式進入點這個我們知道,那怎麼讓它跑起來呢:

~/warp$ cargo run --example hello
PS warp> cargo run --example hello

我們之前學過cargo run-p 是選擇 package,--bin是選擇執行檔,而--example則是選擇example。先來來試試看,因為我們的專案是用工作空間workspace,而--example是作用域是在package裡,所以我們在core裡開一個examples的資料夾,放上demo.rs

core/examples/demo.rs
fn main() {
    println!("Hello, this is example message from core package");
}

再執行:

~/demo-app$ cargo run -p core --example demo

執行範例demo結果

如果只是自己開啟的package專案,沒有使用工作空間workspace的話,就不用加上-p的參數

但我們的專案demo-app是開workspace,加example到子項裡好像有點不方便呢,我們可以參照axum的workspace的使用方式,先看axum的Cargo.toml檔案:

[workspace]
members = ["axum", "axum-*", "examples/*"]
# Only check / build main crates by default (check all with `--workspace`)
default-members = ["axum", "axum-*"]
# Example has been deleted, but README.md remains
exclude = ["examples/async-graphql"]
resolver = "2"

從這裡我們可以看到,members的設定可以用萬用字元比對,詳細可以參考Cargo Guide的說明,members是使用glob去比對檔名,所以如果我們同時要包含多個檔名,可以用這種方式去簡化設定。

再來是在workspace裡開exampes資料夾,可以把裡面的專案當一般的專案處理,借鏡一下axum專案的資料夾開法,可以看到examples裡又開了好多資料夾,每個資料夾都是獨立的一個package,有各自的Cargo.toml檔案:

axum example資料夾結構

看一下Cargo.toml的內容

hello world example in axum

注意到這裡有個name屬性設為example-hello-world,我們想跑這個範例,就要用package的方式執行:

~/axum$ cargo run -p example-hello-world

之前提過 -p 指的不是資料夾名字,而是我們設定該package(crate)的name屬性。

好了看完別人的,我們也模仿來自己試一下,首先建立example資料夾,再建立一個demo package:

~/demo-app$ cargo new examples/demo

開啟examples/demo專案的資料夾結構

這時cargo幫我們建好一個專案,打開examples/demo/Cargo.toml來看看:

# examples/demo/Cargo.toml
[package]
name = "demo"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

還記得我們是在workspace裡嗎,所以專案根目錄的工作空間要設定才能參照的到:

@@ Cargo.toml @@
 members = [
 ...    
+    "examples/*",
 ]

預設名字是demo,我們跑看看:

~/demo-app$ cargo run -p demo

執行example-demo專案結果

這樣,有沒有對cargo的指令越來越熟悉了呢。

做到這裡,要加入git的時候,又出況狀了。

windows:
git無法加入檔案進stage錯誤-windows
debian:
git無法加入檔案進stage錯誤-debian

查了一下原來是剛剛我們跑的cargo new雞婆 貼心地幫我們幫我們加了git的 repo,先移除多出來的git庫:

windows:

PS demo-app> rm -Recurse -Force .\examples\demo\.git\

linux or mac:

~/demo-app$ rm -rf ./examples/demo/.git

刪掉後就可以順利的推到git裡,沒有問題。

不過,不會以後都這麼麻煩吧,其實查一下文件就知道cargo new 有參數 --vcs none可以使用,記得要把字拼對唷

~/demo-app$ cargo new examples/demo --vcs none

一般專案如果架構比較大的話,通常我們會傾像用比較好的命名慣例,來增加可維護性,所以我們把demo專案改命名加prefix example-

@@ examples/demo/Cargo.toml @@
 [package]
+name = "example-demo"
-name = "demo"
 version = "0.1.0"

之後要執行就使用:

~/demo-app$ cargo run -p example-demo

Tracing 日誌檔案

之前在hello warp裡跑起來的程式只有Running,沒有其他訊息,這樣我們很難知道程式的狀態,通常這時候會把程式執行的東西輸出到日誌檔(Log),以便追蹤(tracing),就依warp的tracing example 抄上來:

// web/src/main.rs
use warp::Filter;

#[tokio::main]
async fn main() {
    let filter = std::env::var("RUST_LOG")
        .unwrap_or_else(|_| "tracing=info,warp=debug".to_owned());

    tracing_subscriber::fmt()
        .with_env_filter(filter)
        .with_span_events(FmtSpan::CLOSE)
        .init();

    let routes = warp::any().map(|| "Hello, World!");

    warp::serve(routes).run(([127, 0, 0, 1], 3030)).await;
}

記得把run.shrun.ps1裡的web跑起來,然後就開啟了解任(錯)務(誤)的旅程了:

加入tracing_subscriber後warp服務啟動的錯誤訊息

沒有tracing_subscriber的參考,去crates.io搜尋一下:

在crate.io搜尋tracing_subscriber

加入Cargo.toml:

@@ Cargo.toml @@
 rand = { version = "0.8" }
 thiserror = { version = "1.0" }
+tracing-subscriber = { version = "0.3" }
 tokio = { version = "1.29", features = ["full"] }
 warp = { version = "0.3"}

@@ web/Cargo.toml @@
 tokio = { workspace = true }
+tracing-subscriber = { workspace = true }
 warp = { workspace = true }

注意程式裡是_(underscore)而不是-(hyphen),但在package裡是-不是_

加入tracing_subscriber後的執行結果

依提示加在引用:

@@ web/src/main.rs @@
+use tracing_subscriber::fmt::format::FmtSpan;
 use warp::Filter;

現在訊息變成:
再加入FmtSpan引用後的執行結果

這裡說找不到with_env_filter 這個方法,可是我們明明把crate引入了呀,這時候可以到tracing-subscriber的文檔查找,插尋with_env_filter如下:

在tracing文件中搜尋with_env_filter

好像有很多個,我們先點第一個看看:

tracing文件中的with_env_filter方法

底下有一句話清楚寫著:「Available on crate feature env-filter only.」,很直白了,就是說這個fn是env-filter這個feature(功能)才有,我們再點網頁最上方中間小小的「Feature flags」:

tracing-subscriber的features清單-1
tracing-subscriber的features清單-2

原來分這麼多功能,這就是之前稍微提到的crate可以分別以功能開關來決定要不要引入,到Cargo.toml補上該Feature:

rust在同一個crate還可以設定feature功能註記,來決定是否要編譯該功能,

@@ Cargo.toml @@
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
-tracing-subscriber = { version = "0.3" }

加完後:

正確補上tracing_subscriber features後執行結果

因為這次修改是加在專案root資料夾裡,不在我們watch的清單中,會發生watch console沒有更新的情境,建議可以Ctrl + C中斷再重新執行run

成功執行了,這次有多一行INFO的資訊,看過LOG檔的話應該不陌生,接下來我們回顧一下這段程式碼做了什麼:

let filter = std::env::var("RUST_LOG")
        .unwrap_or_else(|_| "tracing=info,warp=debug".to_owned());

這裡是在說我們要篩選出哪些log進行輸出(至Console或檔案)。unwrap大家知道是取得Option的值,那麼unwrap_or_else就是在說如果取不到值要做後面閉包(Closure)裡面的事囉。好的看一下內容,tracing篩info,而warp篩debug。這裡有一些tracing常見的level,不過文件看了一堆比大小的範例,但好像不是很完整看不出所以然:
tracing文件裡的Level說明

我們直接點右邊的source進去看程式碼,之前才說明要怎麼使用rust產生的文件格式,我們立馬就能派上用場了:
tracing文件裡的Level原始碼

程式碼裡其實很清楚的說明每個層級,基本上我們可以依使用情境去輸出log,要知道log檔的爆增是很可怕的,雖然現在的硬碟空間不貴,但也抵擋不了log的資料量。記得把重要資訊輸出就好,輸出的訊息儘量是能幫助我們在排查問題所使用的。

一般在各語言設定logger的層級都是以上,比如設為warn就是指warn(含)及以上層級(以上例為Error)。

好了接下來我們繼續模仿warp的範例輸出log看看,調整我們的hello路由如下:

// web/src/main.rs
// 把 hello 拆出為單獨的 router fn
let hello = warp::path("hello")                // 設定 http rest api 路由
    .and(warp::get())                          // 在這使用 get
    .map(|| {                                  // http 請求進來要處理的 fn
        tracing::info!("saying hello...");     // 紀錄到 log 檔
        "Hello, World!"                        // API 回傳值,這裡是字串
    })
    .with(warp::trace::named("hello"));

let routes = hello
    .with(warp::trace::request());

未引用tracing的錯誤訊息

這裡又說沒有tracing的模組,我們看一下:

crates.io查詢tracing的結果

到Cargo.toml設定補上:

@@ Cargo.toml @@
 thiserror = { version = "1.0" }
+tracing = { version = "0.1" }
 tracing-subscriber = { version = "0.3", features = ["env-filter"] }

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

安裝tracing後warp執行結果

補上好了是好了,不過要看一下hello裡面的tracing有沒有work,到網址輸入http://localhost:3030/hello 開看看,便可以看到LOG的確有增加

log輸出的終端機畫面

但現在log檔只有輸出到終端介面畫面,我們想要輸出到檔案怎麼辦,直接google「tracing to file rust」,第一個就是tracing-appender,直接裝起來

@@ Cargo.toml @@
 tracing = { version = "0.1" }
+tracing-appender = { version = "0.2" }
 tracing-subscriber = { version = "0.3", features = ["env-filter"] }

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

官方範例說的使用方式,好像滿簡單的,不過其中的blockingnon_blocking是什麼呢?在多執行緒裡面,我們習慣開non_blocking(非阻塞式?)的thread(執行緒)去跑,避免我們的程式hang(停)住,(白話講blocking就是有人打API進來,我們要等LOG寫完才吐Response回去,但寫log其實是另一件事,不應該影響回傳資料給前端使用者的功能),先複製範例貼上web/src/main.rs,再修改一下範例的檔名路徑,把hourly改daily:

+let file_appender = tracing_appender::rolling::daily("./logs", "log");
+let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);

 tracing_subscriber::fmt()
     .with_env_filter(filter)
     .with_span_events(FmtSpan::CLOSE)
+    .with_writer(non_blocking)
     .init();

這裡可以看到原先的tracing_subscriber只加了一行,其實在這裡就是把寫入檔案的writer注入,類似DI的概念。

這時候執行結果的終端畫面:

設好log檔案後執行結果-終端

找一下資料夾裡有多出log檔案:

設好log檔案後執行結果-檔案

Log檔正確的產生了,可是之前console的訊息怎麼沒了,而且看檔案內容好像怪怪的?多了一堆奇怪的符號,這個ESC其實是為了設定ANSI色碼所使用的,是要在terminal底下標顏色用的,之後我們會有機會玩到。

看一下文件,我們要同時把log輸出至終端畫面及檔案,似乎要用不同的layer去組合,所以我們用registry改寫subscriber:

use tracing_subscriber::prelude::*;               // 加入引用
// ...略
#[tokio::main]
async fn main() {
    // ...略
    tracing_subscriber::registry()
        .with(tracing_subscriber::fmt::layer())  // 輸出至終端
        .with(tracing_subscriber::fmt::layer()   
            .with_writer(non_blocking))          // 輸出至檔案
        .init();

另外找到了tracing裡的ansi設定,試著把輸出到log檔的ansi關掉:

tracing_subscriber::registry()
    .with(tracing_subscriber::fmt::layer())
    .with(tracing_subscriber::fmt::layer()
        .with_ansi(false)                     // 加上這行,不輸出ANSI色碼
        .with_writer(non_blocking))
    .init();

試打一下API,跳一堆訊息出來,也太多,有很多好像跟我們沒關係。

呼叫API時的終端LOG輸出

原來是剛剛在改成layer設定的時候把filter拿掉了,所以連帶出現一堆hyper的log訊息,hyper是rust裡處理http協定的套件,axum等底層的http連線也都是用hyper,不過以我們開發而言似乎不需要看到這麼低階的訊息,那要怎麼過濾出我們要的訊息再紀錄呢,還好這時副駕即時救援,給了我們這個fn:

// web/src/main.rs
use tracing_subscriber::EnvFilter;

pub fn tracing_filter() -> EnvFilter {
    EnvFilter::from_default_env()
        .add_directive("warp=debug".parse().unwrap())
        .add_directive("web=debug".parse().unwrap())
        .add_directive("tracing=debug".parse().unwrap())
}

直接引用看看:

 tracing_subscriber::registry()
+    .with(tracing_filter())
     .with(tracing_subscriber::fmt::layer())
     .with(tracing_subscriber::fmt::layer()

看起來螢幕輸出和檔案輸出都OK了,只是檔案裡warp產的ansi消不掉,但hyper產出來的log沒有ansi,看起來可能是warp的問題,目前找了半天沒找到解法,影響也不大的情況下,先跳過這部分。但是現在main被tracing佔了很大版面,我們把日誌相關的部分抽到logger.rs檔,讓我們的main比較乾淨:

// web/src/main.rs
mod logger;             // 抽出去的檔案

#[tokio::main]
async fn main() {
    let file_appender = tracing_appender::rolling::daily("./logs", "log");
    let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
    logger::register_tracing(non_blocking);

    // routers
}
// web/src/logger.rs
use tracing_appender::non_blocking::NonBlocking;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};

pub fn tracing_filter() -> EnvFilter {
    EnvFilter::from_default_env()
        .add_directive("warp=debug".parse().unwrap())
        .add_directive("web=debug".parse().unwrap())
        .add_directive("tracing=debug".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()
}

ENV 環境變數

剛剛的日誌檔雖然幾經波折之下設定好了,不過好像哪裡怪怪的?我們都把log等級的debug寫死(hard code)在程式碼裡,萬一要發佈到線上環境,到時候需要調整成info或以上的等級的時候怎麼辦?嗯,要把這裡的debug改成用設定的方式,查一下rust有沒有環境變數的設定,google一下:

透過google 查詢 rust dotenv的結果

點進去看看:

rust dotenv套件在crates的說明

維護竟然是4年前,感覺用起來心不太安(?),我們進去github看看,看看有沒有人提出問題來:

rust dotenv套件在github的issue頁面

第二個Issue看起來很醒目,點進去看看:

github關於dotenv套件維護的留言

這裡有熱心的朋友回答,原來可以用dotenvy,查看看:

rust dotenvy套件在crates的說明

感覺比較有在維護,下載量也滿多的,就用這個來設環境吧,看一下文件怎麼說:

rust doc of dotenvy

呃,好啦我知道你是因為dotenv作者沒繼續維護而fork分出來的,可是我也沒用過dotenv啊,這樣叫我怎麼開始 複製貼上 開發呀。可是回去看dotenv的文件可能也不是很正確,好吧我們去看看dotenvy有沒有提供我們剛剛一開始提到的examples

有沒有發現看example的重要性了 XDDD

dotenvy example code

雖然很簡單,但還是可以看得出來基本上的用法,一般正式環境會直接讀取作業系統的環境變數,而開發時才會讀.env檔,我們一樣先加套件:

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

@@ web/Cargo.toml @@
+dotenvy = { workspace = true }
 tokio = { workspace = true }

新增config模組(mod):

// web/src/main.rs
mod config;
// web/src/config.rs
use std::env;

pub const ENV_LOG_LEVEL: &str = "LOG_LEVEL";
pub const ENV_APP_ENV: &str = "APP_ENV";

pub fn init() {
    let env = match env::var(ENV_APP_ENV) { // 取得環境變數的環境
        Ok(v) => v,
        _ => "dev".to_string(),
    };

    if env.starts_with("prod") { return; }  // 正式區則離開不讀.env檔

    match dotenvy::dotenv() {               // 讀取env檔案
        Ok(_) => println!(".env read successfully "),
        Err(e) => println!("Could not load .env file: {e}"),
    };
}
  • const是常數,慣例是大寫加底線_,遇到可能會常用的字串請設為常數,不要變成magic number(?)

在main裡加上init初始化:

 #[tokio::main]
 async fn main() {
+    config::init();
     let file_appender = tracing_appender::rolling::daily("./logs", "log");

.env檔及example.env

# .env
APP_ENV=dev
LOG_LEVEL=debug
# example.env
APP_ENV=dev
LOG_LEVEL=debug

記得要把.env先加到.gitignore裡,避免不小心把連線字串推上git,就哭哭了。通常因為我們.env可能會含敏感資訊不應上版控,所以會再附上example.env,提供設定的範例(記得未來加連線字串的時候在example要放假資料,別貼錯)。

@@ .gitignore @@
+.env

環境變數設好了,那麼來改我們的logger吧:

use crate::config::ENV_LOG_LEVEL;

pub fn tracing_filter() -> EnvFilter {
    let level = std::env::var(ENV_LOG_LEVEL).unwrap_or_else(|_| "info".to_string());
    let dir1 = format!("warp={}", level);
    let dir2 = format!("web={}", level);
    let dir3 = format!("tracing={}", level);
    EnvFilter::from_default_env()
        .add_directive(dir1.parse().unwrap())
        .add_directive(dir2.parse().unwrap())
        .add_directive(dir3.parse().unwrap())
}
  • format!也是巨集,跟之前用的println!很像,差別在於format!是把字串組合的結果回傳,而非打印在命令列。

試一下改變.env檔案的設定再重啟,不同的層級會顯示不同的訊息:

Info層級訊息:
設定層級為info的輸出

Trace層級訊息:
https://ithelp.ithome.com.tw/upload/images/20230923/20162521KWtWBHI4BC.png

好了,沒想到準備工作就這麼漫長,原本想要寫REST API的部分只能留待下一篇了。

參考資料

本系列原始碼:https://github.com/kenstt/demo-app


上一篇
08 說好的 rust CRUD 呢?怎麼還沒好
下一篇
10 所以 rust 的 rest api 終於完成了
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言