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

В прошлый раз я объяснял, что не являюсь программистом по роду профессиональной деятельности, но использую любимое хобби для автоматизации всего, что попадается под руку в работе юриста. Я уверен, что 90% всей юридической волокиты может быть успешно автоматизировано: ведение разнообразных баз и карточек, составление документов по шаблонам, контроль сроков выполнения задач, использование любых вспомогательных сервисов, уже имеющих свои api, для прикручивания автоматизации на конкретном рабочем месте и т.д. К этому нужно стремиться, чтобы по заветам Айзека Азимова высвободить время юриста для реализации основной задачи - размышлять над условиями договора и разводить демагогию в суде.


Так вот, много лет назад я сделал очень большой проект для облегчения своей офисной работы. Он собирал все данные по товарным знакам и патентам (а их несколько сотен), контролировал сроки оплаты патентных пошлин, формировал платежные поручения, договоры, заявления, и, разумеется, выдавал разнообразные отчеты. Собственно, почему в прошедшем времени? Проект вполне рабочий. Вот только разработан был по всем возможным антипаттернам, со всеми велосипедами и костылями, какие только обнаружили на Земле. Возвращаться в этот ролтон (или доширак) код, чтобы его отрефакторить, ой, как не хотелось, ведь здесь идеально подходит мем "А давайте всё перепишем на..."

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

В один из прекрасных дней весь проект был пересобран на 64-разрядной платформе, и, к удивлению моему, упала самая любимая часть: генерация договоров и заявлений с полной автоматизацией всей грамматики и морфологии - склонение инициалов, должностей и прочих слов по нужным падежам, а также учет единственного/множественного числа. Все дело в том, что старинная дельфийская и проприетарная библиотека padegUC, перестала быть бесплатной в своей 64-битной версии.

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

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

IInitialsMorpher = interface
  function GetInitials(Initials: string): CasesResponse;
  function GetWordsCase(Words: string): CasesResponse;
  function GetGenderAndInitials(Initials: string; var Gender: TGender): CasesResponse;
end;

Для универсальности интерфейс объявляет три функции, возвращающих примерно одно и то же, но по-разному: как инициалы (т.е. с заглавными буквами), как обычные слова, что подходит, например, для склонения должности или наименования объекта, и инициалы с дополнительным определением рода, чтобы выбирать между который и которая и т.п. Структура CasesResponse представляет собой обычный строковый массив с названием падежа в качестве индекса, а Gender - литеральное перечисление:

TWordCase = (Nominative, Gentitive, Dative, Accusative, Instrumental, Prepositional);
TGender = (Male, Female, UnrecognizedGender);
CasesResponse = array[TWordCase] of string;

Для реализации интерфейса я рассматривал нескольких кандидатов. В итоге остановился на следующих трех, и сразу укажу на достоинства и недостатки:

Сервис

Достоинства

Недостатки

DaData

- Отличная документация и идеальные примеры

- Корректная работа с инициалами с учетом национальных особенностей народов России

- Определение рода

- Склонение ФИО - платный сервис

- не меняет число

Pymorphy

github

- MIT лицензия, в оригинале существует как библиотека python

- Мощнейший морфологический анализатор на словарях Corpora (правильно разберет любой новояз)

- Масса информации на выходе, в т.ч. род, число, разбор на морфемы и лексемы, словарное ядро корня и т.д.

- работает только в отношении отдельного слова, не словосочетания, т.е. не учитывает контекст

Morphos

- MIT лицензия, в оригинале существует как библиотека php

- Корректная работа с инициалами

- Определение рода

- для меня нет, но для некоторых может быть важен беспорядок слов в запросе

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

Поскольку все сервисы представляют собой rest api, а выдача результата чаще всего происходит в формате json, функцию, взаимодействующую с сервером по http, сразу выносим в общие утилиты:

generic function JSONfromRestUri<T>(Uri: string): T;
var
  HTTPSender: THTTPSend;
  JSONStreamer: TJSONDeStreamer;
  Json: TJSONObject;
begin
  HTTPSender := THTTPSend.Create;
  JSONStreamer := TJSONDeStreamer.Create(nil);
  HTTPSender.Clear;
  Result := T.Create;
  if not HTTPSender.HTTPMethod('GET', Uri) then 
  		raise EInOutError.Create(RESTOUT_ERROR);
  JSON := GetJSON(HTTPSender.Document) as TJSONObject;
  JSONStreamer.JSONToObject(JSON, Result);
  FreeAndNil(JSON);
  FreeAndNil(JSONStreamer);
  FreeAndNil(HTTPSender);
end;

Функция использует библиотеки Freepascal Synapse и Fpjson, поэтому соответствующие модули (httpsend, fpjson, fpjsonrtti) должны быть установлены и включены в uses. Десериализация ответа сервера из json в объект происходит с использованием rtti, т.е. все свойства такого объекта, во-первых, должны объявляться в секции published, во-вторых, иметь не самые сложные типы (примитивы, массивы, списки), и в-третьих, называться идентично полям в json. Скорее всего, декораторы и аннотация в стиле @SerializedName здесь не завезена, ну или я не нашел.

