воскресенье, 8 сентября 2013 г.

Куда может завести "свой DSL". Часть 2. Язык.

Предыдущую часть истории я закончил словами о том, что язык удовлетворяющий предъявляемым к нему требованиям был создан.
Правда, я забыл упомянуть одну существенную деталь.
Мы стремились к тому, чтобы язык был простым.
Это было действительно важно, поскольку мы и сами не хотели усложнять себе жизнь, и пользователю хотели дать инструмент и "монстра" создавать не хотели.
Как известно, слона показано есть частями. Задача выглядела весьма обширной, а её применение - весьма широким. В таких случаях возникает понятное желание разбить её на этапы, но вначале - сформулировать: что должно быть в конце этапа нулевого - т.е. какими минимальными свойствами должен обладать язык, чтобы можно было начать его применение по назначению.
Этот момент исключительно важен для языка, поскольку все недостатки в его проектировании "вылезут наружу" на следующих этапах, а исправить их будет тем сложнее, чем больше кода написано на языке с изъянами.

Тем не менее, задача выбора "упрощалась" тем, что выбора-то у нас как раз и не было. Время было понятно какое, срывы сроков понятно чем грозили.
Поэтому, за основу была принята концепция самого простого известного языка - Basic, но с элементами строгой типизации (элементами, поскольку набор типов был ограничен в целях простоты) и Pascal-подобым синтаксисом.

Вы спросите: а что же там осталось от Basic? - IMHO мне проще указать, что было не от него - я это уже сделал :-)
От Basic было отсутствие локальных переменных и возможностей процедурного программирования. В Basic, напомню, процедуры в каком-то виде есть (GOSUB). В нашем языке такой возможности поначалу не было, хотя очень быстро выяснилось, что если без локальных переменных ещё можно обойтись в "простой программе", то без аналогов GOSUB/RETURN программа превращается в классические "макароны", а программирование - в ночной кошмар. Так что, аналоги GOSUB/RETURN появились достаточно быстро, что существенно упростило жизнь нам, а языку её продлило до начала 2000-х годов.

Разумеется, с самого появления нашего DSL для генерации отчётов началось его интенсивное применение.
Это работало. И действительно решало поставленные задачи.
Я помню даже некоторое "очарование" от использования этого языка - ведь теперь для того, чтобы изменить отчёт не нужно было пересобирать всё приложение (это ведь долго), достаточно было компилировать отчёт и подбросить файл с полученным P-кодом в нужное место.
Да и работала эта штука вполне себе шустро, никто не жаловался, по крайней мере.
Ну и понятно, что время разработки отчётов сократилось, поскольку проблемы форматирования во многим решались на уровне "нашего DSL".

Идиллия? - Не совсем...
Практически сразу начала показываться "обратная сторона" только что изготовленной "монеты":
  1. В языке не было возможностей для полноценного процедурного программирования - выше я обозначил, что это было обусловлено его концептуальной простотой.
    Более того, при проработке концепции языка считалось, что и без процедурного программирования можно обойтись при создании отчётов, а сама эта возможность усложнит язык, "отдалит его от пользователя".
    Можно было конечно добавить соответствующую возможность, но тут негативную роль сыграли обстоятельства, не связанные с программированием: постоянно не хватало ни времени, ни ресурсов.
    Так что, в итоге мы остались с GOSUB/RETURN без параметров процедур и локальных переменных.
  2. На модульности мы тоже сэкономили, т.е. с повторным использованием кода были проблемы. Точнее, его не было, этого повторного использования.
    На начальном этапе все отчёты казались совершенно разными, вся функциональность языка описывалась исполняющей системой. Трудно было увидеть, что в процессе накопления опыта наверняка появятся "типовые решения", которые можно было бы применять в различных местах. А они появлялись, но их очень непросто было использовать, хотя механизм включения кода (include - эдакие "подотчёты") и был поддержан, но ввиду отсутствия классов или, хотя бы, локальных переменных, использование его было сильно затруднено.
  3. У исполняющей системы не было отладчика. Совсем.
    Проект "отладчик" был нам совершенно не по карману, поскольку требовал бы проработки интерактивной составляющей, что практически всегда чревато существенными затратами труда.
    Пришлось использовать отладочную печать.
  4. Взаимодействие кода отчёта с кодом приложения сводилось к простейшим интерактивным функциям (с помощью которых, впрочем, можно было сделать немало) и функциональности по обработке таблиц, через текущее, установленное в приложении соединение.
    Новые связи добавить было сложно чисто технически, т.к. требовались изменения в исполняющей системе, да и сама архитектура решения была изначально рассчитана "на простоту".
  5. Не было документации, а язык был ни на что не похож, что задирало порог входа, а новым разработчикам для изучения языка приходилось разбирать существующий код, написанный их коллегами.
    Казалось бы, тут должна была помочь простота языка, но... Не очень помогала.
    Дело в том, что началось усложнение языка в "другом направлении".
    Иногда требовались дополнительные функции по взаимодействию с основным приложением. Эти функции приходилось добавлять, стараясь обеспечить совместимость с существовавшим кодом.
    Это приводило к появлению "магии" "секретных флагов", что не способствовало быстрому вхождению в тему. Не следует также сбрасывать со счетов "магию" шаблонов - т.е. того, что упрощало форматирование текста.
    Тем не менее, по языку в итоге появилась документация, где "секретные" методики были освещены.
