Aeson: Hello World!

Приветствую, друзья!

Возникла тут у меня насущная потребность изучить работу с JSON в Haskell. Сами знаете, сегодня JSON используется везде и всюду, потому я и захотел разобраться в этом вопросе основательно.

Разумеется, выбор мой пал на пакет aeson, ибо он мощный, шустрый и весьма широко используемый. В рамках данной статьи мы рассмотрим лишь самые азы работы с ним (которых, впрочем, будет достаточно для базовой работы с JSON).

Привет, JSON-мир!

Итак, у нас есть JSON-файл с информацией об одном-единственном хосте:

{
    "ip": "127.0.0.1",
    "port": 3000
}

Соответственно, требуется распарсить это хозяйство и вывести. Наиболее простым способом использования пакета aeson является чтение в (составной) тип. Определим же его:

data Host = Host { ip   :: String
                 , port :: Int
                 }

printPretty :: Host -> String
printPretty (Host ip port) = ip ++ ":" ++ show port

Теперь нужно поведать, каким образом мы желаем извлекать значение этого типа из сырого JSON. Для этого определим экземпляр класса FromJSON:

instance FromJSON Host where
    parseJSON (Object host) = Host <$> host .: "ip"
                                   <*> host .: "port"

    parseJSON _             = mzero

Как вы поняли, класс типов FromJSON объединяет все типы, значения которых могут быть построены из содержимого JSON. Этот класс включает в себя единственный метод parseJSON, но мы пока не станем рассматривать его подробно.

Теперь пишем наш главный код:

