静态博客生成器Rigos暑假开发总结

2023-07-18 12:44 | Rigos | #Rust# #Blog#

目录

    前言

    Q: 为什么要写这篇 log ?

    A: 就像做笔记一样,在学习中逐步完成一个项目,自然有很多事物需要被总结,以备日后回看,也作为同好者的参考。

    Q: 为什么要写一个静态博客生成器 ? A: 大概是这样的心路历程:

    graph LR
    A(在网上写些文字很酷) -->B(发布地点要unique) -->C(博客主题要unique) -->D(博客生成器要unique)-->E(......)
    

    由于我暂时对 Web 技术不熟悉,实现动态博客的难度比较高,所以选择编写静态博客生成器,并在编写过程中,学习 Rust 和 Web 技术栈。

    那么,让我们一步步地构建一个静态博客生成器吧!

    分析我们要实现的功能

    graph LR
    C[静态博客生成器]
        D -->D2[清理根目录]
        C -->D[维护网页根目录]
        D -->D1[从源到根目录]
        D1 -->D11[从markdown到html]
        D1 -->D12[文章索引]
        C -->E[本地服务器]
    

    从用户端入手是被建议的,在快速开发的满足感支持我们兴趣的同时,软件的框架也具有了雏形。

    读入参数并做出反应

    在Rust的标准库中,std::env::args()可以用来读取参数,我们可以写一个简单的demo演示这个过程:

    main.rs

    fn main(){
        let args: Vec<String> = env::args().collect();
        dbg!(args);
    }
    

    可以看到:args[0]是可执行文件本身的路径,而后跟着的才是参数。

    当然,我们也能使用crate.io里的clap等轮子实现该功能,这个部分会放到后面的重构篇。

    可供参考

    编写命令行处理

    我们学习hexo,预计实现以下功能:

    • help: 帮助页面
    • init: 创建新项目
    • build: 生成public目录
    • clear: 清理public目录
    • run: 运行本地服务器以预览网页
    • cbr: 一条龙运行clear, build and run

    注意:我们暂且只接受一个参数,如hexo new post这样的实现我们往后再议。

    错误处理:非法的参数

    Rust是一个安全的语言,我们要考虑错误处理。

    main.rs

    fn main(){
        let args: Vec<String> = env::args().collect();
        /*错误处理*/
        if args.len() != 2
        {
            eprintln!("我们只需要一个参数");
            std::process::exit(1);
        }
        dbg!(args);
    }
    

    使用match匹配操作

    当我们的参数合法时,我们就可以开始匹配操作了

    main.rs

    fn main(){
        // ...在错误处理之后
        match args.as_str() {
            "help" => help(),
            "build" => build(),
            "clear" => clear(),
            "run" => run(),
            "cbr" => {
                clear();
                build();
                run();
            }
            _ => {
                /*未知参数*/
                eprint!("未知参数");
                std::process::exit(1);
            }
        }
        // ...
    }
    
    //实现options
    fn help(){/*...*/}
    fn build(){/*...*/}
    fn clear(){/*...*/}
    fn run(){/*...*/}
    

    这里我们将各个操作拆开来写,是为了代码的清晰,不在main.rs内塞太多无关的代码,保证可读性与规范性。

    当然,这里的各个options也可以拆到其他文件、乃至类里实现,这里有一些参考:

    写了用户端的雏形,整个程序的结构都清晰了许多。下一节我们将开始实现一个简单的web server。

    模仿hexo s,我们也得在我们的程序中实现本地预览的功能。要实现本地预览的功能,最直接的办法就是运行一个根目录为public下的网络服务器。

    crates.io中,不但有actix_webrocket这样的重量级实现,也有tiny-http这样的轻量级实现。只需要在HTTP Server类别下查找。

    不过,我们连线程池都不需要,只需要实现最基础的HTTP协议访问文件即可。

    TCP的监听

    TCP是一个数据流传输协议,用于设备间的信息交换。Rust的标准库中提供了std::net模块,我们可以使用它来监听 TCP :

    fn run(){
        let listener = std::net::TcpListener::bind("127.0.0.1:7878").unwrap();
        
        for stream in listener.incoming() {
            println!("Connection established!");
        }
    }
    

    运行程序,并打开浏览器访问127.0.0.1:7878,我们可以看到一行行Connection established!被输出。这是浏览器向服务器请求数据的缘故。

    读取TCP数据流并响应HTTP

    HTTP协议的请求格式如下:

    Method Request-URI HTTP-Version CRLF
    headers CRLF
    message-body
    

    CRLF\r\n作为行间分隔。

    作为静态网页的Server,我们要面对的Method主要是GET,而GET请求没有message-body。同时,headers我们暂且也不需要理会。所以,我们只需要关注第一行,请求行。

    下面是一个我们将面临的典型请求:

    GET / HTTP/1.1
    

    作为静态网页的Server,我们只需要关注第二个参数Request-URI,用户请求什么文件,我们就返回什么文件的内容。

    这里采用了std::thread提供的多线程,其实大可不必

    fn run()中,我们读取stream

    for stream in listener.incoming() {
        let stream = stream.unwrap();
         std::thread::spawn(|| {
                handle_connection(stream);
        });
    }
    

    我们将处理连接的函数独立出来:

    fn handle_connection(mut stream: TcpStream){
        let buf_reader = BufReader::new(&mut stream);
    	let request_line = buf_reader.lines().next().unwrap().unwrap();
    }
    

    这里的request_line就是HTTP请求的第一行,也就是我们真正需要关注的那一行。

    在我们响应请求时,我们需要关注两点:status_line和返回内容。

    前置条件:我们要在程序目录下放置index.html404.html

    fn handle_connection中:

    let (status_line, filename) = match &request_line[..] {
            "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "index.html"),
            _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };
    
    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();
    let response = format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
    stream.write_all(response.as_bytes()).unwrap();
    

    上面的代码做了两件事:

    • 解析request_line并选择不同答复内容
    • 拼接符合HTTP协议的返回内容并答复

    运行程序,手动修改浏览器的url并请求网页,我们会发现:除却127.0.0.1:7878的请求可以被OK答复并返回index.html的内容,其他诸如127.0.0.1:7878/index.html等都会被答复404并返回404.html的内容。

    令人欣慰的是,我们成功响应了HTTP的请求;令人失望的是,我们答复的规则显然不能满足我们的需求。

    从本地UTF-8文本文件读取内容并返回

    我们的目的是:读取HTTP请求的uri,并返回与之相匹配的文件内容:

    • 对于/的请求,返回index.html
    • 对于文件的请求,如果存在则返回内容,如果不存在则返回404.html

    除此之外,我们还要面对HTTP的对 Unicode 的支持问题,即我们得考虑到我们的文件名可能包含非ASCII遭到百分转义的可能。

    这里我们采用percent_encoding库处理文件,具体例子在docs.rs 可查。

    在fn handle_connection中:

    const PUBLIC_DIR: &str = "public";
    //...
    /*Tackle HTTP 
    Read the uri of the HTTP request and return the matching file content:
    For requests to /, index.html is returned
    For file requests, return the content if it exists, or return 404.html if it does not exist
    */
    fn handle_connection(mut stream: std::net::TcpStream){
        let buf_reader = std::io::BufReader::new(&mut stream);
        let request_line = std::io::BufRead::lines(buf_reader).next().unwrap().unwrap();
        let (status_line, filename) = match &request_line[..] {
            "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "/index.html".to_string()),
            _ => {
                let url_phase: Vec<&str> = request_line[..].split(' ').collect();
                let url_raw = url_phase[1]; /*get uri */
                let iter = percent_encoding::percent_decode(url_raw.as_bytes());
                let decoded = iter.decode_utf8().unwrap().to_string();
                let path_str = format!("{}{}", utils::PUBLIC_DIR, decoded);
                let path_str = path_str.as_str();
                if std::path::Path::exists(std::path::Path::new(path_str)){
                    ("HTTP/1.1 200 OK", decoded)
                }
                else {
                    crate::utils::info(utils::Info::RUN, "cannot found", &decoded);
                    ("HTTP/1.1 404 NOT FOUND", "/404.html".to_string())
                }
            }
        };
    
        let contents = std::fs::read_to_string(format!("{}{}", utils::PUBLIC_DIR, filename).as_str()).unwrap();
        let length = contents.len();
        let response = format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
        std::io::Write::write_all(&mut stream, response.as_bytes()).unwrap();
    }
    

    由此,我们实现了我们对本地Web Server的基本需求,即路由,从文件中读取UTF-8字符流并拼接为HTTP协议的response并响应。

    但是,我们不得不承认,我们的web server 还是有很多重要的功能没有实现。比如,它只能读取UTF-8格式的文本文件,而不能读取非文本类型(base64另当别论)。

    再比如,对标一般的web_server,我们没有正则表达路由,没有线程池,也没有......重复造轮子的事情对项目来说是没有必要性的,然而对在学习的我们意义重大,我将会在一个独立的篇章内更新该内容。

    下一节我们会运用pulldown_cmark库,实现从markdownhtml的转换,然后使用tera这样的类Jinja2/Django库进行渲染,同时创建索引,用css和js丰富我们的静态网站。

    Fin.