Здесь думаю, уместно привести программу на этом языке. А то я только говорю о вкусе "коньяка", не давая его попробовать :-)
Чтож - извольте:
label
  Prepare,
  Execute,
  PrintItem,
  PrintHeader,
  ExitPoint;

const
  stReportName = 'TURN1.LST';
  stTitle = 'Аналитическая справка за период с * по *';
  stHead0 = '┌──────┬─────────────────────────┬─────────────────────────────┬─────────────────────────────┬─────────────────────────────┐';
  stHead1 = '│ Шифр │      Наименование       │  ОСТАТОК НА НАЧАЛО ПЕРИОДА  │      ОБОРОТ ЗА ПЕРИОД       │  ОСТАТОК НА КОНЕЦ ПЕРИОДА   │';
  stHead2 = '│аналит│  аналитического счета   ├──────────────┬──────────────┼──────────────┬──────────────┼──────────────┬──────────────┤';
  stHead3 = '│ счета│                         │    Дебет     │    Кредит    │    Дебет     │    Кредит    │    Дебет     │    Кредит    │';
  stDelmt = '├──────┼─────────────────────────┼──────────────┼──────────────┼──────────────┼──────────────┼──────────────┼──────────────┤';
  stItem  = '│******│*************************│**************│**************│**************│**************│**************│**************│';
  stTotal = '│      │             И Т О Г О : │***********.**│***********.**│***********.**│***********.**│***********.**│***********.**│';
  stBottm = '└──────┴─────────────────────────┴──────────────┴──────────────┴──────────────┴──────────────┴──────────────┴──────────────┘';
  stCenter= '****************************************************************************************************************************';
  stDouble = '***********.**';
  stUnAssigned = 'нет данных';

  ec_SetFName  = 400;
  ec_Dialog    = 500;
  ec_Execute   = 600;
  ec_PrintItem = 700;

var
  Progress: Pointer;
  SUBD: pSUBD;
  Rec: IntRecord;
  Dialog: pDialog;
  tabORGANIZ, tabBALANCE, tabBALANCE1, tabBALANCE2: pTable;
  vString, vString1, sDebitB, sDebitE, sCreditB, sCreditE,
  sTurnDebit, sTurnCredit, sTemp: String;
  I, vOrgCode, vOrgCount, gLong: LongInt;
  vDouble, vSumDebitB, vSumCreditB, vSumDebitE, vSumCreditE,
  vSumTurnDebit, vSumTurnCredit: Double;
  vWord, vDate, vDateB, vDateE: Word;

page
  begin
    SetBeginTop(1);
    SetBeginBottom(GetSizePage);
    SetFormFeed(GetFormFeed);
    SetOptions(opTopMove+ opUserMove+ opEndMove+ opFirstTop+ opLastEnd, False);
    SetOptions(opTop+ opEnd+ opFeedCR, True);
    if not SetFName(stReportName, smClear) then
      Halt(ec_SetFName);
  end;
  top
    gLong := GetCurrentPage;
    if gLong > 1 then
      begin
        sTemp := MakeStr('Лист *', gLong(T));
        print(stCenter, sTemp(R));
        call PrintHeader;
      end;
  end;
  bottom
    print(stBottm);
  end;
