28. 乾貨系列從零用Rust編寫正反向代理,專案紀錄檔的原始碼實現

2023-11-17 09:00:15

wmproxy

wmproxy已用Rust實現http/https代理, socks5代理, 反向代理, 靜態檔案伺服器,四層TCP/UDP轉發,內網穿透,後續將實現websocket代理等,會將實現過程分享出來,感興趣的可以一起造個輪子

專案地址

國內: https://gitee.com/tickbh/wmproxy

github: https://github.com/tickbh/wmproxy

專案中的使用

目前需要將每條請求資料進入的紀錄檔,如access_log,或者專案相關的錯誤紀錄檔error_log記錄下來。

以下將介紹專案中如何進行記錄並格式化紀錄檔的

檔案設定

當前需要根據專案中的設定進行相應的初始化,需要用程式碼將當前的設定進行初始化。

[http]
# 存取列表的寫入檔案及格式
access_log = "access main debug"
# 錯誤列表的寫入檔案及格式,錯誤的第二個是錯誤等級。
error_log = "error debug"

# 紀錄檔格式
[http.log_format]
main = "{d(%Y-%m-%d %H:%M:%S)} {client_ip} {l} {url} path:{path} query:{query} host:{host} status: {status} {up_status} referer: {referer} user_agent: {user_agent} cookie: {cookie}"

[http.log_names]
access = "logs/access.log trace"
error = "logs/error.log"
default = "logs/default.log"

紀錄檔的組成部分

紀錄檔的組成分為三個部分

  1. access_log及error_log的寫入檔案、格式及紀錄檔等級
  2. log_names紀錄檔的別名,包含紀錄檔檔案及可能包含紀錄檔等級,沒有等級預設Info
  3. 紀錄檔格式,記錄紀錄檔攜帶的相關訊息,如存取的使用者端ip{client_ip}或者存取Url{url}等,遵循Rust的列印結構,用{}裡面包含要列印的相關訊息

以下是存取資訊列印的資料

2023-11-16 15:02:00 127.0.0.1:55922 INFO http://127.0.0.1:82/root/?aaa=1 path:/root/ query:aaa=1 host:127.0.0.1 status: ???  referer:  user_agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0 cookie: 

注意點

因為access_logerror_log可以在[http]的層級下任意設定,第一步我們需要收集到合適的log_names進行初始化,我們用的是一個HashMap做鍵值對,防止重複:

/// http.rs
pub fn get_log_names(&self, names: &mut HashMap<String, String>) {
    self.comm.get_log_names(names);
    for s in &self.server {
        s.get_log_names(names);
    }
}
/// server.rs
pub fn get_log_names(&self, names: &mut HashMap<String, String>)  {
    self.comm.get_log_names(names);
    for l in &self.location {
        l.get_log_names(names);
    }
}
/// common.rs
pub fn get_log_names(&self, names: &mut HashMap<String, String>)  {
    for val in &self.log_names         {
        if !names.contains_key(val.0) {
            names.insert(val.0.clone(), val.1.clone());
        }
    }
}

收集好正確的log檔案後,我們需要對其初始化或者過載入,其中重新載入需要擁有上次初始化的Handle那麼我們需對基進行儲存:

lazy_static! {
    /// 用靜態變數儲存log4rs的Handle
    static ref LOG4RS_HANDLE: Mutex<Option<log4rs::Handle>> = Mutex::new(None);
}

/// 嘗試初始化, 如果已初始化則重新載入
pub fn try_init_log(option: &ConfigOption) {
    let log_names = option.get_log_names();
    let mut log_config = log4rs::config::Config::builder();
    let mut root = Root::builder();
    for (name, path) in log_names {
        let (path, level) = {
            let vals: Vec<&str> = path.split(' ').collect();
            if vals.len() == 1 {
                (path, Level::Info)
            } else {
                (
                    vals[0].to_string(),
                    Level::from_str(vals[1]).ok().unwrap_or(Level::Info),
                )
            }
        };
        // 設定預設的匹配型別列印時間資訊
        let parttern =
            log4rs::encode::pattern::PatternEncoder::new("{d(%Y-%m-%d %H:%M:%S)} {m}{n}");
        let appender = FileAppender::builder()
            .encoder(Box::new(parttern))
            .build(path)
            .unwrap();
        if name == "default" {
            root = root.appender(name.clone());
        }
        log_config =
            log_config.appender(Appender::builder().build(name.clone(), Box::new(appender)));
        log_config = log_config.logger(
            Logger::builder()
                .appender(name.clone())
                // 當前target不在輸出到stdout中
                .additive(false)
                .build(name.clone(), level.to_level_filter()),
        );
    }

    if !option.disable_stdout {
        let stdout: ConsoleAppender = ConsoleAppender::builder().build();
        log_config = log_config.appender(Appender::builder().build("stdout", Box::new(stdout)));
        root = root.appender("stdout");
    }

    let log_config = log_config.build(root.build(LevelFilter::Info)).unwrap();
    // 檢查靜態變數中是否存在handle可能在多執行緒中,需加鎖
    if LOG4RS_HANDLE.lock().unwrap().is_some() {
        LOG4RS_HANDLE
            .lock()
            .unwrap()
            .as_mut()
            .unwrap()
            .set_config(log_config);
    } else {
        let handle = log4rs::init_config(log_config).unwrap();
        *LOG4RS_HANDLE.lock().unwrap() = Some(handle);
    }
}

