Sans-IO
2분 읽기 •
나는 라이브러리나 개발 도구를 만드는 걸 꽤 좋아한다. 내 깃헙에 있는 프로젝트들 중에 아마 상업성을 띄는 건 얼마 없을 것이다.
어쨌든 이런 나같은 부류의 사람들에게 재밌는 패턴 하나 소개해보려고 한다. 개념 자체는 꽤 오래된 건데 sans-io라는 녀석이다. 이름을 보면 좀 유추할 수 있는데 프랑스어로 sans가 ~없는 이라는 뜻이다. 즉, IO 없는 패턴 이라고 의역할 수 있겠다.
우리가 일반적으로 라이브러리를 만들 때 네트워크, 디스크 등 종합적인 IO 작업이 들어가는 경우가 있다. 이런 상황에서 발생할 수 있는 문제는 다음과 같다:
- 라이브러리가 특정 모듈에 종속될 수 있다.
- 함수 색깔 문제가 생길 수도 있다.
- 테스트를 짤 때 테스트가 flaky해질 수 있다.
그래서 위 문제점을 해결하기 위해 나온 패턴이 바로 sans-io다.
사실 별 거창한 건 없다. 그리고 코어랑 인프라 레이어를 분리한다는 점에서 헥사고날 아키텍처랑 비슷한 위치에 있다. 다른 점은 실제 비즈니스 중심보다는 좀 더 로우하다는 거?
코드로 한번 예시를 들어보겠다. 예를 들어 음.. pastebin의 글자를 가져오는 라이브러리를 짠다고 치자.
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
pub struct PastebinClient {
host: String,
}
impl PastebinClient {
pub fn new(host: impl Into<String>) -> Self {
Self { host: host.into() }
}
pub async fn fetch(&self, key: &str) -> std::io::Result<String> {
let mut stream = TcpStream::connect((self.host.as_str(), 80)).await?;
let request = format!(
"GET /raw/{key} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
self.host,
);
stream.write_all(request.as_bytes()).await?;
let mut response = String::new();
stream.read_to_string(&mut response).await?;
let body = response
.split_once("\r\n\r\n")
.map(|(_, body)| body.to_owned())
.unwrap_or_default();
Ok(body)
}
}물론 겉보기엔 잘 동작하는 코드다. 다만 이 라이브러리를 쓰려면 tokio를 같이 들여와야 하고, async-std나 뭐 다른 비동기 라이브러리나 동기 코드에서 쓰고 싶으면 방법이 없다. 심지어 테스트를 짜려면 진짜 서버를 띄우거나 mock 서버를 하나 세워야 한다.
sans-io 패턴을 쓰면 IO를 걷어낸다. 직접 소켓을 만지는 대신 상태 머신을 반환한다.
pub enum Step {
Send(Vec<u8>),
Recv,
Done(String),
}
pub struct FetchPaste {
host: String,
key: String,
sent: bool,
eof: bool,
buffer: Vec<u8>,
}
impl FetchPaste {
pub fn new(host: impl Into<String>, key: impl Into<String>) -> Self {
Self {
host: host.into(),
key: key.into(),
sent: false,
eof: false,
buffer: Vec::new(),
}
}
pub fn feed(&mut self, chunk: &[u8]) {
if chunk.is_empty() {
self.eof = true;
} else {
self.buffer.extend_from_slice(chunk);
}
}
pub fn step(&mut self) -> Step {
if !self.sent {
self.sent = true;
let request = format!(
"GET /raw/{} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
self.key, self.host,
);
return Step::Send(request.into_bytes());
}
if !self.eof {
return Step::Recv;
}
let text = String::from_utf8_lossy(&self.buffer);
let body = text
.split_once("\r\n\r\n")
.map(|(_, body)| body.to_owned())
.unwrap_or_default();
Step::Done(body)
}
}이제 이 crate는 소켓이 뭔지도 모르고, 런타임이 뭔지도 모른다. 그럼 실제 IO는 어디서 호출되냐고 할 수 있는데, 실제 애플리케이션 레이어에서 사용한다.
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
async fn fetch(host: &str, key: &str) -> std::io::Result<String> {
let mut stream = TcpStream::connect((host, 80)).await?;
let mut state = FetchPaste::new(host, key);
let mut chunk = [0u8; 4096];
loop {
match state.step() {
Step::Send(bytes) => stream.write_all(&bytes).await?,
Step::Recv => {
let n = stream.read(&mut chunk).await?;
state.feed(&chunk[..n]);
}
Step::Done(body) => return Ok(body),
}
}
}이 루프가 호출부의 끝이다. tokio 자리에 std::net::TcpStream을 넣으면 그대로 동기 버전이 되고, async-std를 넣으면 async-std 버전이 된다.
테스트도 굉장히 편해진다. 서버도, 런타임도, #[tokio::test]도 필요 없고 단순히 바이트 입출력만 검증하면 되기 때문에 가벼워진다.
#[test]
fn remove_header_return_body() {
let mut state = FetchPaste::new("pastebin.com", "foobar");
assert!(matches!(state.step(), Step::Send(_)));
assert!(matches!(state.step(), Step::Recv));
state.feed(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nbazqux");
state.feed(b""); // EOF
assert!(matches!(state.step(), Step::Done(body) if body == "bazqux"));
}flaky할 구석이 없는 깔끔한 호출이 된다.
다만, 보다보면 조금 복잡한 구석이 없지 않아 있는데, 직접 상태머신 분기 처리를 해줘야하는 부분이라거나.. 뭔가 되게 raw 해보인다. 이 글의 댓글을 보면 async / await 문법이 들어오기 전에는 대부분 다 이런식으로 처리를 했다고 한다.
참고
- https://sans-io.readthedocs.io/
- https://news.hada.io/topic?id=15692
- https://rosettalens.com/s/ko/the-case-for-sans-io