end;

begin
  call Prepare;
  call Execute;
  ExtB := 1;
  DateShablon := stReportName;
  goto ExitPoint;

Prepare:
begin
  SUBD := GetObject(iSUBD);

  CreateRecord(Rec, 2, '', '');
  if UnAssigned(Rec) then
    Halt(ec_Dialog+ 1);
  if not (
     AddField(Rec, indDate, 10, '', 'Период с:', '') and
     AddField(Rec, indDate, 10, '', '      до:', '') and
     SetData(Rec)) then
    Halt(ec_Dialog+ 2);
  SetSpecific(Rec, 1, tfInput);
  SetSpecific(Rec, 2, tfInput);

  vDateE := Today;
  vDateB := vDateE- 30;
  SetField(Rec, 1, vDateB);
  SetField(Rec, 2, vDateE);

  Dialog := NewDialog(Rec, SUBD, 'Аналитическая справка:', tfInput);
  if not ExecDialog(Dialog) then
    Halt(0);
  Dispose(Dialog);
  GetField(Rec, 1, vDateB);
  GetField(Rec, 2, vDateE);
  Dispose(Rec);
  return;
end;

PrintHeader:
begin
  print(stHead0);
  print(stHead1);
  print(stHead2);
  print(stHead3);
  print(stDelmt);
  return;
end;

PrintItem:
begin
  FieldToStr(tabORGANIZ, 'Шифр', vString); vString := MakeStr('*', vString(T));
  FieldToStr(tabORGANIZ, 'Название', vString1); vString1 := MakeStr('*', vString1(T));
  vOrgCode := 0; GetField(tabORGANIZ, 'КодОрганиз', vOrgCode);
  tabBALANCE1 := DoubleTable(tabBALANCE);
  if not KeySelect(tabBALANCE1, opAnd, ftEqual, 'КодОрганиз', vOrgCode) then
    Halt(ec_PrintItem+ 1);
  sDebitB := ''; sCreditB := '';
  sDebitE := ''; sCreditE := '';
  sTurnDebit := ''; sTurnCredit := '';
  if ItemNum(tabBALANCE1) > 0 then
    begin
      { определить остаток на конец периода }
      FunFromField(tabBALANCE1, 'АктуальноС', fnMax, vDate);
      tabBALANCE2 := DoubleTable(tabBALANCE1);
      if not KeySelect(tabBALANCE2, opAnd, ftEqual, 'АктуальноС', vDate) then
        Halt(ec_PrintItem+ 5);
      if ItemNum(tabBALANCE2) = 0 then
        Halt(ec_PrintItem+ 6);
      ItemGet(tabBALANCE2, 1);
      GetField(tabBALANCE2, 'Дебет', vDouble);
      vSumDebitE := vSumDebitE+ vDouble;
      if vDouble <> 0 then sDebitE := MakeStr(stDouble, vDouble(R));
      GetField(tabBALANCE2, 'Кредит', vDouble);
      vSumCreditE := vSumCreditE+ vDouble;
      if vDouble <> 0 then sCreditE := MakeStr(stDouble, vDouble(R));
      Dispose(tabBALANCE2);

      { определить остаток на начало периода }
      FunFromField(tabBALANCE1, 'АктуальноС', fnMin, vDate);
      if vDate <= vDateB then
        begin
          tabBALANCE2 := DoubleTable(tabBALANCE1);
          if not KeySelect(tabBALANCE2, opAnd, ftEqual, 'АктуальноС', vDate) then
            Halt(ec_PrintItem+ 2);
          if ItemNum(tabBALANCE2) = 0 then
            Halt(ec_PrintItem+ 3);
          ItemGet(tabBALANCE2, 1);

          GetField(tabBALANCE2, 'Дебет', vDouble);
          vSumDebitB := vSumDebitB+ vDouble;
          if vDouble <> 0 then sDebitB := MakeStr(stDouble, vDouble(R));
          GetField(tabBALANCE2, 'Кредит', vDouble);
          vSumCreditB := vSumCreditB+ vDouble;
          if vDouble <> 0 then sCreditB := MakeStr(stDouble, vDouble(R));
          if vDate < vDateB then
            { запись не относится к обрабатываемому периоду - исключить ее
              из обработки }
            if not KeySelect(tabBALANCE1, opAnd, ftNotEqual, 'АктуальноС', vDate) then
              Halt(ec_PrintItem+ 4);
        end
      else
        begin
          sDebitB := stUnAssigned;
          sCreditB := stUnAssigned;
        end;
      Dispose(tabBALANCE2);

      { определить суммы оборотов }
      FunFromField(tabBALANCE1, 'ОборотДебт', fnSum, vDouble);
      vSumTurnDebit := vSumTurnDebit+ vDouble;
      if vDouble <> 0 then sTurnDebit := MakeStr(stDouble, vDouble(R));
      FunFromField(tabBALANCE1, 'ОборотКред', fnSum, vDouble);
      vSumTurnCredit := vSumTurnCredit+ vDouble;
      if vDouble <> 0 then sTurnCredit := MakeStr(stDouble, vDouble(R));
    end;
  print(stItem, vString, vString1, sDebitB(R), sCreditB(R), sTurnDebit(R), sTurnCredit(R), sDebitE(R), sCreditE(R));
  Dispose(tabBALANCE1);
  return;
