Rust로 구현한 명령행 HTTP 서버
Rust로 명령행 HTTP 서버를 만들어보기로 했다. 간단한 명령행 HTTP 서버는 이미 많이 있지만, 직접 작성해 보면 공부도 될테고 유용하게 사용할 수도 있으며 무엇보다도 재미있을 것 같다.
시작
예전에 Rust로 에코 서버를 만들었을 때처럼 여기서도 Actix Web을 사용한다. 명령행 인자를 처리하는 데는 Clap을 사용할 것이다. 디스크에서 파일을 읽어 HTTP 응답으로 보내는 작업에는 Actix-file을, 로깅에는 env_logger를 활용할 것이다.
Cargo.toml
파일의 [dependencies]
섹션에 다음과 같이 종속성을 추가한다.
[dependencies]
actix-web = "4"
actix-files = "0"
clap = { version = "4", features = ["derive"] }
env_logger = "0"
log = "0.4"
명령행 인자 처리
서버 포트와 베이스 경로를 명령행 인자로 받아 처리하기 위해 다음과 같이 명령행 인자를 저장할 구조체를 정의한다. 별 쓸모는 없겠지만 워커 개수도 지정할 수 있다. 디폴트 포트는 3000
, 디폴트 베이스 경로는 .
(명령을 실행한 현재 디렉터리), 디폴트 워커 수는 1로 정했다.
#[derive(Default, Debug, Parser)]
#[clap(version, about = "A simple command-line static http server")]
struct Arguments {
#[clap(short, long, default_value_t = 3000)]
port: u16,
#[clap(short, long, default_value = ".")]
base: PathBuf,
#[clap(short, long, default_value_t = 1)]
workers: usize,
}
이제 다음과 같이 main
함수를 정의한다.
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::Builder::from_env(Env::default().default_filter_or("info")).init();
let args = Arguments::parse();
let port = args.port;
let workers = args.workers;
let base = args.base.canonicalize()?;
info!("Starting server on port {port}");
HttpServer::new(move || {
App::new()
.app_data(Data::new(base.clone()))
.service(handle_get)
})
.bind(("0.0.0.0", port))?
.workers(workers)
.run()
.await
}
main
함수의 첫 행은 env_logger
를 초기화하는 코드다. 별도 설정이 없으면 디폴트 로깅 레벨은 info
로 한다. Arguments::parse()
를 호출하면 명령행 인자를 파싱해 위에서 정의한 Arguments
구조체에 값을 채운다. 베이스 경로는 app_data
로 설정한다. 그리고 GET
요청을 처리할 handle_get
을 등록한다. handle_get
은 다음 섹션에서 정의할 것이다.
GET 핸들러
handle_get
함수는 HttpRequest
와 Data<PathBuf>
를 인자로 받는다. main
함수에서 app_data
로 지정한 베이스 경로가 Data<PathBuf>
를 통해 전달된다.
#[route("/{_:.*}", method = "GET")]
async fn handle_get(req: HttpRequest, data: Data<PathBuf>) -> impl Responder {
let base = data.get_ref();
let mut actual_path = base.clone();
if req.path() != "/" {
actual_path = actual_path.join(&req.path()[1..]);
}
if !actual_path.exists() {
return HttpResponse::NotFound().body("Requested path does not exist.\n");
}
if actual_path.is_dir() {
let index = actual_path.join("index.html");
if index.exists() {
let index = NamedFile::open_async(index).await.unwrap();
index.into_response(&req)
} else {
HttpResponse::Ok()
.insert_header(("Content-Type", "text/html"))
.body(dir(base, &actual_path).unwrap())
}
} else {
let file = actix_files::NamedFile::open_async(actual_path)
.await
.unwrap();
file.into_response(&req)
}
}
handle_get
함수 코드가 조금 길지만 하는 일은 간단하다.
- 먼저 HTTP 요청에서 요청 경로를 빼내 베이스 경로와 조합해 호스트의 파일시스템 경로로 바꾼다.
- 경로에 해당하는 파일 또는 디렉터리가 존재하지 않는 경우에는 HTTP-404 응답을 만들어 리턴한다.
- 실제 경로가 디렉터리인 경우에는 먼저 해당 디렉터리에
index.html
파일이 있는지 확인한다.index.html
파일이 있으면 해당 파일을 HTTP 응답으로 보내주면 된다.index.html
파일이 없는 경우에는 디렉터리의 파일 목록을 보여주는 HTML을 만들어 응답으로 보내준다.
- 이 경로가 파일인 경우는 바로 파일을 읽어 HTTP 응답으로 내려주면 된다.
파일을 HTTP 응답으로 보내는 일은 actix_files
로 간단히 처리했다. 디렉터리 목록을 표시하는 HTML을 만드는 작업은 dir
함수에서 한다. dir
함수는 다음과 같이 구현할 수 있다.
fn dir(base: &PathBuf, path: &PathBuf) -> io::Result<Vec<u8>> {
let mut buf: Vec<u8> = Vec::new();
buf.write_all(
format!(
"<html><head>{}</head><body><div class=\"path-header\">Path: {}</div><ol>",
css(),
&path.to_str().unwrap()
)
.as_bytes(),
)?;
if base != path {
buf.write_all("<li><a href=\"..\">..</a></li>".as_bytes())?;
}
for f in files_in(path)? {
if let (Ok(href), Some(name)) = (f.strip_prefix(base), f.file_name()) {
let href = href.to_str().unwrap();
let name = name.to_str().unwrap();
buf.write_all(
format!(
"<li><a href=\"/{}\">{}{}</li>",
href,
name,
if f.is_dir() { "/" } else { "" }
)
.as_bytes(),
)?;
}
}
buf.write_all(b"</ol></body><html>")?;
Ok(buf)
}
HTML 문자열이 코드와 섞여있어 코드가 아름답지는 않지만, 읽기 어려운 정도는 아니다. 주어진 경로의 파일 목록을 구해 HTML로 렌더링하는 게 주 작업이다. 결과는 Result<Vec<u8>>
으로 리턴한다. HTML 페이지에 적용할 CSS는 다음과 같이 css
함수에서 리턴하도록 했다.
fn css() -> &'static str {
r#"<style>
body {
font-family: monospace;
font-size: 1.2rem;
line-height: 1.2;
margin: 1rem;
}
.path-header {
padding: 5px 10px;
color: #fff;
background-color: #44f;
border-radius: 5px;
}
a:hover {
background-color: #ffa;
}
a:visited {
color: blue;
}
</style>"#
}
css
함수는 고정 문자열을 리턴하므로 특별히 설명할 내용은 없다. CSS를 조금 안다면 이 부분을 마음에 맞게 바꿔 디렉터리 목록을 표시하는 HTML 페이지를 예쁘게 꾸밀 수 있다.
dir
함수에서는 주어진 디렉터리 안의 파일 목록을 구하기 위해 files_in
을 호출한다. files_in
함수는 다음과 같이 구현한다.
fn files_in(dir: &PathBuf) -> io::Result<Vec<PathBuf>> {
let mut files = vec![];
for f in fs::read_dir(dir)? {
let dir_entry = f?;
files.push(dir_entry.path());
}
files.sort();
Ok(files)
}
실제 파일 목록은 fs::read_dir()
을 호출해 구한다. dir
함수에서 직접 fs::read_dir()
을 호출할 수도 있지만, 이름 순으로 정렬하기 위해 별로 함수로 빼냈다.
코드를 컴파일 후 실행해보면 잘 동작하는 것을 확인할 수 있다.
GET 이외의 HTTP 요청 처리
한 가지 아쉬운 점이 있다. 이 HTTP 서버는 GET
요청만 처리하고 있는데, HEAD
, POST
등의 요청을 날리면 HTTP-404를 리턴한다. GET
이외의 HTTP 요청에 대해서는 HTTP-405 (Method Not Allowed)를 리턴하도록 하려면 어떻게 해야 할까?
다음과 같이 GET
이외의 모든 HTTP 메서드를 처리하는 핸들러를 따로 만들어 HTTP-405를 리턴하도록 하면 된다.
#[route(
"/{_:.*}",
method = "POST",
method = "PUT",
method = "DELETE",
method = "HEAD",
method = "CONNECT",
method = "OPTIONS",
method = "TRACE",
method = "PATCH"
)]
async fn handle_other(_: HttpRequest) -> impl Responder {
HttpResponse::new(StatusCode::METHOD_NOT_ALLOWED)
.set_body(BoxBody::new("Method Not Allowed.\n"))
}
그리고 handle_other
를 등록하도록 main
함수를 수정한다.
...
HttpServer::new(move || {
App::new()
.app_data(Data::new(base.clone()))
.service(handle_get)
.service(handle_other)
})
...
이렇게 별도 핸들러를 만들어 등록해야 하는 건 Actix에서 조금 실망스러운 부분이다. GET
핸들러만 등록했다면 다른 HTTP 메서드 요청에 대해서는 HTTP-404가 아니라 HTTP-405를 리턴하는 게 맞지 않을까?
결론
Actix-web을 활용해 Rust로 간단한 명령행 웹서버를 만들어왔다. 작업의 대부분을 프레임워크와 라이브러리로 처리했더니 공부는 별로 안 된 것 같다. 그래도 잠깐 재미는 있었다.