{-# LANGUAGE OverloadedStrings #-}

module Main where

import qualified Data.ByteString as B
import           Control.Monad (mzero)
import           Control.Applicative ((<$>), (<*>))
import           Data.Aeson

...

main :: IO ()
main = do
    rawJSON <- B.readFile "/Users/dshevchenko/our.json"
    -- Проверки, связанные с чтением файла, опущены...
    let result = decodeStrict rawJSON
    putStrLn $ case result of
        Nothing   -> "Invalid JSON!"
        Just host -> printPretty host

При работе с пакетом aeson умолчальным строковым типом является ByteString, а не String (и это хорошо, ибо значиииительно эффективнее). После прочтения файла мы вызываем функцию decodeStrict, которая и делает всю главную работу по извлечению данных из JSON. Обращаю ваше внимание на то, что функция decodeStrict является строгим вариантом функции decode. Мы решили использовать строгие строки, поэтому взяли decodeStrict, для ленивых строк мы бы взяли decode и Data.ByteString.Lazy.readFile.

После запуска этой программы результат вполне ожидаем:

127.0.0.1:3000

Из JSON

Взглянем ещё раз на экземпляр класса FromJSON для нашего хоста:

instance FromJSON Host where
    parseJSON (Object host) = Host <$> host .: "ip"
                                   <*> host .: "port"

    parseJSON _             = mzero

Обратите внимание на тип Object. Используя этот тип, мы заявляем о нашем желании работать с классическим JSON-объектом (представляющим собой набор пар “ключ-значение”). И в данном случае это именно то, что нам нужно: есть один JSON-объект, содержащий две пары, для IP-адреса и для порта. Поэтому мы формируем значение заготовленного типа Host на основании извлечённых значений, а именно IP-адреса:

host .: "ip"

и порта:

host .: "port"

Ну а второй вариант метода parseJSON для типа Host, как вы уже поняли, представляет собой пустышку: если в JSON не окажется ожидаемого нами объекта, значит ничего и сделать нельзя.

Кстати, если аппликативный синтаксис вызывает у вас затруднения, метод parseJSON можно переписать в более явной форме:

parseJSON (Object host) = do
    anIP  <- host .: "ip"
    aPort <- host .: "port"
    return $ Host anIP aPort

Здесь мы последовательно извлекаем значения двух ключей и строим из них значение типа Host, возвращая его в монадическую обёртку.

В JSON

Проделаем обратный фокус - подготовимся к превращаению значения типа Host в JSON. Для этого нам необходимо определить свой экземпляр класса ToJSON:

instance ToJSON Host where
    toJSON (Host anIP aPort) = object [ "ip"   .= anIP
                                      , "port" .= aPort
                                      ]

Здесь всё наоборот: берём значение типа Host и указываем его IP-адрес и порт для ключей ip и port соответственно. Обратите внимание на функцию object: раньше мы извлекали JSON-объект, а теперь мы его формируем.

Пишем главный код:

import qualified Data.ByteString.Lazy.Char8 as LC

...

main :: IO ()
main =
    let host = Host "127.0.0.1" 3050
    in LC.putStrLn $ encode host

Функция encode сериализует значение хоста, и потому на выходе мы получим ожидаемый результат:

{"ip":"127.0.0.1","port":3050}

Украшаем

Не знаю как вам, друзья, а мне не нравятся вот такие однострочные JSON. Всё-таки иногда JSON читают не только программы, но и люди. К счастью, есть маленький пакет aeson-pretty, который украсит наш результат. Установив его, пишем:

import           Data.Aeson.Encode.Pretty

...

main :: IO ()
main =
    let host = Host "127.0.0.1" 3050
    in LC.putStrLn $ encodePretty host

Вместо функции encode теперь используется функция encodePretty, и поэтому на выходе мы получим вот такую красоту:

{
    "ip": "127.0.0.1",
    "port": 3050
}

Кстати, если отступ в 4 символа вас не устраивает (я знаю, многие привыкли к двум пробелам), это можно легко изменить:

main :: IO ()
main =
    let host = Host "127.0.0.1" 3050
        prettyConfig = Config { confIndent  = 2
                              , confCompare = mempty
                              }
    in LC.putStrLn $ encodePretty' prettyConfig host

Мы используем второй вариант функции encodePretty, а именно encodePretty', потому что лишь этот вариант принимает конфиг в качестве первого аргумента. Результат:

{
  "ip": "127.0.0.1",
  "port": 3050
}

Опциональное значение

Пусть наличие порта для хоста будет необязательным, то есть файл может быть и таким:

{
    "ip": "127.0.0.1"
}

В этом случае мы обязаны сообщить об опциональности ключа “port”:

parseJSON (Object host) = do
    anIP  <- host .:  "ip"
    aPort <- host .:? "port" .!= 80
    return $ Host anIP aPort

Оператор .:? указывает на то, что значения “port” может не быть. А оператор .!=, в свою очередь, задаёт умолчальное значение порта, в случае его отсутствия в JSON. Просто и понятно.

Массив

Чуток усложним наш JSON. Пусть теперь в нём живёт безымянный массив хостов:

[
    {
        "ip": "127.0.0.1",
        "port": 3000
    },
    {
        "ip": "127.0.0.1",
        "port": 3010
    },
    {
        "ip": "127.0.0.1",
        "port": 3020
    }
]

Очевидно, теперь в качестве результата мы ожидаем получить список значений типа Host. Нет ничего проще! Пишем:

main :: IO ()
main = do
    rawJSON <- B.readFile "/Users/dshevchenko/our.json"
    let result = decodeStrict rawJSON :: Maybe [Host]
    putStrLn $ case result of
        Nothing    -> "Invalid JSON!"
        Just hosts -> show $ printPretty <$> hosts

Видите? Мы всего лишь явно указали тип значения, возвращаемого функцией decodeStrict: раньше тип был Maybe Host, а теперь - Maybe [Host]. Соответственно, в Just-секции мы бежим по извлечённому списку и красиво отображаем каждый из хостов. И вот результат:

["127.0.0.1:3000","127.0.0.1:3010","127.0.0.1:3020"]

Массив - в JSON

Теперь наоборот: сформируем JSON-массив на основании списка хостов. К счастью, нам ничего не придётся переделывать для этого. Мы просто пишем:

main :: IO ()
main =
    let hosts = [ Host "127.0.0.1" 3050
                , Host "127.0.0.1" 3070
                , Host "127.0.0.1" 3080
                ]
    in LC.putStrLn $ encodePretty hosts

То есть просто формируем список хостов и передаём на сериализацию. Готово:

[
    {
        "ip": "127.0.0.1",
        "port": 3050
    },
    {
        "ip": "127.0.0.1",
        "port": 3070
    },
    {
        "ip": "127.0.0.1",
        "port": 3080
    }
]

Именованный массив

Ещё немного усложним наш JSON. Пусть теперь у нас будет именованный массив хостов:

{
    "hosts": [
        {
            "ip": "127.0.0.1",
            "port": 3000
        },
        {
            "ip": "127.0.0.1",
            "port": 3010
        },
        {
            "ip": "127.0.0.1",
            "port": 3020
        }
    ]
}

В этом случае достать их прежним способом мы уже, разумеется, не сможем. Ведь теперь у нас есть один JSON-объект на верхнем уровне, содержащий ключ, которому соответствует уже известный нам массив хостов. Поэтому определим ещё один тип:

data Hosts = Hosts [Host]

Значение типа Hosts хранит список хостов. А раз появился новый тип, нужно и ещё одно парсинговое правило. Определим экземпляр класса FromJSON для типа Hosts:

instance FromJSON Hosts where
    parseJSON (Object hosts) = do
        anArray <- hosts .: "hosts"
        return $ Hosts anArray

Тут всё точно так как и прежде, ведь на верхнем иерархическом уровне у нас располагается обычный JSON-объект, с единственным ключом “hosts”. Поэтому главный код остаётся без изменений:

main :: IO ()
main = do
    rawJSON <- B.readFile "/Users/dshevchenko/our.json"
    let result = decodeStrict rawJSON
    putStrLn $ case result of
        Nothing            -> "Invalid JSON!"
        Just (Hosts hosts) -> show $ printPretty <$> hosts

И наш результат:

["127.0.0.1:3000","127.0.0.1:3010","127.0.0.1:3020"]

И вновь в JSON

Сделаем обратное действие: сформируем JSON с именованным списком хостов. И поскольку у нас появился тип Hosts, определим правило превращения в JSON и для него:

instance ToJSON Hosts where
    toJSON (Hosts hosts) = object [ "hosts" .= hosts
                                  ]

Полагаю, тут уже всё ясно без комментариев. А теперь пишем:

main :: IO ()
main =
    let hosts = [ Host "127.0.0.1" 3050
                , Host "127.0.0.1" 3070
                , Host "127.0.0.1" 3080
                ]
    in LC.putStrLn $ encodePretty $ Hosts hosts

Как видите, мы передали на сериализацию уже не голый список, а значение типа Hosts (для этого нам и понадобился экземпляр класса ToJSON для данного типа). И вот результат:

{
    "hosts": [
        {
            "ip": "127.0.0.1",
            "port": 3050
        },
        {
            "ip": "127.0.0.1",
            "port": 3070
        },
        {
            "ip": "127.0.0.1",
            "port": 3080
        }
    ]
}

Выводы

Как мы убедились, использовать aeson для работы с простым JSON совершенно несложно. Лично мне очень понравилось. Но, как я и сказал, всё это только азы. В будущих заметках мы изучим более продвинутые возможности этого пакета.