Использование Haskell при поддержке критически важной для бизнеса информационной системы

Дмитрий Астапов

Аннотация: Статья рассказывает о том, как язык функционального программирования Haskell использовался автором в качестве инструментального средства для решения прикладных задач, возникавших в процессе развития и поддержки критически важной для бизнеса информационной системы в рамках крупной телекоммуникационной компании.


The article describes how Haskell functional language proved instrumental in solving practical tasks arising during development and maintenance of a certain business-critical information system in a large telecom company.



Обсуждение статьи ведётся по адресу
http://community.livejournal.com/fprog/1985.html.

1  Обзор системы управления услугами

Рассказ стоит начать с краткого описания контекста, в рамках которого существовала некая критически важная для бизнеса информационная система и связанные с ней проблемы.

Описываемые события происходили восемь лет назад, в 2001 году. В то время я работал в одном из крупнейших в Украине операторов мобильной связи, и мне было поручено отвечать за технические аспекты внедрения в компании промышленной системы управления услугами. После того, как проект внедрения был завершен, я должен был единолично отвечать за «вторую линию» поддержки и развитие системы.

Система управления услугами1 отвечает за претворение в жизнь высокоуровневых команд на управление услугами, таких как: «подключить нового абонента», «приостановить обслуживание абонента за неуплату», «активировать услугу MMS», и так далее.

Эти высокоуровневые команды должны быть преобразованы в набор низкоуровневых инструкций для телекоммуникационного оборудования (например, «активировать услугу GSM Data» или «дать абоненту доступ к GPRS APN 2»), после чего инструкции должны быть выполнены в определенном порядке нужными экземплярами коммуникационного оборудования. Система должна учитывать, что одни и те же функции в рамках сети оператора могут выполняться на разнотипном оборудовании нескольких поставщиков — например, в сети могут присутствовать коммутаторы нескольких поколений от двух поставщиков. Соответственно, система должна выбирать правильные протоколы для подключения к оборудованию, правильный набор команд для формирования инструкций, обрабатывать всевозможные исключительные ситуации и вообще всячески скрывать от других информационных систем детали и подробности процесса управления услугами.


Рис. 1: Система управления услугами

Являясь критически важной для бизнеса, система использовалась круглосуточно. В часы пик в нее поступало от 6 до 10 тысяч входящих запросов в час. Каждый запрос преобразовывался в 5—15 низкоуровневых задач, каждая из которых, в свою очередь, состояла из нескольких команд для конкретного экземпляра оборудования. Система имела дюжину различных интерфейсов к нескольким десяткам телекоммуникационных платформ.

Ошибки в обработке запросов немедленно приводили к недополучению услуг абонентами, что означало финансовые потери для компании и клиентов. Соответственно, процесс обработки запросов должен был быть отлажен до мелочей, и все изменения в нем должны были производиться со всевозможным тщанием.

2  Используемый в системе язык программирования и связанные с ним проблемы

Входящие запросы отличаются двумя основными свойствами: во-первых, все необходимые для обработки запроса данные содержатся в нем самом; во-вторых, запрос как правило имеет декларативную суть. То есть, в нем можно выделить несколько независимых друг от друга частей, которые могут быть содержательно проинтерпретированы независимо друг от друга в произвольном порядке.

Например, в рамках запроса «активировать для указанного абонента услуги SMS, MMS, GPRS, CSD, WAP-over-GPRS, WAP-over-CSD» можно выделить часть, описывающую абонента (его номер телефона, номер SIM-карты и т. п.), и части, описывающие параметры всех перечисленных услуг.

Производители системы решили, что лучше всего процесс обработки запросов организовать в виде, представляющем по сути интерпретатор императивного языка:


Рис. 2: Блок-схема работы интерпретатора

