Attoparsec 사용법

Posted on July 7, 2021

parsec 포스트처럼 아이디어를 찾아 본 게 아니라, 그저 사용법만 알아 봤습니다.

Parser 타입 정의 - Parsec과 차이

newtype Parser a = Parser { runParser :: forall r. S -> Failure r -> Success a r -> Result r }

문자열과 실패를 처리할 함수, 성공을 처리할 함수를 받아 결과를 돌려주는 타입입니다.

parsec 라이브러리에서는 다음과 같습니다.

newtype Parser a = Parser { parse :: String -> [(a,String)] }

parsec 에서는 결과값을 리스트로 표시해서 실패를 []로 표현했는데, 여기서는 명시적으로 실패, 부분 매칭, 성공 3가지를 결과 타입으로 정의했습니다. Partial이 눈에 띕니다.

data Result r = Fail S [String] String
              | Partial (B.ByteString -> Result r)
              | Done S r

Q. Fail, Done은 그럭 저럭 넘어 가겠는데, Partial의 함수(B.ByteString -> Result r)가 목에 걸립니다. 결과로 파서 함수를 돌려주는 이유가 뭘까요?
A. 프로그래머가 지정하는 함수가 아니라, parse 함수가 결과로 만들어내는, 내부에 일부 파싱 작업이 진행된 결과를 갖고 있는 함수입니다. 이 함수를 추가 문자열에 적용하는 것만으로 기존 파싱 작업했던 것과 합쳐져서 최종 결과를 만들어 냅니다.

사용법

parse, parseOnly

parse에 파서와 문자열을 넣어주면 기본 끝난 문자열로 인식하지 않습니다. 문자열이 더 들어오길 기다리다가 ““이 들어오면 파싱을 끝마칩니다. feed로 추가 문자열을 넣어줍니다. 추가 문자열 없이 끝내려면 parseOnly를 씁니다. 이런 특성 때문에, Lazy하게 스트림을 처리할 때 parsec보다 유리합니다.

> r = parse (double) "1"
> print r
Partial _

> feed r ""
Done "" 1.0

물론 매칭 실패하면 추가 문자열을 기다리지 않습니다.

네트워크에서 패킷을 받아 double로 파싱하는 상황을 가정해 봅시다.

recvByte = ...소켓에서 읽은 ByteString 리턴
dParser = parse double 

dParser recvByte

만일 recvByte"1"만 리턴하고, 다음은 아직 읽기 전이라면,

dParser "1"

에서 Done "" 1.0 으로 끝나면 안됩니다. 다음에 들어오는게 구분자(공백 같은 것들..)일지 이어지는 숫자일지 알 수 없으니 일단 Partial 상태로 두어야 합니다.

feed함수는 Partial p를 받아 p를 추가 문자열에 적용하는 함수입니다.

feed :: Monoid i => IResult i r -> i -> IResult i r
feed Fail ...
feed (Partial p) moreByte = p moreByte
feed Done ...

feed (dParser "1"의 결과값 Partial p) "2"

이렇게 추가 적용했는데, 아직도 패킷이 끝났다는 표시가 없으면 어떻게 할까요? 또 feed를 불러야 합니다. 현재 "12"까지 매칭한 상태입니다. 파서를 한 두번 적용해서 끝나는 게 아니니 루프를 돌려야 합니다.

recvLoop1 :: MonadIO m => (ByteString -> Result a) -> TCP.Socket -> m (Maybe (a, ByteString))
recvLoop1 p sock = do
  resByte <- Network.Simple.TCP.recv sock 1 --  소켓에서 1바이트 읽기. 
          -- Network.Simple.TCP.recv는 결과값이 (Maybe ByteString) 입니다. 
  case p <$> resByte of
    Nothing            -> return Nothing -- 읽어 온 값이 없을 때
    Just (Fail {})     -> return Nothing -- 매칭 실패. (resByte를 리턴해서 다른 파서를 시도할 수도 
                                         -- 있지만, 여기선 단순하게 Nothing을 리턴합니다.)
    Just (Partial p')  -> recvLoop1 p sock -- p와 일부 매칭이 되고, 
                                           -- 다음을 더 읽어봐야 아는 상태. feed와 비슷한 구현.
    Just (Done left a) -> return (Just (a, left)) -- 완전히 p와 매칭 성공하고 남은 문자열이 있는 상황

패킷을 남김없이 모두 파싱

끊임없는 스트림을 파싱할 때는 Done이나 Fail로 끝나게 하면 안될 때도 있습니다. 한 번에 한가지 메시지만 들어 오는 게 아니라, 여러 메시지가 함께 들어 올 경우도 있습니다. 아래 recvLoop2는 들어온 패킷은 모두 파싱 시도해서 결과를 리스트에 담습니다. recvLoop1recvLoop2의 차이는, recvLoop1은 한 번만 매칭 시도해서 성공하면 남은 패킷이 있더라도 리턴하고, recvLoop2는 들어온 모든 패킷에 반복해서 매칭을 시도합니다.

cronokirby / haze - Haze/Peer에서 발췌


parse :: (ByteString -> Result String) -> ByteString -> Maybe ([Message], CallBack)
parse callback bytes = do
    (msgs, callback') <- applyPartial callback bytes []
    return (reverse msgs, callback')
  where
    applyPartial callback bs acc = 
      case callback bs of
        Fail{}      -> Nothing
        Partial f   -> Just (acc, f) -- Partial일 때만 값을 가지고 루프를 빠져 나간다. 
        Done left m -> applyPartial firstCallBack left (m : acc) 
                       -- Done일때는 acc에 결과를 누적하고 다시 첫 파서부터 매칭 시도

recvLoop2 :: (ByteString -> Result String) -> IO ()
recvLoop2 callback = do
    bytes  <- ... -- 소켓에서 데이터 읽기 
    case parse callback bytes of
        Nothing          -> recvLoop2 firstCallBack -- 아무것도 매칭 못하면 다시 처음부터
        Just (msgs, callback') -> do -- Partial 상태일 때만 여기에 도착한다.
            메시지 처리
            recvLoop2 callback' 
Github 계정이 없는 분은 메일로 보내주세요. lionhairdino at gmail.com