静态博客生成器Rigos暑假开发总结
目录
前言
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_web和rocket这样的重量级实现,也有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.html
和404.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
库,实现从markdown
到html
的转换,然后使用tera这样的类Jinja2/Django
库进行渲染,同时创建索引,用css和js丰富我们的静态网站。
Fin.