Входящий запрос представляет собой список пар «имя=значение». Все переменные, упомянутые в начальном запросе, составляют стартовое окружение. Далее на каждом шаге обработки проверяется, выполняется ли условие, сформулированное в терминах обычных операций сравнения над переменными из окружения. Если условное выражение conditionX истинно, то выполняются связанные с ним команды actionX, в противном случае происходит переход к следующему блоку «условие + команды», и так до конца списка.

Таким образом, обработка запроса сводилась к анализу переданных в запросе переменных, порождению на их основе новых, и, по окончании анализа, порождению команд на основании всего множества переменных. Запрос вида «удалить указанного абонента» мог быть обработан таким образом:

Подобная архитектура позволяла системе иметь относительно простое ядро: для каждого запроса нужно было хранить относительно небольшой контекст выполнения, отсутствовал стек вызовов или его аналог и т. д. Обработка запросов хорошо распараллеливалась на несколько процессов в рамках одного сервера или на несколько независимых серверов. Система могла не бояться достаточно серьезных сбоев инфраструктуры — регулярно сохраняя текущий контекст для всех запросов, можно было в случае сбоя легко восстановить их обработку, в том числе и на другом сервере.

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

Другими словами, для описания процесса обработки входящих запросов в системе использовался свой собственный проблемно-ориентированный язык (domain-specific language, DSL), код на котором выглядел примерно так:

10 Comment : 'NMT Convert MAIN_DIRNUM -> NMT_PRIMARY_RID' Cond : Equals{$MARKET,"NMT"} Oper : Regexp{$MAIN_DIRNUM=(.....)(...)(.*),$MTX_DUO=/2/, $TEMP_REMAIN=/3/} AND Concat{$NMT_MTX_NUMBER,$MTX_DUO,$TEMP_REMAIN} AND Regexp{$NMT_MTX_NUMBER=(.)(.*), $NMT_EDI_MSISDN_11_1=/2/} AND Lookup{mtxridlookup,$MTX_DUO,$PORT_DUO} AND Concat{$NMT_PRIMARY_RID,$PORT_DUO,$TEMP_REMAIN} 20 Comment : 'RID2 conversions' Cond : Equals{$MARKET,"NMT"} Oper : Regexp{$EDI2_NEW_PORT=(......)(.*),$NMT_RID2=/2/} 30 Comment : 'NMT Convert MAIN_DIRNUM -> NMT_VMS_NUMBER' Cond : Equals{$MARKET,"NMT"} Oper : Regexp{$MAIN_DIRNUM=(.....)(...)(.*), $TEMP_DIRNUM=/2/,$TEMP_REMAIN=/3/} AND Lookup{vmslookup,$TEMP_DIRNUM,$VMS_TRIPLET} AND Concat{$NMT_VMS_NUMBER,$VMS_TRIPLET,$TEMP_REMAIN}

Можно заметить, что во всех трех блоках сопоставление с регулярным выражением используется для того, чтобы реализовать отсечение нескольких первых символов от значения переменной. Учитывая ограниченность синтаксиса, программа просто изобиловала подобными ухищрениями.

Приведенный выше текст программы — это выдержка из системного отчета, который выводил всю DSL-программу в красивом читаемом текстовом виде. В самой же системе редактирование текста DSL-программы (или бизнес-логики, как я буду называть её в этой статье) осуществлялось с помощью графического интерфейса.

Интерфейс был типичным для всех нишевых продуктов, а в телекоммуникациях таких продуктов — большинство. Производители программного продукта в первую очередь фокусируют усилия на создании хорошего «ядра» продукта. При этом авторы программ не уделяют интерфейсам пользователя должного внимания, так как зачастую сами ими не пользуются. В описываемой системе текст бизнес-логики можно было редактировать частями, по одному выражению за раз, выбирая имена операторов из выпадающих списков. О какой-либо поддержке процесса разработки не было и речи — интерфейс не предоставлял даже функции поиска с заменой, не говоря уже о чем-то более сложном2.


Рис. 3: Снимок экрана графического интерфейса системы

