Too late — 'cause I got it now
there are monads all around
IO, State and lists abound
It's easy, like those people say
but my program got abstracted all away!
Maybe — o o o,
It's a monad too, I know
Why should I use another language at all?
Снова безумный адепт Haskell, и еще одна попытка доказать его практичность. Нестареющая классика.
Я постараюсь рассказать шикарную историю (не ведитесь на пафосную рекламу), в которой будут все необходимые компоненты блокбастера (я серьезно, не ведитесь) — знакомые герои, хорошо продуманная вселенная и открытая концовка (ну что ж...).
Немного серьезности никогда не помешает. Поэтому сначала, без малейшего намека на юмор, расскажу логику написания этого текста. Мне хотелось (прежде всего, для себя, но надеюсь, кому-нибудь тоже будет интересно) реализовать на Haskell некую до боли близкую, неимоверно практичную задачу. Положительный результат решения этой задачи дал бы лишний повод гордиться собой, скилы и еще один довод в пользу выбора этого языка программирования. В качестве подопытной задачи я выбрал получение и обработку информации о коммитах в репозиторий на github. Собственно, она будет содержать в себе работу с github api — загрузка и парсинг json.
Полагаю, что решать ее стоит по шагам, поэтому начнем с исходной позиции, а именно пустой директории в файловой системе.
Создание модуля
Для начала, создадим новый модуль для наших целей
cabal init
Пытливый cabal задаст несколько вопросов, а в результате вы получите заготовку модуля с конфигурационным файлом project_name.cabal. Для большей эстетики добавим в модуль директорию src, и укажем ее в конфигурации
executable project-name
hs-source-dirs: src
main-is: Main.hs
Конечно, Main.hs необходимо создать)
Дальше пару слов о dependency hell. Это больная тема Haskell, в которой намечается прогресс. Вариантов решения проблемы зависимостей несколько, но мы молоды и любим все модное, поэтому будем использовать свежую фичу cabal-1.18 — sandoxes.
Собственно, для использования необходимо инициализировать песочницу и установить зависимости
cabal sandbox init
cabal install --only-dependencies
В дальнейшем для сборки модуля можно, как обычно, воспользоваться командой
cabal build
Если возникло острое желание что-нибудь поотлаживать, да и вообще, посмотреть, как оно работает изнутри (а, по законам жанра, такое желание обязательно возникнет), можно запустить ghci в созданной песочнице командой
cabal repl
Все, боязнь пустого каталога преодолена, двигаемся дальше.
http-conduit
Первая задача, которую необходимо решить — это загрузка информации о комитах в json формате. Собственно, источник очевиден, но на этом простые вещи заканчиваются. Итак, на этом этапе будем использовать пакет http-conduit за авторством солнцеликого Edward Snow Michael Snoyman. В целом, conduit — это замечательное решение для работы с потоками данных. У меня врядли получится хорошо об этом рассказать, поэтому добро пожаловать в блог человека по фамилии eax. Я расскажу совсем чуть-чуть и по периферии.
Для начала, надо добавить нужные зависимости в раздел build-depends конфигурационного файла
bytestring >= 0.10,
conduit >= 1.0,
http-conduit >= 1.9,
и обновить песочницу описанной выше командой.
Вот теперь можем трепетно приступить к коду. Для начала, чтобы упростить себе жизнь и работу со строками, добавим extension
{-# LANGUAGE OverloadedStrings #-}
Подключаем нужные модули
import Data.Conduit
import Network.HTTP.Conduit
import qualified Data.Conduit.Binary as CB
import qualified Data.ByteString.Char8 as BS
Весь код загрузки json будет выглядеть примерно так
main = do
manager <- newManager def
req <- parseUrl "https://api.github.com/../.."
let headers = requestHeaders req
req' = req {
requestHeaders = ("User-agent", "some-app") :
headers
}
runResourceT $ do
res <- http req' manager
responseBody res $$+- CB.lines =$ parserSink
Насколько я помню, api github требует наличия заголовка User-agent, поэтому пришлось немного расширить request. Основное действо происходит в последних двух строках, где мы получает ответ с json. Т.к. результат завернут в трансформер ResourceT, то функции для его получения должны быть вызваны с использованием runResourceT. После получения тела ответа мы отправляем его в сток, который предназначен для разбора json и выглядит он так
parserSink :: Sink BS.ByteString (ResourceT IO) ()
parserSink = do
md <- await
case md of
Nothing -> return ()
Just d -> parseCommits d
Сток в случае успеха будет просто разбирать полученный json и выводить его на экран (эта часть магии скрыта в функции parseCommits).
Aeson
Продолжаем коверкать
- Т.к. Haskell строго типизирован, то нам потребуются типы, которые будут описывать заложенную в json структуру данных
- Если я ничего не перепутал, то Aeson использует lazy bytestring, в то время как в стоке оказывается strict bytestring, поэтому придется продемонстрировать навыки жонглирования типами
Итак, сначала определим типы. Можно не заморачиваться, и определить их лишь частично, отправив часть информации из json в топку. Себе оставим только url, хэш и commit message.
import qualified Data.ByteString.Char8 as BS
import Data.Aeson (FromJSON(..))
data CommitInfo = CommitInfo {
message :: BS.ByteString
} deriving (Show)
data Commit = Commit {
sha :: BS.ByteString,
url :: BS.ByteString,
commit :: CommitInfo
} deriving (Show)
Дальне нам было бы канонично использовать аппликативные функторы для сопоставления json и полей из структур данных, но мы всех обманем и воспользуемся Generic'ом.
{-# LANGUAGE DeriveGeneric #-}
import GHC.Generics (Generic)
и добавим к имеющимся структурам данных наследование от Generic
deriving(Show, Generic)
Останется только заявить о возможности создания Commit & CommitInfo из json
instance FromJSON Commit
instance FromJSON CommitInfo
Осталось всего несколько шагов до финиша, мы почти у цели
parseCommits :: BS.ByteString -> Sink BS.ByteString (ResourceT IO) ()
parseCommits rawData = do
let parsedData = decode $ BL.fromChunks [rawData] :: Maybe [Models.Commit]
case parsedData of
Nothing -> liftIO $ BS.putStrLn "Parse error"
Just commits -> liftIO $ printCommits commits
Как видите, приходится создавать lazy bytestring для отдачи на декодирование. Если парсинг прошел успешно, с помощью liftIO поднимаем полученные значения и выводим в консоль.
Finish
Все, красная дорожка, фанфары и торжественное завершение вечера. Полный пример расположен здесь. Код не является примером торжества идеалов computer science, поэтому замечания от гуру приветствуются. Надеюсь, все остальные чему-нибудь научились, или хотя бы получили удовольствие и стали ближе к миру Haskell. Да пребудет с вами сила!
Автор: erthalion