Parsing different JSON payloads into a single data structure

1 / 5 on ldap-bot February 27, 2020 &russian @code #haskell #aeson

Что-то давно не видно технических статей, спешу исправиться. В связи с задержкой, “градус гиковости” будет временно значительно повышен. В публикации на прошлой неделе я рассказал про новый бот для Workplace, который помогает управлять проектными (и не только) группами не прибегая к помощи HelpDesk. Он написан на языке программирования Haskell, что для нашей компании выбор не совсем типичный (хотя с RFX-ами на эту тему к нам обращались). Знаниями надо делиться, так что запланировал несколько статей по мотивам написания этого бота. Статьи будут раскрывать некоторые интересные аспекты реализации, которые, на первый взгляд, могут показаться странными или неочевидными, но все же позволяют по новому взглянуть на типовые задачи, возникающие при разработке ПО.

Почему Haskell?

Меня всегда привлекал этот язык программирования своей строгостью, лаконичностью и близостью к математике. Однако, написать на нем что-то более-менее крупное – шанса все не представлялось. Да, были небольшие pet-проекты, курсы по решению алгоритмических задач, но до “полноценного” production использования дело не доходило.

Но недавно я в очередной раз посетил конференцию по функциональному программированию - F(by) и твердо решил – в этот раз (пока мотивация от докладов не прошла) – надо обязательно это сделать! Задача возникла совершенно естественным способом, из рутины. В настоящее время, многих менеджеров (а в последствии и всех остальных сотрудников) перевели на использование облачных учетных записей Microsoft Office. У них пропала возможность самостоятельно редактировать состав проектных групп.

Дело в том, что синхронизация между наземным хранилищем и “облаком” может быть настроена только однонаправленная (“земля-воздух” кхе-хе). Так как Outlook у менеджеров уже облачный, то изменения, которые они пытаются с его помощью сделать, не могут попасть в наш Active Directory. Предлагаемый MIDS путь - создавать запросы в HelpDesk - меня категорически не устраивал. Вот и пришла идея этот процесс автоматизировать.

План статей пока выходит примерно такой:

Parsing different JSON payloads into a single data structure

Чтобы не затягивать - начнем с первой темы ;)

Обычно, при разработке API Endpoint-ов принято иметь структуры данных, которые отражают принимаемый json один-к-одному. А только потом извлекать из него значения, полезные/нужные для работы программы. Так делается для… простоты. Программист знает формат json-а, который будет на входе его сервиса и либо (в случае динамического языка программирования) парсит этот json как нетипизированный Value, либо (в случае статической типизации) парсит его в экземпляр класса, отражающий структуру приходящего json-а.

Подход, сам по себе не плох, но появляется промежуточный слой DTO, для работы приложения совершенно не обязательный. Особенно если достоверно известно (как раз мой случай), что формат этого json-а вряд-ли изменится в скором времени – json-ы мне присылает Facebook.

Пропустить промежуточную DTO можно написав собственный парсер, который сразу преобразует json в нужную для работы системы структуру данных. Звучит довольно сложно, ведь все привыкли использовать для разбора json-а готовые библиотеки, основанные на аннотациях (в случае статических языков) либо парсить json “в нетипизированный объект”.

К счастью в Haskell дела с парсингом (всего, не только json) исторически обстоят намного лучше. Существуют библиотеки так называемых parsing combinator-ов, для создания эффективных парсеров при помощи композиции (композиция функций – краеугольный камень функционального программирования). С их помощью можно распарсить json прямо в нужную вам структуру данных.

Facebook (Workplace) присылает боту примерно такой json в случае поступления сообщения от пользователя:

  { "object": "page",
    "entry": [{"id": "entry_id", "time": 123,
      "messaging": [{
        "sender": {"id": "sender_id", "community": {"id": "community_id"}},
        "recipient": {"id": "recipient_id"}, "timestamp": 123,
        "message": {"mid": "mid", "text": "text"}}]}]}

И такой json в случае, если пользователь нажал на кнопку из help сообщения.

  { "object": "page",
    "entry": [{"id": "entry_id", "time": 123,
      "messaging": [{"sender": {"id": "sender_id", "community": {"id": "community_id"}},
        "recipient": {"id": "recipient_id"}, "timestamp": 123,
        "postback": {"title": "postback_title", "payload": "payload"}}]}]}

Обратите внимание на последнюю строку json сообщения, в первом случае передается message, а во втором postback. Данных много, но мне из этого всего нужен только sender_id - уникальный идентификатор отправителя (нужен для того, чтобы послать ему ответ) и text либо payload - текст сообщения, которое пользователь послал боту, либо payload (свойство payload назначается кнопке на help сообщении и присылается боту при ее нажатии пользователем).

Парсить все это я буду в такую незатейливую структуру данных:

data Messages = Messages
  { messages :: NonEmpty Message
  }

data Message = Message
  { sender_id :: String
  , text      :: String
  }

Для парсинга была выбрана стандартная для этой задачи библиотека Aeson, требующая “реализовать” интерфейс FromJSON. Не пугаемся незнакомому синтаксису, я все объясню…

instance FromJSON Messages where
  parseJSON = withObject "root object" $ \root ->
    root .: "entry" >>= fmap (Messages . fromList . toList  . join) . withArray "entries array"
      (mapM $ withObject "entry object" $ \entry ->
        entry .: "messaging" >>= withArray "messaging array"
          (mapM $ withObject "message object" $ \message ->
            Message
              <$> (message .: "sender" >>= (.: "id"))
              <*> (  (message .: "message" >>= (.: "text"))
                 <|> (message .:  "postback" >>= (.: "payload"))
                  )
          )
      )