Естественно, что сколько-нибудь существенная модификация текста бизнес-логики с помощью этого пользовательского интерфейса почти наверняка вносила глупые ошибки, выявить которые можно было только с помощью тестирования.

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

После окончания тестирования необходимо было перенести изменения из тестовой системы в промышленную. Никаких специальных инструментов для этого не существовало, перенос изменений по задумке авторов системы выполнялся вручную. Рядом располагались два интерфейсных окна: одно от тестовой системы, второе — от промышленной, и изменения вдумчиво переписывались от руки. Естественно, вероятность что-то при этом пропустить или ненамеренно изменить была весьма высока.

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

И все было бы ничего, если бы в реализации системного DSL не обнаружились сложновоспроизводимые ошибки. Например, все переменные по умолчанию имели специальное значение NULL, которое, в принципе, могло быть присвоено другой переменной. Однако, присвоение значения NULL иногда приводило к тому, что интерпретатор без всякой диагностики пропускал остаток программного блока после такого присваивания. Такое поведение проявлялось только под большой нагрузкой, и нам стоило больших трудов докопаться до первопричины. До тех пор, пока производитель не устранит ошибку в своем коде, необходимо было найти способ как-то обходить эту ошибку. Логично было бы не допускать присваивания NULL вообще, но как это отследить?

3  Постановка задачи

Из вышесказанного становится ясно, что для успешной поддержки этой критически важной для бизнеса системы требовалось радикально изменить все этапы стандартного жизненного цикла поддержки и развития системы.

Если бы информационная система была разработана «под заказ», естественным решением было бы заказать её доработку. Если бы она была доступна в исходных кодах, можно было бы потенциально думать над тем, чтобы произвести все необходимые изменения самостоятельно. Однако система представляла собой коробочный программный продукт, в связи с чем сравнительно быстрого и/или недорого способа получить требуемую функциональность не предвиделось.

Кроме того, поскольку я отвечал за поддержку системы самостоятельно, без помощников, и это была далеко не единственная моя обязанность, рассчитывать на внедрение практики парного программирования, рецензирования кода или иные подобные организационные методы не приходилось.

В результате я решил разработать отдельный набор инструментов, которые позволили бы:

4  Написание инструментальных средств на Haskell

Поскольку система предоставляла возможность экспортировать полный текст бизнес-логики в текстовый файл, вопрос с контролем версий был частично решен путем регулярного размещения этого текста в корпоративной системе контроля версий.

В самой системе текст бизнес-логики хранился в обработанном виде в нескольких таблицах в СУБД Oracle. После изучения схемы базы на языке Haskell был написан компилятор, который выполнял синтаксический разбор текстового файла с бизнес-логикой и преобразование его в набор SQL-выражений, замещающих код бизнес-логики в системе новой версией.

В результате появилась возможность не только экспортировать бизнес-логику из системы в текстовый файл, но и импортировать подобный текстовый файл обратно. После этого вся разработка начала вестись в нормальном текстовом редакторе3. Кроме того, появилась возможность автоматически переносить изменения из тестовой системы в промышленную без вмешательства человека.

Далее при помощи модуля синтаксического анализатора из вышеупомянутого компилятора был построен интерпретатор бизнес-логики, который принимал на вход пакет файлов с входящими запросами и эмулировал работу ядра системы.

На выходе получались протоколы, в которых было указано, какие команды для какого оборудования были сформированы, и в каком порядке они будут исполняться. Кроме того, при необходимости интерпретатор мог выдать полную «трассу» исполнения бизнес-логики с указанием, какие переменные были модифицированы на каждом шаге, с каким результатом были вычислены все части условного выражения в рамках каждого программного блока, и полным перечислением состояния переменных после каждого блока. Файлы-протоколы формировались в виде, облегчающем их обработку стандартными утилитами grep, diff и т. п. В результате появилась возможность наладить нормальное тестирование при внесении изменений в бизнес-логику.