Строка запроса для сервиса Morphos выглядит следующим образом:

MORPHOS_URL = 'http://morphos.io/api/inflect-name?name=%s&_format=json';

Ответ сервера представляет собой json с массивом из шести строк, внутри - слово/словосочетание из запроса, склоненное по всем падежам русского языка в стандартном порядке ИРВДТП, само слово из запроса в поле name и определенный род в поле gender:

{
    "name": "Иванов Иван",
    "cases": [
        "Иванов Иван",
        "Иванова Ивана",
        "Иванову Ивану",
        "Иванова Ивана",
        "Ивановым Иваном",
        "об Иванове Иване"
    ],
    "gender": "m"
}

Приступим же к конкретной реализации интерфейса IInitialsMorpher для сервиса Morpher. Сначала объявим класс, в который данные из json будут автоматически десериализоваться библиотеками Fpjson (в документации говорится, что классы должны быть потомками TPersistent):

TMorphosResponse = class(TPersistent)
  private
    fCases: TStrings;
    fGender: string;
    fName: string;
  public
    constructor Create;
    destructor Destroy; override;
  published
    property name: string read fName write fName;
    property cases: TStrings read fCases write fCases;
    property gender: string read fGender write fGender;
  end;

Добавляем класс реализации интерфейса:

TMorphosImpl = class(TInterfacedObject, IInitialsMorpher)
  public
    function GetInitials (Initials: string): CasesResponse;
    function GetWordsCase (Words: string): CasesResponse;
    function GetGenderAndInitials(Initials: string; var Gender: TGender): CasesResponse;
  end;

Основной алгоритм сосредоточен в одной функции, а две другие лишь паразитируют на ней:

function TMorphosImpl.GetGenderAndInitials(Initials: string; var Gender: TGender): CasesResponse;
var
  inf: TWordCase;
  i: integer = 0;
  response: TMorphosResponse;
begin
  response := specialize JSONfromRestUri<TMorphosResponse>
                (Replacetext(Format(MORPHOS_URL, [Initials]), ' ', '+'));
  for inf in TWordCase do begin
      Result[inf] := response.cases[i];
      inc(i);
  end;
  case response.gender of
       'm': Gender := Male;
       'f': Gender := Female;
       else Gender := UnrecognizedGender;
  end;
  FreeAndNil(response);
end;

Это функция, возвращающая массив склоненных ФИО и род в отдельном свойстве. Не трудно догадаться, что для реализации первой функции (только ФИО) нужно вызвать её же, но отбросить род, а для реализации другой - результат первой функции привести к нижнему регистру:

function TMorphosImpl.GetInitials(Initials: string): CasesResponse;
var
  MokeGender: TGender = UnrecognizedGender;
begin
  Result := GetGenderAndInitials(Initials, MokeGender);
end;

function TMorphosImpl.GetWordsCase(Words: string): CasesResponse;
var
  inf: TWordCase;
begin
  Result := GetInitials(Words);
  for inf in TWordCase do
    Result[inf] := UTF8LowerString(Result[inf]);
end;

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

Для себя я выбрал полу-фабрику полу-фасад, чтобы уже в этом классе добавить еще одну фичу (на самом деле не так уж и нужную, но вдруг, интернет все-таки кончится?) - кеширование результатов на диск для сокращения накладных расходов на http-запросы. И если для Morphos это не особо актуально, то для DaData даже очень, поскольку каждый запрос стоит 10 копеек.

Подробное описание реализации кэша я здесь не стану приводить, поскольку оно выходит за рамки темы. Скажу лишь, что для себя выбрал хранение значений в json файле и обработку их в памяти с помощью НashMap-типа из Generics.Collections - библиотеки, портированной в freepascal из delphi. Ключом при этом являются сами инициалы, и потенциально использование хэш-массива даст оптимальную скорость поиска на больших данных (от которых по идее нужно держаться подальше с помощью разных вариантов авто очистки кэша, ведь иногда и http-запрос может отработать быстрее, чем загрузка огромного словаря в память из файла и дальнейший поиск).

В конечном итоге использование имплементации Morphos выглядит так:

Morpher := TMorphFabric.Create(MORPHOS);
//...
response := Morpher.GetInitials(Text)
StringList.AddStrings(response);

Для всех заинтересовавшихся свою получившуюся библиотеку с тестовым оконным приложением для win я буду хранить в открытом репозитории. Дорабатывать, скорее всего, я уже в ней ничего не стану, поскольку сейчас погрузился в открытое море мобильной разработки (kotlin) и python для реализации того самого мема "переписать всё на..."

Всех с наступающим Новым 2020+'1' годом!