Rust Echo 서버
요즘 Rust를 조금씩 보고 있는데, 갑자기 Rust로 에코 서버를 만들어보면 어떨까 하는 생각이 들었다. 에코 서버는 요청을 그대로 리턴하는 서버를 말하며, 보통 클라이언트가 서버에 제대로 연결되었는지 테스트하는 용도로 사용된다.
HTTP 에코 서버는 HTTP 요청에 포함된 메서드, 요청 경로, 헤더, 쿼리 파라미터, 본문(body)을 HTTP 응답에 모두 포함시켜 리턴한다. 서버 응답을 보면 클라이언트가 보낸 요청을 재구성할 수 있다.
Actix
Rust로 웹 서버를 만들 때 사용할 수 있는 프레임워크가 몇 가지 있는데, 그 중 Actix를 택했다. 다른 프레임워크를 모두 둘러본 후 결정한 것은 아니고, Actix가 언듯 보기에 괜찮아 보였고 이름이 마음에 들었다.
Actix를 사용하려면 먼저 Cargo.toml
의 [dependencies]
섹션에 antix-web
종속성을 추가해야 한다. 응답 헤더의 MIME 타입을 설정하기 위해 mime
종속성도 함께 추가한다.
[dependencies]
actix-web = "4"
mime = "0"
그리고 다음과 같이 main
함수를 정의한다. App
인스턴스를 생성하고, App::service
로 echo
핸들러를 등록한다.
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let server = HttpServer::new(|| App::new().service(echo));
println!("Serving on http://localhost:3000");
server
.bind("127.0.0.1:3000")?
.run()
.await
}
에코 핸들러
echo
핸들러는 다음과 같다. 모든 HTTP 요청을 이 핸들러에서 처리할 것이므로 경로에 /*
를 설정했다. 핸들러에서 처리할 HTTP 메서드를 이런 식으로 나열하는 것이 마음에 썩 들지는 않지만, HTTP 메서드가 수백 개씩 있는 것도 아니니 크게 문제될 것 없다.
#[route(
"/{_:.*}",
method = "GET",
method = "POST",
method = "PUT",
method = "DELETE",
method = "HEAD",
method = "CONNECT",
method = "OPTIONS",
method = "TRACE",
method = "PATCH"
)]
async fn echo(req: HttpRequest, body: String) -> impl Responder {
let response = EchoResponse {
method: req.method().as_str(),
path: req.path(),
queries: queries_as_map(req.query_string()),
headers: headers_as_map(req.headers()),
body: body_as_json(body),
};
HttpResponse::Ok()
.insert_header(header::ContentType(mime::APPLICATION_JSON))
.body(serde_json::to_string(&response).unwrap())
}
EchoResponse
구조체를 만들고, HttpRequest
로부터 필요한 데이터를 얻어 필드를 채운 다음, serde_json::to_string()
을 이용해 JSON으로 변환해 리턴한다. Serde JSON은 다음 섹션에서 자세히 설명한다.
요청 본문을 Bytes
로 받을 수도 있지만, 여기서는 문자열로 변환해 응답 JSON에 넣을 것이므로 그냥 String
으로 받았다. 테스트해 보지는 않았지만, 요청 본문이 바이너리 데이터라면 아마 에러가 발생할 것이다.
에코 응답
응답으로 메서드, 경로, 쿼리 파라미터, 헤더, 본문을 포함한 JSON을 리턴할 것이다. 데이터를 JSON으로 렌더링 하는 데는 Serde JSON을 쓰면 된다. Serde JSON을 쓰려면 Cargo.toml
에 다음을 추가한다.
[dependencies]
...
serde = "1"
serde_json = "1"
json!
매크로를 사용하면 JSON을 아주 간단히 만들 수 있다.
let response = json!({
"method": req.method().as_str(),
"path": req.path(),
"queries": queries_as_map(req.query_string()),
"headers": headers_as_map(req.headers()),
"body": body,
});
...
처음에는 이렇게 했는데, 응답의 키 순서가 엉망이 되는 문제가 있었다. method
, path
, ... 순으로 나오도록 응답을 만들고 싶은데, json!
으로 생성된 JSON은 순서가 제멋대로 바뀌었다. 아마 내부적으로 HashMap
을 사용해 그런 것 같다. 여기까지는 이해를 했지만, 매 요청마다 키 순서가 마음대로 바뀌는 것까지는 참을 수 없었다.
내가 원하는 키 순서를 강제하기 위해 다음과 같이 구조체를 만들었다. body
는 JSON을 리턴할 수 있도록 serde_json::Value
타입으로 설정했다.
struct EchoResponse<'a> {
method: &'a str,
path: &'a str,
queries: BTreeMap<&'a str, SingleOrMulti<'a>>,
headers: BTreeMap<&'a str, SingleOrMulti<'a>>,
body: serde_json::Value,
}
헤더와 쿼리 파라미터 키가 알파벳 순으로 정렬되게 하기 위해 queries
와 headers
의 데이터 타입을 HashMap
대신 BTreeMap
으로 했다. SingleOrMulti
에 대해서는 헤더 섹션에서 설명한다.
여기에 #[derive(Serialize)]
를 추가하고 json!
매크로를 사용할 수도 있지만, 이 경우에는 키가 알파벳 순으로 정렬된다. 요청마다 키 순서가 바뀌는 것보다는 훨씬 낫지만, 여전히 내가 원하는 순서는 아니다. 다음과 같이 EchoResponse
에 대해 Serialize
를 구현하면 키 순서가 항상 method
, path
, queries
, ... 순으로 나온다.
impl<'a> Serialize for EchoResponse<'a> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let mut state = serializer.serialize_struct("Echo", 5)?;
state.serialize_field("method", &self.method)?;
state.serialize_field("path", &self.path)?;
state.serialize_field("queries", &self.queries)?;
state.serialize_field("headers", &self.headers)?;
state.serialize_field("body", &self.body)?;
state.end()
}
}
헤더
Actix에서는 HttpRequest
에 정의된 headers()
메서드로 해더 맵을 얻을 수 있다. HTTP 요청에서 헤더는 중복될 수 있으므로 headers()
가 리턴하는 HeaderMap
은 멀티맵이다. Serde가 HeaderMap
을 적절히 JSON으로 렌더링해준다면 문제가 쉽겠지만, 안타깝게 HeaderMap
은 Serialize
를 구현하지 않았다.
Serde는 이미 HashMap
을 JSON으로 렌더링할 수 있으므로, HeaderMap
을 HashMap<String, String>
으로 바꾸는 걸 고려해 볼 수도 있다. 그러나 HeaderMap
은 멀티맵, 즉 키 하나에 여러 개의 값이 연결될 수 있으므로 적절해보이지 않는다. Java에서라면 Map<String, Object>
와 같이 값의 타입을 Object
로 지정하고, 여기에 String
이나 List<String>
을 넣는 방법을 썼을텐데, Rust에서는 그렇게 할 수 없다.
내가 생각한 방법은 다음과 같은 enum
을 만드는 것이었다.
enum SingleOrMulti<'a> {
Single(&'a str),
Multi(Vec<&'a str>),
}
이 enum
이 있으면 HeaderMap
을 HashMap<&str, SingleOrMulti>
로 변환하면 된다. 물론 다음과 같이 SingleOrMulti
에 대해 Serialize
를 구현해줘야 한다.
impl <'a> Serialize for SingleOrMulti<'a> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer
{
match self {
SingleOrMulti::Single(v) => {
serializer.serialize_str(v)
},
SingleOrMulti::Multi(vs) => {
let mut seq = serializer.serialize_seq(Some(vs.len()))?;
for e in vs {
seq.serialize_element(e)?;
}
seq.end()
}
}
}
}
HeaderMap
을 HashMap<&str, SingleOrMulti>
로 변환할 수도 있지만, HashMap
을 사용할 경우 키 순서가 요청마다 임의로 바뀌어 보기 좋지 않다. 헤더 이름이 알파벳 순으로 정렬되도록 BTreeMap
을 사용한다.
fn headers_as_map(headers: &HeaderMap) -> BTreeMap<&str, SingleOrMulti> {
let mut ret = BTreeMap::new();
for key in headers.keys() {
let vs: Vec<&str> = headers
.get_all(key)
.into_iter()
.map(|v| v.to_str().unwrap_or("<<Error: Contains Non-visible ASCII characters>>"))
.collect();
let k = key.as_str();
let v = if vs.len() > 1 {
SingleOrMulti::Multi(vs)
} else {
SingleOrMulti::Single(vs[0])
};
ret.insert(k, v);
}
ret
}
쿼리 파라미터
Actix에 HeaderMap
처럼 QueryMap
같은 게 있을 줄 알았는데 찾지 못했다. 쿼리 스트링은 HttpRequest
의 query_string()
메서드로 얻을 수 있다. 여기서는 쿼리 스트링을 serde_urlencoded::from_str()
로 파싱해 처리하려 한다.
다음과 같이 serde_urlencoded
를 Cargo.toml
의 [dependencies]
섹션에 추가한다.
[dependencies]
...
serde_urlencoded = "0"
헤더를 처리할 때는 HeaderMap
의 get_all()
메서드로 특정 키에 대한 값을 모두 얻어 처리했다. serde_urlencoded::from_str()
은 (key, value)
튜플의 벡터를 리턴하므로, 처리 방법이 조금 다르다. 튜플을 하나씩 읽으며 맵을 업데이트해야 한다.
fn queries_as_map(query_string: &str) -> BTreeMap<&str, SingleOrMulti> {
let mut ret = BTreeMap::new();
let queries = from_str::<Vec<(&str, &str)>>(query_string).unwrap();
for (k, v) in queries {
match ret.get_mut(k) {
None => {
ret.insert(k, SingleOrMulti::Single(v));
}
Some(SingleOrMulti::Single(ov)) => {
let vs = vec![*ov, v];
ret.insert(k, SingleOrMulti::Multi(vs));
}
Some(SingleOrMulti::Multi(vs)) => {
vs.push(v);
}
}
}
ret
}
역시 키 순서가 알파벳 순으로 정렬되도록 BTreeMap
을 사용했다.
본문
요청 본문(body)을 JSON으로 파싱해보고 성공한 경우에는 JSON으로, 실패한 경우에는 문자열로 리턴한다.
fn body_as_json(body: String) -> serde_json::Value {
serde_json::from_str(&body).unwrap_or(serde_json::Value::String(body))
}
테스트
다음과 같이 HTTP 요청을 만들어 보냈다.
POST http://localhost:3000/echo/hello/world?a=10&b=20&c=31&c=32&c=33
Accept-charset: utf-8
x-header-1: hello
h: 10
h: 20
h: 30
{
"hello": "world",
"ping": "pong"
}
에코 서버 응답은 다음과 같다.
{
"method": "POST",
"path": "/echo/hello/world",
"queries": {
"a": "10",
"b": "20",
"c": [
"31",
"32",
"33"
]
},
"headers": {
"accept": "*/*",
"accept-encoding": "gzip",
"connection": "keep-alive",
"content-length": "40",
"extension": "Security/Digest Security/SSL",
"h": [
"10",
"20",
"30"
],
"host": "localhost:3000",
"mime-version": "1.0",
"x-header-1": "hello"
},
"body": {
"hello": "world",
"ping": "pong"
}
}
쿼리 파라미터 c
의 모든 값과 헤더 h
의 모든 값이 배열로 표시되었다. 요청에는 없는 accept
, connection
등의 헤더가 보이는데, 이는 내 HTTP 클라이언트 (restclient.el)가 요청에 포함시켜 보낸 것이다.
성능
이 간단한 서버의 성능은 어느 정도일까? wrk로 맥북에서 테스트해보니 다음과 같은 결과가 나왔다.
Server | RPS |
---|---|
reflexive (debug build) | 35,951 |
reflexive (release build) | 64,398 |
Velociraptor (vert.x) | 55,357 |
제대로 된 성능 테스트라 볼 수는 없지만, 서버가 어느 정도 요청을 처리할 수 있는지 대략적인 감을 잡는 용도로는 사용할 수 있을 것 같다. 디버그 빌드와 릴리즈 빌드의 차이가 거의 두 배에 가까운 것을 볼 수 있다.
마무리
아직 Rust를 많이 공부하지는 않았지만, 에코 서버를 구현하는 난이도는 Java로 구현했을 때보다 많이 어렵지는 않았다. 헤더나 쿼리 파라미터를 처리할 때 약간 헤맸지만, enum
을 이용해 해결할 수 있었다.
Rust에는 null
도 없고 예외(exception)도 없다. 대충 예외를 던지거나 null
을 리턴할 수 없으므로, 그런 예외상황을 코드로 작성할 때 어떻게 하는 게 옳을지 더 깊히 생각하게 되는 것 같다.
참고
- Actix Rust용 웹 프레임워크.
- reflexive: 위에서 설명한 코드 전체를 확인할 수 있다.
- Velociraptor: Java(Vert.x)로 구현한 에코 서버. 에코 외에도 몇 가지 잡다한 기능이 더 있다.
- Building a request inspector:
HeaderMap
을HashMap<String, String>
으로 변환하는 접근법을 택했다. 특정 키에 값이 여럿이 있는 경우에는,
를 구분자로 연결한 문자열을 만들어 넣는다. 글쓴이도 인정했듯이 우아한 접근법은 아니다.