После этого на базе интерпретатора был сделан «детектор багов», который для данной программы бизнес-логики и большого массива запросов проверял, не возникают ли в ходе их обработки условия, в которых могут срабатывать известные ошибки в системе. В частности, идентифицировались все случаи присваивания NULL и для них выдавалась диагностика о том, в каком месте программы это произошло, при обработке каких переменных, и как выглядит полная трасса исполнения до этого момента.

Кроме этого, на базе интерпретатора был сделан инструмент, позволяющий автоматически формировать репрезентативную выборку входящих запросов. Запросы, прошедшие через систему за какой-то достаточно большой период (месяц или квартал), разбивались на классы эквивалентности по следующему критерию: все запросы, которые генерировали похожую трассу исполнения и порождали один и тот же набор команд (с точностью до значений переменных типа «номер телефона», «номер SIM-карты» и т. п.), считались эквивалентными. Из каждого класса эквивалентности в репрезентативную выборку попадал только один запрос. Полученный набор запросов использовался для регрессионного тестирования и поиска программных блоков, которые по каким-либо причинам ни разу не были использованы при обработке всех этих запросов.

Также на базе интерпретатора был сделан аналог утилиты «sdiff»4 для результатов работы бизнес-логики. На вход ей подавались две версии бизнес-логики и набор входных запросов для тестирования, а утилита генерировала отчет о том, на каких входных запросах поведение программ различается и в чем именно заключаются эти различия. Отчет включал в себя подробный перечень того, чем отличаются трассы исполнения обеих версий программы. На примере этой утилиты можно проиллюстрировать, как выглядел код разрабатываемых инструментальных средств:

diffBusinessLogic oldLogic newLogic request = let context = mkInitContext request oldLogicTrace = runAndTrim context oldLogic newLogicTrace = runAndTrim context newLogic in if newLogicTrace == oldLogicTrace then return () else do printSectionHeader printRequest request printAligned $ suppressEquals oldLogicTrace newLogicTrace where runAndTrim context logic = trimVolatileVars $ run context logic

Функция run, предоставляемая интерпретатором, преобразует запрос и бизнес-логику в трассу исполнения. Функция trimVolatileVars, взятая из генератора репрезентативной выборки, удаляет из трассы исполнения все упоминания переменных, которые обязательно разнятся от запроса к запросу (порядковый номер запроса и тому подобные служебные переменные). Если обработанные таким образом трассы исполнения различаются, то они выводятся в два столбца (при помощи функции printAligned), при этом в выводе подавляются (функцией suppressEquals) упоминания переменных и выражений, которые в обеих трассах имеют одинаковые значения.

Именно с помощью этой утилиты и проводилось тестирование новых версий бизнес-логики перед передачей в промышленную эксплуатацию. В частности, если было известно, что новая версия бизнес-логики отличается от старой только обработкой запросов, включающих в себя входную переменную FOO, то репрезентативная выборка запросов при помощи grep разделялась на две части: запросы, содержащие переменную FOO, и запросы, её не включающие. После чего с помощью «sdiff»-подобной утилиты проверялось, что старая и новая версии обрабатывают все запросы из первой группы по-разному, а все запросы из второй группы - одинаково.

Наконец, на базе библиотеки QuickCheck был сделан генератор случайных (но не произвольных!) входных запросов. В частности, было известно, что номера телефонов абонентов могут принадлежать только некоторому определенному диапазону, номера IMSI тоже определенным образом ограничены, перечень услуг известен и конечен и так далее. Используя эти знания, можно было генерировать запросы, корректные по структуре, но не обязательно непротиворечивые и правильные по содержанию. Они использовались для тестирования бизнес-логики на «дуракоустойчивость».

Полный перечень разработанных инструментальных средств и отношения между ними можно увидеть на рисунке 4.


Рис. 4: Полный перечень разработанных инструментальных средств

5  Достигнутые результаты

Главный результат заключался в том, что удалось уйти от использования убогого графического интерфейса и ручной работы по переносу кода из тестовой системы в промышленную. Удалось наладить полноценный контроль версий разрабатываемого кода.

