Rust로 구현한 명령행 HTTP 서버

내 이 세상 도처에서 쉴 곳을 찾아보았으나, 마침내 찾아낸, 컴퓨터가 있는 구석방보다 나은 곳은 없더라.

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 함수는 HttpRequestData<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 함수 코드가 조금 길지만 하는 일은 간단하다.

  1. 먼저 HTTP 요청에서 요청 경로를 빼내 베이스 경로와 조합해 호스트의 파일시스템 경로로 바꾼다.
  2. 경로에 해당하는 파일 또는 디렉터리가 존재하지 않는 경우에는 HTTP-404 응답을 만들어 리턴한다.
  3. 실제 경로가 디렉터리인 경우에는 먼저 해당 디렉터리에 index.html 파일이 있는지 확인한다.
    • index.html 파일이 있으면 해당 파일을 HTTP 응답으로 보내주면 된다.
    • index.html 파일이 없는 경우에는 디렉터리의 파일 목록을 보여주는 HTML을 만들어 응답으로 보내준다.
  4. 이 경로가 파일인 경우는 바로 파일을 읽어 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로 간단한 명령행 웹서버를 만들어왔다. 작업의 대부분을 프레임워크와 라이브러리로 처리했더니 공부는 별로 안 된 것 같다. 그래도 잠깐 재미는 있었다.

참고

  • RUPA 위에서 설명한 소스 코드 전체를 확인할 수 있다.
  • RUP 외부 라이브러리를 사용하지 않고 구현한 명령행 HTTP 서버. HTTP/1.1만 지원한다. 원래는 이걸 설명하려 했는데, 글이 너무 길고 복잡해질 것 같아 방향을 틀었다.