GHC와 그에 딸려오는 부트 라이브러리만으로, Cabal/Stack 없이 간단한 CLI를 만드는 방법을 소개합니다. 예시로 Go로 작성했던 프로그램을 Haskell로 옮기며 핵심 요소를 설명합니다.
요즘 트위터에서 Haskell 이야기를 보면, 모두가 끊임없이 어떤 “고급” 기법을 외치고 서로에게 과시하려 드는 것처럼 보입니다. 하지만 실제로 Haskell을 쓰는 데 필요한 건 Haskell 컴파일러 하나뿐입니다. 이 글에서는 GHC와 함께 제공되는 부트 라이브러리만으로 예전에 제가 Go로 썼던 프로그램을 어떻게 포팅했는지 보여드리겠습니다. 간단한 CLI를 쓰는 데에는 더 많은 라이브러리가 필요하지 않으므로 Cabal이나 Stack도 쓰지 않을 겁니다.
GHC에 함께 들어 있는 다수의 라이브러리들을 말합니다: https://downloads.haskell.org/~ghc/latest/docs/html/users_guide/8.6.1-notes.html#included-libraries
여기서는 base, text, process만 사용할 예정이지만, 여러분은 이 목록에서 마음에 드는 것을 더 찾아볼 수도 있습니다.
대략 세 가지가 전부입니다.
nix-env -i ghc만 썼지만, 다른 방법을 써도 됩니다.그다음 Haskell 소스 파일을 만듭니다. 여기서는 touch prefetch-github.hs를 썼습니다.
프로그램을 실행하려면 나중에 runhaskell prefetch-github.hs를 실행하면 됩니다. 나중에 쓸 바이너리를 만들려면 ghc prefetch-github.hs -o prefetch-github를 실행하세요. 실행과 패키징은 뒤에서 더 설명합니다.
왜 Go에서 다시 썼냐고요? 이유는 간단합니다.
세 번째 이유는 때로 꽤 넘기기 어렵지만, Haskell을 즐겁게 쓰는 데 핵심이라고 생각합니다.
이 프로그램은 원래 Go로 작성했으며, 여러 인자를 달리해 nix-prefetch-git을 호출합니다. 그리고 nix-prefetch-git의 출력을 파싱해 제가 원하는 결과를 뽑아냅니다. 주로 nixpkgs.fetchFromGitHub 또는 nixpkgs.fetchgit에 넣을 Nix set(레코드) 표현을 만들고 싶습니다.
따라서 해결해야 할 하위 목표는 대략 다음과 같습니다.
Haskell은 오래된 언어라서 리터럴 멀티라인 문자열 같은 몇몇 편의 기능이 없습니다. 많은 사람들이 멀티라인 문자열을 위해 각종 Template Haskell 해법을 찾으려 하지만, 저는 가장 단순한 해법을 택했습니다: 줄바꿈 이스케이프입니다.
실제로는 여러분이 상상하는 그대로입니다.
helpMessage :: String
helpMessage = "Usage of prefetch-github:\n\
\ -branch\n\
\ Treat the rev as a branch, where the commit reference should be used.\n\
\ -fetchgit\n\
\ Print the output in the fetchGit format. Default: fromFromGitHub\n\
\ -hash-only\n\
\ Print only the hash.\n\
\ -owner string\n\
\ The owner of the repository. e.g. justinwoo\n\
\ -repo string\n\
\ The repository name. e.g. easy-purescript-nix\n\
\ -rev string\n\
\ Optionally specify which revision should be fetched."
보기엔 좀 못생겼나요? 그럴 수도 있죠. 그래도 레이아웃을 더 세밀하게 통제할 수 있기도 합니다.
나중에 출력해야 할 fetchFromGitHub 결과를 생각하면, 그냥 문자열을 이어 붙이는 식으로 동일하게 처리할 수 있습니다.
-- | Required arg for Owner of a repo (user or org)
newtype Owner = Owner String
-- | Required arg for repo (e.g. 'readme' in justinwoo/readme)
newtype Repo = Repo String
-- | Revision (git)
newtype Rev = Rev String
newtype Sha = Sha String
mkGithubTemplate :: Owner -> Repo -> Rev -> Sha -> String
mkGithubTemplate (Owner owner) (Repo repo) (Rev rev) (Sha sha) = "{\n\
\ owner = \"" <> owner <> "\";\n\
\ repo = \"" <> repo <> "\";\n\
\ rev = \"" <> rev <> "\";\n\
\ sha256 = \"" <> sha <> "\";\n\
\}"
트위터에 따르면 optparse-applicative를 써야 합니다. 저도 어느 정도 동의합니다. 좋은 라이브러리고 원하는 구조를 잡는 데 도움이 되죠. 하지만 리스트의 문자열에 패턴 매칭을 하는 것도 재밌고, 재귀로 하면 더 재밌습니다. 그래서 저는 그냥 System.Environment.getArgs를 쓰고 패턴 매칭을 했습니다.
main :: IO ()
main = do
args <- Env.getArgs
case args of
[] -> help
["help"] -> help
["-help"] -> help
_ -> main' args
main' :: [String] -> IO ()
main' args = do
owner <- parseOwner args
repo <- parseRepo args
-- ...
help :: IO ()
help = putStrLn helpMessage
Owner와 Repo를 파싱하기 위해 재귀와 패턴 매칭의 힘을 씁니다.
parseOwner :: [String] -> IO Owner
parseOwner ("-owner" : owner : _) = pure $ Owner owner
parseOwner (_ : xs) = parseOwner xs
parseOwner [] = fail "owner must be specified in args. see help."
parseRepo :: [String] -> IO Repo
parseRepo ("-repo" : repo : _) = pure $ Repo repo
parseRepo (_ : xs) = parseRepo xs
parseRepo [] = fail "repo must be specified in args. see help."
parseRev :: [String] -> Maybe Rev
parseRev ("-rev" : rev : _) = Just $ Rev rev
parseRev (_ : xs) = parseRev xs
parseRev [] = Nothing
nix-prefetch-git 실행 프로세스 만들기앞서처럼 실행할 명령을 템플릿으로 만들 수 있습니다.
mkNixPrefetchGitCmd :: Owner -> Repo -> String -> String
mkNixPrefetchGitCmd (Owner owner) (Repo repo) revArg = cmd
where
url = "https://github.com/" <> owner <> "/" <> repo <> ".git/"
cmd = "GIT_TERMINAL_PROMPT=0 nix-prefetch-git " <> url <> " --quiet --rev " <> revArg
그리고 System.Process의 함수들을 써서 실행합니다.
let cmd = mkNixPrefetchGitCmd owner repo revArg
let cp = Proc.shell cmd
out <- Proc.readCreateProcess cp ""
이렇게 하면 셸에서 결과를 되돌려 받고, 프로세스가 non-zero 종료 코드를 반환하면 프로그램이 실패합니다.
nix-prefetch-git의 출력은 JSON입니다. 많은 사람들이 JSON 역직렬화에는 Aeson이 “정답”이라고 말하겠지만, 여기서는 다음을 고려할 수 있습니다.
이 사실을 알면, 이번에는 Aeson조차 필요 없습니다.
-- | Parse out the result of running nix-prefetch-git
-- "url": "https://github.com/justinwoo/easy-purescript-nix",
-- "rev": "54266e45aeaebc78dd51a40da36e9840a8a300dd",
-- "date": "2019-02-08T01:59:41+02:00",
-- "sha256": "1swjii6975cpys49w5rgnhw5x6ms2cc9fs8ijjpk04pz3zp2vpzn",
-- "fetchSubmodules": false
parseNixPrefetchGitResult :: String -> IO (Url, Rev, Sha)
parseNixPrefetchGitResult out = do
case handleResult <$> mUrl <*> mRev <*> mSha of
Just x -> x
Nothing -> fail $ "failed to parse nix-prefetch-git output: " <> out
where
texts = Text.lines $ Text.pack out
takeProp key
= Text.filter (\c -> c /= '"' && c /= ',')
. (\xs -> xs !! 1)
. Text.words
<$> List.find (Text.isInfixOf . Text.pack $ "\"" <> key <> "\"") texts
mUrl = takeProp "url"
mRev = takeProp "rev"
mSha = takeProp "sha256"
mkString ctr txt = ctr $ Text.unpack txt
handleResult url rev sha =
if Text.pack failedPrefetchRev `Text.isInfixOf` rev
then fail $ "nix-prefetch-url could not find the repo:\n" <> out
else pure $ (mkString Url url, mkString Rev rev, mkString Sha sha)
이제 위의 부분들을 조합해 일반적인 동작 모드를 완성합니다.
main' :: [String] -> IO ()
main' args = do
owner <- parseOwner args
repo <- parseRepo args
let cmd = mkNixPrefetchGitCmd owner repo revArg
let cp = Proc.shell cmd
out <- Proc.readCreateProcess cp ""
(url, rev, sha@(Sha sha')) <- parseNixPrefetchGitResult out
case (hashOnly, fetchGit) of
(True, _) -> putStrLn sha'
(_, False) -> putStrLn $ mkGithubTemplate owner repo rev sha
(_, True) -> putStrLn $ mkGitTemplate url rev sha
where
fetchGit = parseFetchGit args
asBranch = parseAsBranch args
hashOnly = parseHashOnly args
revArg = case (parseRev args, asBranch) of
(Just (Rev r), True) -> refsPrefix <> r
(Just (Rev r), False) -> r
(Nothing, _) -> ""
이게 전부입니다. 이제 이 프로그램은 runghc prefetch-github.hs [args]로 실행하거나 ghc prefetch-github.hs -o prefetch-github로 바이너리를 컴파일해 사용할 수 있습니다.
> runhaskell prefetch-github.hs -owner justinwoo -repo prefetch-github
{
owner = "justinwoo";
repo = "prefetch-github";
rev = "ecc358529592f403d24a955c293922124c4354f7";
sha256 = "1wcyzmbrs0rzva7jwnqa4vqr34z1sv1cigpyyiaajkf8bx29pamw";
}
프로젝트는 여기에서 볼 수 있습니다: https://github.com/justinwoo/prefetch-github
이 글을 통해 Cabal/Stack을 쓰지 않거나, 트위터가 하라는 온갖 “랜덤한” 것들을 따르지 않아도 Haskell을 쓸 수 있다는 점을 보여드렸기를 바랍니다. 실제로는 이렇게 “심심한” 코드를 익숙하게 쓰는 능력이야말로 더 “고급” 주제를 이해하는 데에도, 그리고 Haskell, PureScript, JavaScript 등 어떤 언어로 일하든 채용 시장에서 경쟁력을 갖추는 데에도 가장 큰 도움이 됩니다.