Haskell, как что-то очень близкое, или получаем комиты из github api

в 15:35, , рубрики: github, haskell, метки:

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

Продолжаем коверкать мышление программистов и переходим к парсингу. Для него будем использовать чрезвычайно могучий пакет под названием 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

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js