我們需要在初始化引數的時候在重新呼叫該函數,保證新的紀錄檔資訊能正確的初始化。

下面是將存取紀錄檔的資料列印下來:

/// 記錄HTTP的存取資料並將其格式化
pub fn log_acess(
    log_formats: &HashMap<String, String>,
    access: &Option<ConfigLog>,
    req: &Request<RecvStream>,
) {
    if let Some(access) = access {
        if let Some(formats) = log_formats.get(&access.format) {
            // 需要先判斷是否該紀錄檔已開啟, 如果未開啟直接寫入將浪費效能
            if log_enabled!(target: &access.name, access.level) {
                // 將format轉化成pattern會有相當的效能損失, 此處快取pattern結果
                let pw = FORMAT_PATTERN_CACHE.with(|m| {
                    if !m.borrow().contains_key(&**formats) {
                        let p = PatternEncoder::new(formats);
                        m.borrow_mut()
                            .insert(Box::leak(formats.clone().into_boxed_str()), Arc::new(p));
                    }
                    m.borrow()[&**formats].clone()
                });

                // 將其轉化成Record然後進行encode
                let record = ProxyRecord::new_req(Record::builder().level(Level::Info).build(), req);
                let mut buf = vec![];
                pw.encode(&mut SimpleWriter(&mut buf), &record).unwrap();
                log::info!(target: &access.name, "{}", String::from_utf8_lossy(&buf[..]))
            }
        }
    }
}

其中快取pattern的結果效能損失的要求不高,但需要存取速度要高:

thread_local! {
    static FORMAT_PATTERN_CACHE: RefCell<HashMap<&'static str, Arc<PatternEncoder>>> = RefCell::new(HashMap::new());
}

加RefCell是因為預設是不可變的,如果有新的資料,需要將其變成可變資料,從而進行快取。
HashMap中的key用&'static str是可以不必要將一些資料轉化成String避免不必要的拷貝。
如果將String變成&'static str那麼意味著這段記憶體將會變成不可回收的資料,意味著記憶體漏失,所以我們需要用Box::leak

Box::leak(formats.clone().into_boxed_str()

HashMap中的value中用Arc,因為我們是一個全部變數,我們要儘量的減少其存取的時間,但是我們又需要持有Pattern,所以我們在這裡應用了一個參照計數Arc,拷貝的時候僅僅消耗加減參照計數。

m.borrow()[&**formats].clone()

分析Pattern

以下程式碼大部分來自log4rs

pub struct PatternEncoder {
    chunks: Vec<Chunk>,
    pattern: String,
}

首先會將一個字串拆成若干個Chunk資訊,

enum Chunk {
    Text(String),
    Formatted {
        chunk: FormattedChunk,
        params: Parameters,
    },
    Error(String),
}

以下用date: {d(%Y-%m-%d %H:%M:%S)} url: {url}{n}做示範,我們在解析這字串的時候將會得到以下五個部分:

  1. date: 這是一個常數資料也就是Text將原樣輸出
  2. {d(%Y-%m-%d %H:%M:%S)}將會轉化成Formatted::FormattedChunk::Time(String, Timezone),然後根據陣列遍歷,若為這個,那邊將寫入時間資訊2023-11-16 15:02:00
  3. url:常數,原樣輸出
  4. {url}將會轉成FormattedChunk::Url如果存在Request將從其中獲取url地址,若沒有則輸出???
  5. {N}將會轉成FormattedChunk::Newline,將會根據平臺輸出換行符。

此時我們的輸出只需要進行一次遍歷即可O(n),也不必replace等造成字串的資料重排導致時間的變化。

此外還有額外引數:

  • {client_ip} 使用者端IP
  • {url} 存取Url
  • {path} 存取路徑,如/user/login
  • {query} 存取請求引數,如user=wmproxy&password=wmproxy
  • {host} 存取Host
  • {referer} 存取的referer
  • {user_agent} 使用者端Agent
  • {cookie} 當前存取的cookie

小結

紀錄檔在程式中必不可少,那麼需要儘可能的高效,所以儘可能的提升紀錄檔的效率是必須處理的一環。

點選 [關注][在看][點贊] 是對作者最大的支援