Удалось устранить падения системы под нагрузкой из-за ошибок в системном интерпретаторе бизнес-логики. Сам производитель окончательно устранил все трудно обнаруживаемые ошибки в области исполнения бизнес-логики только спустя полтора года после появления на свет нашего «детектора багов».

Удалось радикально повысить качество разработки новых версий бизнес-логики: большинство изменений проходили приемку тестировщиками с первого раза и не имели проблем в ходе промышленной эксплуатации. Работу, которая раньше занимала неделю календарного времени, теперь реально было сделать в течение одного рабочего дня.

Общий объем написанного мною кода — примерно 1600 строк на Haskell с обширными многострочными комментариями. В том числе:


Программный модульКоличество строк
Типы данных абстрактного синтаксического дерева80
Парсеры бизнес-логики и запросов190
Компилятор в SQL100
Интерпретатор бизнес-логики280
Утилита «sdiff»100
Построитель «репрезентативной выборки»300
Генератор запросов на QuickCheck300
Таблица 1: Объем кода программных модулей, в строках

Общее время разработки оценить тяжело, так как между реализацией отдельных модулей были значительные перерывы, но, судя по записям в системе контроля версий, интерпретатор был написан примерно за неделю.

Почему же был выбран именно Haskell и в чем же, в ретроспективе, оказались его преимущества?

Поскольку довольно долгое время я занимался поддержкой системы в одиночку, причем это не было моим основным занятием, то моей главной целью было как можно скорее достигнуть практических результатов с минимальными затратами времени и сил. В таких условиях я мог позволить себе взять любое инструментальное средство.

Я выбрал Haskell, который уже тогда привлекал меня простотой и скоростью разработки. Кроме того, мне импонировала возможность положиться на механизм вывода типов и писать код, который по возможности будет «правильным по построению». Ведь я замахивался на то, чтобы своим интерпретатором находить и устранять ошибки в другом интерпретаторе, и мне вряд ли удалось бы это, будь в моем интерпретаторе свои уникальные ошибки.

В числе прочих преимуществ Haskell можно назвать:

Все это в сумме приводило к тому, что в большинстве случаев код, в соответствии с расхожей присказкой про Haskell, правильно работал после первой же успешной компиляции. Благодаря относительно небольшому объему всего проекта и наличию тестов, можно было смело и радикально переписывать любые части проекта. В результате большая часть времени посвящалась решению прикладной задачи и алгоритмическим оптимизациям, а не низкоуровневому программированию и борьбе с ограничениями языка.

Таким образом, считающийся «академическим» язык был с большим успехом применен для решения практических повседневных задач в критичной для бизнеса области.

6  Постскриптум

Я делал доклад о применении описанных в этой статье инструментальных средств на ежегодном собрании пользователей продуктов Comptel в 2003 году. В 2005 году в очередной версии системы проявился нормальный графический интерфейс пользователя, инструменты для миграции кода между тестовыми и промышленными экземплярами системы, а также инструменты для отладки, функциональность которых во многом повторяла мои персональные наработки.

Я считаю, что этот доклад (и интерес к нему со стороны других клиентов) был одним из факторов, ускоривших появление этих нововведений и в значительной мере определивших их функциональность.


1
Речь идет о продукте Comptel MDS/SAS, ныне известном как Comptel InstantLink.
2
Тут хотелось бы заметить, что подобное наплевательское отношение к инженерам, обслуживающим системы, не является прерогативой какой-то одной компании. Большинство программных и программно-аппаратных продуктов, с которыми мне довелось иметь дело за время работы в телекоммуникациях, имели пользовательские интерфейсы, на которые нельзя было смотреть без слез.
3
Использовался emacs, для которого был сделан свой модуль подсветки синтаксиса.
4
Утилита «sdiff» выводит текст сравниваемых файлов в две колонки, обозначая вставки, удаления и правки в сравниваемых текстах.

Этот документ был получен из LATEX при помощи HEVEA