end;

Execute:
begin
  { подготовить выборку организаций }
  tabORGANIZ := GetTable(SUBD, 'ORGANIZ');
  if ItemNum(tabORGANIZ) = 0 then
    begin
      vWord := MessageBox('Не заполнен справочник организаций. Выполнение отчета прекращено.', mfWarning+ mfOkButton);
      Halt(0);
    end;
  SortTable(tabORGANIZ, 'Шифр', stAscend);

  { получить выборку в таблице данных о балансе организаций, соответствующую
    выбранному периоду времени }
  tabBALANCE := GetTable(SUBD, 'BALANCE');
  tabBALANCE1 := DoubleTable(tabBALANCE);
  if not (
     KeyRange(tabBALANCE, opAnd, ftInside, 'АктуальноС', vDateB, vDateE) and
     KeySelect(tabBALANCE1, opAnd, ftLessEqual, 'АктуальноС', vDateB) and
     KeySelect(tabBALANCE1, opAnd, ftGreatEqual, 'АктуальнДо', vDateB)) then
    Halt(ec_Execute+ 1);
  TableBoolean(opOr, tabBALANCE, tabBALANCE1);
  Dispose(tabBALANCE1);
  if ItemNum(tabBALANCE) = 0 then
    begin
      vWord := MessageBox('За указанные период операции отсутствуют.', mfWarning+ mfOkButton);
      Halt(0);
    end;

  { заголовок отчета: }
  DateShablon := 'dd/mm/yyyy'; vString := DateToStr(vDateB); vString1 := DateToStr(vDateE);
  vString := MakeStr(stTitle, vString(T), vString1(T));
  vString := '#CND#'+ MakeStr(stCenter, vString(C));
  print(vString);
  print('');
  call PrintHeader;

  CreateViewProcess(Progress, 'Подождите', 'Формирование аналитической справки...', 'обработано ', ' организаций');
  vOrgCount := ItemNum(tabORGANIZ);
  for I := 1 to vOrgCount do
    begin
      ItemGet(tabORGANIZ, I);
      if I = vOrgCount then
        SetContinuity(True);
      call PrintItem;
      ViewProcess(Progress, I/ vOrgCount);
    end;
  print(stDelmt);
  print(stTotal, vSumDebitB(R), vSumCreditB(R), vSumTurnDebit(R), vSumTurnCredit(R), vSumDebitE(R), vSumCreditE(R));
  print(stBottm);
  SetContinuity(False);
  Dispose(Progress);
  Dispose(tabORGANIZ);
  Dispose(tabBALANCE);
  return;
end;

ExitPoint:
  SUBD := nil;