Основой является функция withObject, первый параметр которой служебный - название объекта, который мы собираемся парсить. Первый, самый главный объект обзовём root object. Второй параметр - это λ (lambda) - то есть функция, которая на вход принимает уже распаршенный root объект и дальше вольна делать с ним все что ей хочется. А хочется ей взять (при помощи оператора .:, чтобы было похоже на разделитель : ключ-значение из json-а) из root объекта поле по ключу "entry" и начать его парсить (>>=) дальше.

Пока опустим магию fmap (Messages . fromList . toList . join), о ней позже. Что в json-е лежит по ключу "entry"? А там массив, значит необходимо воспользоваться функцией withArray первый параметр которой, по традиции - описание того, что сейчас парсим. Нужны эти описания, к слову, для того, чтобы при ошибке парсинга вывести понятную ошибку, например ошибка для json{"object": "page", "entry": 123} будет такая: parsing entries array failed, expected Array, but encountered Number. Так что наличие этих описаний полезно как для debug-а, так и для информативности ошибок будущего софта.

Парсим entry object, messaging array и message object уже знакомыми нам withObject и withArray, попутно не забывая итерироваться по ним при помощи mapM (аналог простого map, парсим мы все же массивы, на выходе тоже должны быть массивы). Подошли к самому интересному, созданию итоговых объектов Message.

Конструктор Message (в данном случае Message – это название “конструктора” для создания одноименной структуры Message), принимает две строки - sender_id и text. В Процессе парсинга, у нас нет “строк” (с типом String), есть только “парсеры, которые могут вернуть строку” (с типом Parser String). Так что приходится пользоваться операторами <$> и <*> для того, чтобы увязать парсеры строк и строки между собой. Фактически, оператором <$> мы “учим” конструктор Message принимать вместо строк - парсеры строк.

На месте первого параметра (там где должен быть sender_id) передаем парсер message .: "sender" >>= (.: "id") - его можно перевести на “человеческий” язык как “когда я буду парсить message, я возьму у него свойство sender, а у его содержимого возьму свойство id”. То есть этот парсер, способен обработать json "sender": {"id": "sender_id", "community": {"id": "community_id"}}, вернув при этом только sender_id и проигнорировав все остальное, чего нам и нужно.

Аналогичным образом можно поступить и с text только вот не всегда "message": {"mid": "mid", "text": "text"}} от Facebook в этом месте приходит, иногда ещё и "postback": {"title": "postback_title", "payload": "payload"}} может быть. Мощь и изящество parsing combinator-ов раскрывается как раз в таких случаях. Комбинатор <|> говорит - сначала попытайся применить парсер, который слева от меня (message .: "message" >>= (.: "text"), а если он вернёт ошибку парсинга - попробуй тот, который от меня справа message .: "postback" >>= (.: "payload"). В итоге, выражение (message .: "message" >>= (.: "text")) <|> (message .: "postback" >>= (.: "payload")) распарсит либо цепочку message->text либо postpack->payload и вернет строку String. Мы скомбинировали два строковых парсера и получили на выходе тоже “парсер строк”, реализующий собой операцию “выбора”, на что намекал знак | в комбинаторе <|>.

Вспомним теперь про два вложенных друг в друга mapM. На уровне root object-а получается, что мы сформировали список списков сообщений, точнее вектор векторов (так как Aeson работает с векторами а не списками) то есть Vector (Vector Message). Для его “схлопывания” применим join, превратив Vector (Vector Message) в Vector Message, затем (операцию . стоит “читать” слева направо, так как он право-ассоциативен) конвертируем Vector в список при помощи toList, список в NonEmpty (это вид списков, которые не могут быть пусты, ведь должно же в нотификации от Facebook быть хотя бы одно сообщение пользователя) при помощи fromList и передадим это все в конструктор Messages.

Ух, похоже это тот самый случай, когда объяснение кода заняло раз в 10 больше символов, чем сам код… Но что в итоге? Мы можем парсить два разных сообщения в одну структуру данных, с которой работает бот. Для него ведь не важно, сам пользователь написал в чате /help или воспользовался кнопкой-подсказкой. Реагировать бот на это должен одинаково. Тесты успешно проходят:

describe "Messages spec" $ do
  let decoding :: Text -> Messages
      decoding = fromJust . decode . pack . unpack

  it "parses text message properly" $ do
    decoding [I.text|
      { "object": "page",
        "entry": [{"id": "id", "time": 1,
          "messaging": [{
            "sender": {"id": "sender_id", "community": {"id": "id"}},
            "recipient": {"id": "id"}, "timestamp": 1,
            "message": {"mid": "mid", "text": "text"}}]}]}
    |] `shouldBe` (Messages $ (Message "sender_id" "text") :| [])

  it "parses postback message properly" $ do
    decoding [I.text|
      { "object": "page",
        "entry": [{"id": "id", "time": 1,
          "messaging": [{"sender": {"id": "sender_id", "community": {"id": "id"}},
            "recipient": {"id": "id"}, "timestamp": 1,
            "postback": {"title": "title", "payload": "payload"}}]}]}
    |] `shouldBe` (Messages $ Message "sender_id" "payload" :| [])

  it "fails to parse incomplete json" $ do
    (eitherDecode "{\"object\": \"page\"}" :: Either String Messages) `shouldBe` Left "Error in $: key \"entry\" not found"