Использование 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»), после чего инструкции должны быть выполнены в определенном порядке нужными экземплярами коммуникационного оборудования. Система должна учитывать, что одни и те же функции в рамках сети оператора могут выполняться на разнотипном оборудовании нескольких поставщиков — например, в сети могут присутствовать коммутаторы нескольких поколений от двух поставщиков. Соответственно, система должна выбирать правильные протоколы для подключения к оборудованию, правильный набор команд для формирования инструкций, обрабатывать всевозможные исключительные ситуации и вообще всячески скрывать от других информационных систем детали и подробности процесса управления услугами.
Являясь критически важной для бизнеса, система использовалась круглосуточно. В часы пик в нее поступало от 6 до 10 тысяч входящих запросов в час. Каждый запрос преобразовывался в 5—15 низкоуровневых задач, каждая из которых, в свою очередь, состояла из нескольких команд для конкретного экземпляра оборудования. Система имела дюжину различных интерфейсов к нескольким десяткам телекоммуникационных платформ.
Ошибки в обработке запросов немедленно приводили к недополучению услуг абонентами, что означало финансовые потери для компании и клиентов. Соответственно, процесс обработки запросов должен был быть отлажен до мелочей, и все изменения в нем должны были производиться со всевозможным тщанием.
2 Используемый в системе язык программирования и связанные с ним проблемы
Входящие запросы отличаются двумя основными свойствами: во-первых, все необходимые для обработки запроса данные содержатся в нем самом; во-вторых, запрос как правило имеет декларативную суть. То есть, в нем можно выделить несколько независимых друг от друга частей, которые могут быть содержательно проинтерпретированы независимо друг от друга в произвольном порядке.
Например, в рамках запроса «активировать для указанного абонента услуги SMS, MMS, GPRS, CSD, WAP-over-GPRS, WAP-over-CSD» можно выделить часть, описывающую абонента (его номер телефона, номер SIM-карты и т. п.), и части, описывающие параметры всех перечисленных услуг.
Производители системы решили, что лучше всего процесс обработки запросов организовать в виде, представляющем по сути интерпретатор императивного языка:
Входящий запрос представляет собой список пар «имя=значение». Все
переменные, упомянутые в начальном запросе, составляют стартовое
окружение. Далее на каждом шаге обработки проверяется,
выполняется ли условие, сформулированное в терминах обычных операций
сравнения над переменными из окружения. Если условное выражение
conditionX
истинно, то выполняются связанные с ним команды
actionX
, в противном случае происходит переход к следующему
блоку «условие + команды», и так до конца списка.
Таким образом, обработка запроса сводилась к анализу переданных в запросе переменных, порождению на их основе новых, и, по окончании анализа, порождению команд на основании всего множества переменных. Запрос вида «удалить указанного абонента» мог быть обработан таким образом:
-
В запросе присутствует переменная
$IMSI
? Значит, речь идет об абоненте сети GSM, выполняемMARKET
="
GSM
"
. - Если
(
$MARKET
=="
GSM
")
и в списке услуг абонента есть MMS, то надо удалить его inbox на MMS-платформе. ВыполняемMMS_ACTION
="
DELETE
"
. - …и так далее для всех прочих услуг, которые требуют отдельного удаления учетных записей.
- Если
(
$MARKET
=="
GSM
") && (
$MMS_ACTION
<>"")
, то вычисляем ID абонента на MMS-платформе по его номеру телефона и SIM-карты. - …и тому подобное для всех прочих услуг.
- Для каждого запланированного действия находим его приоритет в справочной таблице.
- Преобразуем все действия в команды для оборудования и выполняем в порядке возрастания приоритета.
Подобная архитектура позволяла системе иметь относительно простое ядро: для каждого запроса нужно было хранить относительно небольшой контекст выполнения, отсутствовал стек вызовов или его аналог и т. д. Обработка запросов хорошо распараллеливалась на несколько процессов в рамках одного сервера или на несколько независимых серверов. Система могла не бояться достаточно серьезных сбоев инфраструктуры — регулярно сохраняя текущий контекст для всех запросов, можно было в случае сбоя легко восстановить их обработку, в том числе и на другом сервере.
К сожалению, команд в языке было всего пять:
-
Присвоение константного значения новой или существующей переменной
«окружения»:
Assign
(
var
,
constant
)
. - Присвоение переменной значения, полученного конкатенацией
значений других переменных и констант:
Concat
(
destination
,
$foo
,
$bar
, "
baz
")
. - Сопоставление значения переменной с регулярным
выражением и присвоение результата сопоставления всего выражения и
входящих в него групп другим переменным:
Regexp
(
$var1
,
regexp
,
full_match
,
group_1_match
,
group_2_match
, ...)
. - Извлечение значения из внешнего «словаря» (базы данных),
используя значение переменной в качестве «ключа»:
Lookup
("
datasource
",
$key
,
value
)
. - Отправка на исполнение устройству X команды, составленной
из шаблона T, заполненного значениями переменных S1, S2, S3…:
Command
("
X
",
$T
,
$S1
,
$S2
,
$S3
, ...)
.
Другими словами, для описания процесса обработки входящих запросов в системе использовался свой собственный проблемно-ориентированный язык (domain-specific language, DSL), код на котором выглядел примерно так:
Можно заметить, что во всех трех блоках сопоставление с регулярным выражением используется для того, чтобы реализовать отсечение нескольких первых символов от значения переменной. Учитывая ограниченность синтаксиса, программа просто изобиловала подобными ухищрениями.
Приведенный выше текст программы — это выдержка из системного отчета, который выводил всю DSL-программу в красивом читаемом текстовом виде. В самой же системе редактирование текста DSL-программы (или бизнес-логики, как я буду называть её в этой статье) осуществлялось с помощью графического интерфейса.
Интерфейс был типичным для всех нишевых продуктов, а в телекоммуникациях таких продуктов — большинство. Производители программного продукта в первую очередь фокусируют усилия на создании хорошего «ядра» продукта. При этом авторы программ не уделяют интерфейсам пользователя должного внимания, так как зачастую сами ими не пользуются. В описываемой системе текст бизнес-логики можно было редактировать частями, по одному выражению за раз, выбирая имена операторов из выпадающих списков. О какой-либо поддержке процесса разработки не было и речи — интерфейс не предоставлял даже функции поиска с заменой, не говоря уже о чем-то более сложном2.
Естественно, что сколько-нибудь существенная модификация текста бизнес-логики с помощью этого пользовательского интерфейса почти наверняка вносила глупые ошибки, выявить которые можно было только с помощью тестирования.
Тут крылась следующая проблема: для нужд тестирования имелся второй экземпляр системы. Тестирование заключалось в том, что тестировщик отправлял на исполнение в тестовом экземпляре системы пачку запросов, а потом вручную исследовал команды, которые были отправлены на оборудование, и результаты их работы. Проблема заключалась в том, что генерация команд даже для дюжины запросов требовала от тестировщика значительного объема ручной работы. Один проход тестирования даже небольшого изменения занимал несколько часов, требовал большого напряжения внимания и не ловил ошибки, случайно внесенные в те ветви бизнес-логики, которые не должны были изменяться и, соответственно, не тестировались.
После окончания тестирования необходимо было перенести изменения из тестовой системы в промышленную. Никаких специальных инструментов для этого не существовало, перенос изменений по задумке авторов системы выполнялся вручную. Рядом располагались два интерфейсных окна: одно от тестовой системы, второе — от промышленной, и изменения вдумчиво переписывались от руки. Естественно, вероятность что-то при этом пропустить или ненамеренно изменить была весьма высока.
Производитель, естественно, обещал золотые горы и новый, радикально измененный интерфейс пользователя буквально в следующей версии системы, которая выйдет буквально через год-два. До тех пор предлагалось довольствоваться тем, что есть.
И все было бы ничего, если бы в реализации системного DSL не
обнаружились сложновоспроизводимые ошибки. Например, все переменные по
умолчанию имели специальное значение NULL
, которое, в принципе,
могло быть присвоено другой переменной. Однако, присвоение значения
NULL
иногда приводило к тому, что интерпретатор без всякой
диагностики пропускал остаток программного блока после такого
присваивания. Такое поведение проявлялось только под большой нагрузкой, и
нам стоило больших трудов докопаться до первопричины. До тех пор, пока
производитель не устранит ошибку в своем коде, необходимо было найти способ
как-то обходить эту ошибку. Логично было бы не допускать
присваивания NULL
вообще, но как это отследить?
3 Постановка задачи
Из вышесказанного становится ясно, что для успешной поддержки этой критически важной для бизнеса системы требовалось радикально изменить все этапы стандартного жизненного цикла поддержки и развития системы.
Если бы информационная система была разработана «под заказ», естественным решением было бы заказать её доработку. Если бы она была доступна в исходных кодах, можно было бы потенциально думать над тем, чтобы произвести все необходимые изменения самостоятельно. Однако система представляла собой коробочный программный продукт, в связи с чем сравнительно быстрого и/или недорого способа получить требуемую функциональность не предвиделось.
Кроме того, поскольку я отвечал за поддержку системы самостоятельно, без помощников, и это была далеко не единственная моя обязанность, рассчитывать на внедрение практики парного программирования, рецензирования кода или иные подобные организационные методы не приходилось.
В результате я решил разработать отдельный набор инструментов, которые позволили бы:
- Облегчить разработку. Получить, по меньшей мере, возможность использовать копирование/вставку текста и поиск с заменой.
- Облегчить тестирование новых версий бизнес-логики. Необходимо было, как минимум, получить возможность легко проводить регрессионное тестирование: то есть, проверять, что внесенные изменения имеют локальный эффект и не затрагивают сторонние ветки кода. На случай обнаружения ошибок были необходимы какие-то средства пошаговой отладки или аналог отладочной печати.
- Обеспечить перенос новых версий кода из тестовой системы в промышленную без участия человека, чтобы исключить вероятность внесения изменений в код после его тестирования.
- Обеспечить контроль за версиями бизнес-логики, получить возможность параллельно вести разработку нескольких альтернативных вариантов кода.
4 Написание инструментальных средств на Haskell
Поскольку система предоставляла возможность экспортировать полный текст бизнес-логики в текстовый файл, вопрос с контролем версий был частично решен путем регулярного размещения этого текста в корпоративной системе контроля версий.
В самой системе текст бизнес-логики хранился в обработанном виде в нескольких таблицах в СУБД Oracle. После изучения схемы базы на языке Haskell был написан компилятор, который выполнял синтаксический разбор текстового файла с бизнес-логикой и преобразование его в набор SQL-выражений, замещающих код бизнес-логики в системе новой версией.
В результате появилась возможность не только экспортировать бизнес-логику из системы в текстовый файл, но и импортировать подобный текстовый файл обратно. После этого вся разработка начала вестись в нормальном текстовом редакторе3. Кроме того, появилась возможность автоматически переносить изменения из тестовой системы в промышленную без вмешательства человека.
Далее при помощи модуля синтаксического анализатора из вышеупомянутого компилятора был построен интерпретатор бизнес-логики, который принимал на вход пакет файлов с входящими запросами и эмулировал работу ядра системы.
На выходе получались протоколы, в которых было указано, какие команды
для какого оборудования были сформированы, и в каком порядке они будут
исполняться. Кроме того, при необходимости интерпретатор мог выдать полную
«трассу» исполнения бизнес-логики с указанием, какие переменные
были модифицированы на каждом шаге, с каким результатом были вычислены
все части условного выражения в рамках каждого программного блока, и полным
перечислением состояния переменных после каждого блока.
Файлы-протоколы формировались в виде, облегчающем их обработку
стандартными утилитами grep
, diff
и т. п. В
результате появилась возможность наладить нормальное тестирование
при внесении изменений в бизнес-логику.
После этого на базе интерпретатора был сделан «детектор багов»,
который для данной программы бизнес-логики и большого массива
запросов проверял, не возникают ли в ходе их обработки условия, в
которых могут срабатывать известные ошибки в системе. В частности,
идентифицировались все случаи присваивания NULL
и для
них выдавалась диагностика о том, в каком месте программы это
произошло, при обработке каких переменных, и как выглядит полная
трасса исполнения до этого момента.
Кроме этого, на базе интерпретатора был сделан инструмент, позволяющий автоматически формировать репрезентативную выборку входящих запросов. Запросы, прошедшие через систему за какой-то достаточно большой период (месяц или квартал), разбивались на классы эквивалентности по следующему критерию: все запросы, которые генерировали похожую трассу исполнения и порождали один и тот же набор команд (с точностью до значений переменных типа «номер телефона», «номер SIM-карты» и т. п.), считались эквивалентными. Из каждого класса эквивалентности в репрезентативную выборку попадал только один запрос. Полученный набор запросов использовался для регрессионного тестирования и поиска программных блоков, которые по каким-либо причинам ни разу не были использованы при обработке всех этих запросов.
Также на базе интерпретатора был сделан аналог утилиты «sdiff»4 для результатов работы бизнес-логики. На вход ей подавались две версии бизнес-логики и набор входных запросов для тестирования, а утилита генерировала отчет о том, на каких входных запросах поведение программ различается и в чем именно заключаются эти различия. Отчет включал в себя подробный перечень того, чем отличаются трассы исполнения обеих версий программы. На примере этой утилиты можно проиллюстрировать, как выглядел код разрабатываемых инструментальных средств:
Функция run
, предоставляемая интерпретатором, преобразует
запрос и бизнес-логику в трассу исполнения. Функция
trimVolatileVars
, взятая из генератора репрезентативной
выборки, удаляет из трассы исполнения все упоминания переменных,
которые обязательно разнятся от запроса к запросу (порядковый номер
запроса и тому подобные служебные переменные). Если обработанные таким
образом трассы исполнения различаются, то они выводятся в два столбца
(при помощи функции printAligned
), при этом в выводе
подавляются (функцией suppressEquals
) упоминания переменных и
выражений, которые в обеих трассах имеют одинаковые значения.
Именно с помощью этой утилиты и проводилось тестирование
новых версий бизнес-логики перед передачей в промышленную
эксплуатацию. В частности, если было известно, что новая версия
бизнес-логики отличается от старой только обработкой запросов,
включающих в себя входную переменную FOO
, то репрезентативная
выборка запросов при помощи grep
разделялась на две части:
запросы, содержащие переменную FOO
, и запросы, её не
включающие. После чего с помощью «sdiff»-подобной утилиты проверялось, что старая и
новая версии обрабатывают все запросы из первой группы по-разному, а
все запросы из второй группы - одинаково.
Наконец, на базе библиотеки QuickCheck
был сделан генератор случайных (но не
произвольных!) входных запросов. В частности, было известно, что номера
телефонов абонентов могут принадлежать только некоторому определенному диапазону, номера
IMSI тоже определенным образом ограничены, перечень услуг
известен и конечен и так далее.
Используя эти знания, можно было генерировать запросы, корректные по структуре, но не
обязательно непротиворечивые и правильные по содержанию. Они
использовались для тестирования бизнес-логики на «дуракоустойчивость».
Полный перечень разработанных инструментальных средств и отношения между ними можно увидеть на рисунке 4.
5 Достигнутые результаты
Главный результат заключался в том, что удалось уйти от использования убогого графического интерфейса и ручной работы по переносу кода из тестовой системы в промышленную. Удалось наладить полноценный контроль версий разрабатываемого кода.
Удалось устранить падения системы под нагрузкой из-за ошибок в системном интерпретаторе бизнес-логики. Сам производитель окончательно устранил все трудно обнаруживаемые ошибки в области исполнения бизнес-логики только спустя полтора года после появления на свет нашего «детектора багов».
Удалось радикально повысить качество разработки новых версий бизнес-логики: большинство изменений проходили приемку тестировщиками с первого раза и не имели проблем в ходе промышленной эксплуатации. Работу, которая раньше занимала неделю календарного времени, теперь реально было сделать в течение одного рабочего дня.
Общий объем написанного мною кода — примерно 1600 строк на Haskell с обширными многострочными комментариями. В том числе:
Общее время разработки оценить тяжело, так как между реализацией отдельных модулей были значительные перерывы, но, судя по записям в системе контроля версий, интерпретатор был написан примерно за неделю.
Почему же был выбран именно Haskell и в чем же, в ретроспективе, оказались его преимущества?
Поскольку довольно долгое время я занимался поддержкой системы в одиночку, причем это не было моим основным занятием, то моей главной целью было как можно скорее достигнуть практических результатов с минимальными затратами времени и сил. В таких условиях я мог позволить себе взять любое инструментальное средство.
Я выбрал Haskell, который уже тогда привлекал меня простотой и скоростью разработки. Кроме того, мне импонировала возможность положиться на механизм вывода типов и писать код, который по возможности будет «правильным по построению». Ведь я замахивался на то, чтобы своим интерпретатором находить и устранять ошибки в другом интерпретаторе, и мне вряд ли удалось бы это, будь в моем интерпретаторе свои уникальные ошибки.
В числе прочих преимуществ Haskell можно назвать:
- Возможность «встроить» код парсера непосредственно в код основной программы при помощи библиотеки комбинаторов парсеров Parsec.
- Богатый набор
контейнерных типов, предоставляемых языком и стандартными
библиотеками (списки, массивы, хэш-таблицы, отображения
map
). - Широкий арсенал средств для написания кода на высоком
уровне абстракции и комбинирования решения из частей: функции высших
порядков, монады
Reader
,Writer
иState
. - Возможность тестировать свой код на псевдо-случайных входных данных,
автоматически генерируемых на основании типов тестируемых функций
библиотекой
QuickCheck
.
Все это в сумме приводило к тому, что в большинстве случаев код, в соответствии с расхожей присказкой про Haskell, правильно работал после первой же успешной компиляции. Благодаря относительно небольшому объему всего проекта и наличию тестов, можно было смело и радикально переписывать любые части проекта. В результате большая часть времени посвящалась решению прикладной задачи и алгоритмическим оптимизациям, а не низкоуровневому программированию и борьбе с ограничениями языка.
Таким образом, считающийся «академическим» язык был с большим успехом применен для решения практических повседневных задач в критичной для бизнеса области.
6 Постскриптум
Я делал доклад о применении описанных в этой статье инструментальных средств на ежегодном собрании пользователей продуктов Comptel в 2003 году. В 2005 году в очередной версии системы проявился нормальный графический интерфейс пользователя, инструменты для миграции кода между тестовыми и промышленными экземплярами системы, а также инструменты для отладки, функциональность которых во многом повторяла мои персональные наработки.
Я считаю, что этот доклад (и интерес к нему со стороны других клиентов) был одним из факторов, ускоривших появление этих нововведений и в значительной мере определивших их функциональность.
- 1
- Речь идет о продукте Comptel MDS/SAS, ныне известном как Comptel InstantLink.
- 2
- Тут хотелось бы заметить, что подобное наплевательское отношение к инженерам, обслуживающим системы, не является прерогативой какой-то одной компании. Большинство программных и программно-аппаратных продуктов, с которыми мне довелось иметь дело за время работы в телекоммуникациях, имели пользовательские интерфейсы, на которые нельзя было смотреть без слез.
- 3
- Использовался emacs, для которого был сделан свой модуль подсветки синтаксиса.
- 4
- Утилита «sdiff» выводит текст сравниваемых файлов в две колонки, обозначая вставки, удаления и правки в сравниваемых текстах.
Этот документ был получен из LATEX при помощи HEVEA