end.
Если Вы "нормальный программист" то Ваша реакция должна быть "Фу-у-у-у!" и Вы, совершенно понятно, точно знаете, как сделать в 100500 раз лучше.
Остальным мне хочется пожать руку, поскольку это - не просто работало, но закрывало очень существенную проблему. Больше возвращаться к этой теме не буду.

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

  1. Программировать на этом языке было довольно просто. Ну, особенно, если уже приходилось это делать. "Порог входа" рано или поздно преодолевается, да и был он "порогом", в основном, не для наших сотрудников, а для пользователей, которые впрочем, с ним тоже успешно справлялись. Но создавая очередной отчёт, не покидало ощущение deja vu, появлялось отчётливое понимание, как можно было бы организовать программу много лучше, если бы язык это позволял сделать.
    Разумеется, прежде всего это касалось процедурного программирования и модульности, которые мы принесли в жертву простоте.
    Вы кстати заметили, сколько раз я упоминаю простоту в этом тексте? А ведь именно простота, близость к пользователю позиционируется сторонниками DSL.

    Вы можете конечно упрекнуть меня в том, что я смешиваю "тёплое с мягким": пользователю мол, нужен один язык, а разработчику - другой.
    Знаете... Я думал над этим.
    Пользователю, если он не умеет программировать вообще - лучше предоставить интерактивный инструмент, который поможет ему сделать, что требуется.
    Разумеется, нужно хорошо подумать над этим интерактивным "языком", но это - решаемая проблема (в конце концов, мы же её решили).
    Если пользователь программировать умеет, то лучше предоставить ему полноценный современный язык, но об этом позже.

  2. На основе опыта применения "собственного DSL" для формирования отчётов, у нас сформировалось более-менее отчётливое представление о задачах, которые в рамках его предметной области решаются хорошо, и задачах, которые решаются не так хорошо. Наверное, это имеет отношение не столько к DSL вообще, а больше - конкретно, к теме генерации отчётов, но IMHO это стоит принять во внимание.
    Итак, с формированием отчётов, которые отражают содержимое одного, пусть очень "хитро" слитого (JOIN) набора данных, с master-detail отчётами, с отчётами на одну запись с "расшифровкой" их атрибутов (ну, что-то вроде преобразования кода элемента справочника в описание соответствующей записи этого справочника) - наш язык справлялся относительно неплохо по модулю перечисленных недостатков.
    А вот т.н. "расчётные отчёты", которые представляли из себя большие таблицы, каждая ячейка которых содержала результаты, пусть и простых, но вычислений (разумеется, для таких вычислений часто требовалось обращаться к БД) превращались в мешанину кода, которую было весьма утомительно сопровождать.
    И думаю, понятно почему. Само устройство языка, его изначальная ориентация на простоту, делали затруднительным надлежащую организацию кода.
Вероятно, вам захочется сказать, что простота не означает примитивность.
Наверное, вы правы. Только когда язык разрабатывается (да не только язык, а что угодно), Вам известно текущее состояние предметной области.
Тут вы оказываетесь перед дилеммой (дилемма, это когда у вас есть более одного решения, но все они хреновые вас по каким-либо причинам не устраивают):
  1. Упростить решение и быстро получить результат
  2. Предложить более общее решение с прицелом на будущее
