Sans-IO

2분 읽기 •


나는 라이브러리나 개발 도구를 만드는 걸 꽤 좋아한다. 내 깃헙에 있는 프로젝트들 중에 아마 상업성을 띄는 건 얼마 없을 것이다.

어쨌든 이런 나같은 부류의 사람들에게 재밌는 패턴 하나 소개해보려고 한다. 개념 자체는 꽤 오래된 건데 sans-io라는 녀석이다. 이름을 보면 좀 유추할 수 있는데 프랑스어로 sans가 ~없는 이라는 뜻이다. 즉, IO 없는 패턴 이라고 의역할 수 있겠다.

우리가 일반적으로 라이브러리를 만들 때 네트워크, 디스크 등 종합적인 IO 작업이 들어가는 경우가 있다. 이런 상황에서 발생할 수 있는 문제는 다음과 같다:

그래서 위 문제점을 해결하기 위해 나온 패턴이 바로 sans-io다.

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 문법이 들어오기 전에는 대부분 다 이런식으로 처리를 했다고 한다.

참고