Нам нужен был быстрый результат, поскольку проблема массового создания отчётов стояла очень остро. Более того, возьмусь утверждать, что цели были достигнуты. Это значит, что баланс между простотой и функциональностью был, если не оптимальным, то близким к нему. Особенно, если учесть, что каких-либо серьёзных проблем с сопровождением отчётов не возникало.
Более того, я скажу здесь, что сам являюсь горячим сторонником действий по первой схеме. "Путь кулака", о котором я, если будет повод, расскажу, подразумевает последовательное, эволюционное продвижение от простого к сложному, с тщательным соблюдением ритуала (регламентных процедур). Цель этого пути пройти между Сциллой примитивности, простоты до утрирования, и Харибдой неоправданного усложнения, похоронившей не один проект...
К чему столько "воды"? - Да к одному: когда вы начинаете что-то делать, вы не знаете и не можете знать, как это "пойдёт", и как оно повлияет на предметную область, в которой разрабатывается.
Мой личный опыт с однозначностью указывает, что это влияние может быть самым неожиданным и далеко не всегда удаётся в начале пути увидеть направления развития предметной области и модели её описывающей.
Когда мы берём на себя смелость играть в "провидца", силящегося "увидеть" это развитие до того, как оно случилось, очень часто получается, что "не угадали", что заложенные "на будущее" возможности оказались невостребованными, что они, тем не менее, оказали своё влияние на архитектуру, и что уже есть код, основанный на такой архитектуре, и такого кода - много, и много его, отчасти потому, что описательный язык выбранный посредством "прорицания" и "общих соображений" неадекватен изменившейся предметной области. Становится понятно, что нужно переделывать и сам язык и, как следствие, выбирать другую архитектуру. Только вот что делать с кодом, на этой архитектуре основанным? В какие часы/рубли выйдет его переработка?
Опять не знаем? Ну... Мы можем сыграть в эту игру ещё раз. Опять из "общих соображений", на "набитых шишках" выбрать другое решение, которое уж точно (мы-то знаем!) будет верным.
Если у вас богатые родственники, то вы можете себе это позволить. У меня таковых не числится. К сожалению. Поэтому приходится делать не то, что "грезится", а то, что готовы купить.

Возвращаясь к языку, следует сказать, что его развитие не могло угнаться за развитием предметной области.
Язык благополучно пережил потребность в печати на спец. принтерах. Ну, на которых печатают всякие паспорта и прочие справки строгой отчётности. Задачу удалось решить на уровне команд форматирования, поддержав что-то вроде Esc-последовательностей. Но вот создание отчётов в формате RTF/Excel представляло бы уже задачу совсем другого уровня сложности. Она тоже решалась, но уже за рамками языка, на уровне обработки результатов.
Совсем же плохо стало, когда массово оказались востребованными "расчётные отчёты". К счастью, к тому моменту у нас уже было "куды бечь" :-)

Продолжение следует...

2 комментария :

  1. Нужно время для внимательного прочтения статьи.
    Пока я это буду делать... раз уж глаз зацепился за "предметную область".

    Что Вы думаете о FastSript - скиптовом движке технологии генерации отчетов FastReport? У них там скрипты на "Delphi". По понятным причинам, чтобы "дельфисту" было максимально комфортно.

    Кстати, небольшой побочный эффект. Когда FastReport даёт конверторы из других отчётников (RaveReports etc), то наличие скриптов в отчётах (естественно, в каждом движке они свои) есть БОЛЬШОЙ камень преткновения. Как говорит Александр Люлин - "ну вы понимаете...".

    ОтветитьУдалить
  2. «Что Вы думаете о FastSript - скиптовом движке технологии генерации отчетов FastReport? У них там скрипты на "Delphi". По понятным причинам, чтобы "дельфисту" было максимально комфортно.»
    -- Очень хорошо думаю. FastReport - это вообще отличный инструмент, а использование в его контексте FastScript позволяет отбивать те самые "специальные случаи", которые не описываются ни связями источников данных, ни форматированием.
    Иными словами, FastReport являет собой пример того симбиоза интерактивности с программированием, о котором я говорю в следующей части.
    Пожалуй единственное, что хотелось бы отметить (но это уже я придираюсь) - FastScript представляется несколько более "многословным" в сравнении с "промышленными" скрипт-языками (LUA/Ruby/Python и т.п.). Хот всё, что необходимо для того, чтобы быть успешным в нём есть.

    «Кстати, небольшой побочный эффект. Когда FastReport даёт конверторы из других отчётников (RaveReports etc), то наличие скриптов в отчётах (естественно, в каждом движке они свои) есть БОЛЬШОЙ камень преткновения.»
    -- Да, это знакомо. Но я не сказал бы, что такая уж проблема.
    Если отчётов совсем много, и нет ресурсов на переработку их в FastReport, можно временно оставить две исполняющих системы: старую (Rave) и новую (FastReport). Т.е. вести переработку эволюционным путём, в конце которого полностью избавиться от старого генератора отчётов.
    Именно так мы избавлялись от запрограммированных отчётов на языке, о котором я здесь рассказываю.

    ОтветитьУдалить