пятница, 25 апреля 2014 г.

Мартин Фаулер. Предметно-ориентированные языки программирования

Этот пост о книге Мартина Фаулера, которую он анонсировал здесь, а знакомство с этим постом совпало по времени с моментом, когда нам пришлось разрабатывать свой DSL для описания SQL-запросов в программном коде на Delphi.

Ранее я уже поднимал тему внутренних DSL, т.е. DSL, в которых носителем синтаксиса является другой язык (Delphi в моём случае). С того момента мы существенно продвинулись - эта тема получила ряд интереснейших, на мой взгляд продолжений, но это уже другая история.
Хотя, пожалуй приведу пример того, что мы имеем в настоящий момент:
ПоказатьСкрыть
procedure hnd_CON_TIME_ApplyTriggers(P: Pointer; var Event: TComEvent);
var
  DBC: TCustomConnection;
  parms: ^TEvent_TriggerContainer_ApplyTriggers;
 
  function IsLastWork: Boolean;
  var
    other_works: TDataSet;
  begin
    { найти новые записи }
    other_works := CreateDataSetSelector
      .UseDBC(DBC)
      .SetISelect(
        QuerySelect()
          .Columns([
            Column(QRName(nmDate)),
            Column(QRName(nmTime))
          ])
          .From(FromTable(snCON_TIME))
          .Where(
            &And([
              Compare(QRName(''), coNotEqual, Value(GetDataSetRecId(parms.DataSet))),
              Compare(QRName(nmContactCode), coEqual, Value(parms.DataSet, nmContactCode)),
              Compare(QRName(nmDate), coGreatEqual, Value(parms.DataSet, nmDate))
            ])
          )
          .OrderBy([
            OrderItem(QRName(nmDate), odDesc),
            OrderItem(QRName(nmTime), odDesc)
          ])
        )
      .Open
      .DataSet;
 
    Result := other_works.IsEmpty;
    if not Result then // проверить время
      Result :=
        (CompareDate(
          other_works.FieldByName(nmDate).AsDateTime,
          parms.DataSet.FieldByName(nmDate).AsDateTime
         ) = EqualsValue) and
        (other_works.FieldValues[nmTime] <= parms.DataSet.FieldValues[nmTime]);
  end;
 
  procedure SetContactStatus;
  var
    contact_data: TDataSet;
    vStatus: Variant;
  begin
    { найти нужную запись }
    contact_data := CreateDataSetSelector
      .UseDBC(DBC)
      .FromTable(snLC_CONT)
      .AddKeyValuePair(nmContactCode, Value(parms.DataSet, nmContactCode))
      .SelectColumns([nmStatusCode, nmStatusDate, nmStatusTime, nmStatusMaster])
      .Open
      .DataSet;
    if contact_data.IsEmpty then
      Exit;
 
    { установить статус }
    if parms.DataSet.FieldValues[nmWorkKindCode] = wkContact then
      if contact_data.FieldValues[nmStatusCode] = rqsRegistered then
         vStatus := rqsAccepted
      else
        Exit
    else
      vStatus := CreateDataSetSelector
        .UseDBC(DBC)
        .FromTable(snS_WORK)
        .AddKeyValuePair(nmWorkKindCode, Value(parms.DataSet, nmWorkKindCode))
        .SelectColumns([nmStatusCode])
        .OpenNotEmpty
        .DataSet.FieldValues[nmStatusCode];
 
    if (vStatus <> contact_data.FieldValues[nmStatusCode]) and (vStatus <> 0) then
      begin
        contact_data.Edit;
        contact_data.FieldValues[nmStatusCode] := vStatus;
        contact_data.FieldValues[nmStatusDate] := parms.DataSet.FieldValues[nmDate];
        contact_data.FieldValues[nmStatusTime] := parms.DataSet.FieldValues[nmTime];
        contact_data.FieldValues[nmStatusMaster] := CreateDataSetSelector
          .UseDBC(DBC)
          .FromTable(snUSERS)
          .AddKeyValuePair(fnIdentU, Value(UserPrivateCode))
          .SelectColumns([nmMasterCode])
          .OpenNotEmpty
          .DataSet.FieldValues[nmMasterCode];
        ForcePostData(contact_data);
      end;
  end;
 
var
  trs: IDBTransaction;
begin
  parms := Event.Parms;
  DBC := ExtractConnection(parms.DataSet);
  if parms.Operation in [DC_INSERT, DC_MODIFY] then
    begin
      trs := Transaction(DBC);
      try
        PassEvent(Event);
        if (parms.Operation = DC_INSERT) or IsLastWork then
          SetContactStatus;
        trs.Commit;
      except
        trs.RollBack;
        raise;
      end;
    end;
end;
Хочу сказать о книге. Она удачна, хотя к стилю изложения Фаулера я испытываю отношение, которое сродни его отношению к использованию XML в качестве носителя синтаксиса DSL :-), очевидно, что автор знает, о чём он пишет.
В книге выделю часть IV "Вопросы создания внутренних DSL", в которой автор вводит понятие Построителя выражений, и рассматривает различные способы его настройки. Среди способов настройки автор довольно внятно описывают, в частности fluent-технику, которую называет "соединение методов в цепочки".
Думаю, что если бы была возможность ознакомиться с материалом год назад, мы вероятно не потратили бы две-три недели на изобретение того, что у него изложено довольно системно. Хотя... Как сказать... ;-)
Разумеется, в книге рассматриваются и другие подходы к разработке DSL.
Приятной особенностью книги является наличие разделов "Как это работает" и "Когда это использовать", что более чем востребовано при описании подходов.
В электронном виде книга стоит около 120 рублей, купил её я на books.ru, но думаю, приобрести несложно и в других местах...

13 комментариев :

  1. вот расскажите пожалуйста, как вы ходите отладчиком по этим текучим интерфейсам?

    ОтветитьУдалить
  2. перечитал несколько раз код. непривычно, но круто.. наверное. особенно, если делать ставку на поддержку различных субд.
    а это пример из реального приложения?

    ОтветитьУдалить
  3. «вот расскажите пожалуйста, как вы ходите отладчиком по этим текучим интерфейсам?»
    -- Да как... Обычно... F7, F8, Toggle breakpoint... :-)
    Но вообще-то, нечасто там приходится отладчиком ходить. Методы довольно простые.
    Если уж "припечёт", то лучше breakpoint выставить.

    «перечитал несколько раз код. непривычно, но круто.. наверное. особенно, если делать ставку на поддержку различных субд.
    а это пример из реального приложения?»
    -- Да, это пример из реального приложения. И это действительно СУБД-независимый код.
    Действительно, поначалу непривычно, но не в большей степени, чем замыкания и разные трюки с интерфейсами.
    Это всего лишь последовательный вызов методов, каждый из которых (обычно) возвращает ссылку на экземпляр класса, из которого он вызван.

    ОтветитьУдалить
  4. ну про отладку я спросил потому, что точку останова нельзя пставить в середине выражения. например на строке
    .Open
    (ну допустим я хочу посмотреть текст генерируемого sql-выражения)
    хотя понятно, что это не такая уж и проблема

    ОтветитьУдалить
  5. про код из реального приложения - там где вы делаете
    contact_data.Edit
    и ниже определяете параметр StatusMaster
    как я понимаю происходит выборка этого значения из БД на клиент.
    Хотя на самом деле, для ускорения (и чтобы не делать лишний round-trip) можно написать на SQL так:
    update lc_cont set statuscode = :statuscode, … , statusmaster =(select mastercode from users… )
    ну т.е. вам есть куда ещё развиваться

    ОтветитьУдалить
    Ответы
    1. Забавно... :-)
      Только что посмотрел - на LC_CONT есть триггер уровня приложения.
      Вот его код:
      procedure hnd_LC_CONT_ApplyTriggers(P: Pointer; var Event: TComEvent);
      var
      DBC: TCustomConnection;
      vOldStatus: Variant;
      tabHIS_STAT: TDataSet;
      trs: IDBTransaction;
      begin
      with TEvent_TriggerContainer_ApplyTriggers(Event.Parms^) do
      if Operation = DC_MODIFY then
      begin
      { если статус изменился сохранить историю }
      with DataSet.FieldByName(nmStatusCode) do
      begin
      vOldStatus := OldValue;
      if Value = vOldStatus then
      Exit;
      end;
      { добавить историю }
      if vOldStatus <> 0 then
      begin
      DBC := ExtractConnection(DataSet);
      trs := Transaction(DBC);
      try
      tabHIS_STAT := OpenTable(DBC, snHIS_STAT);
      try
      tabHIS_STAT.Insert;
      tabHIS_STAT.FieldValues[nmContactCode] := DataSet.FieldValues[nmContactCode];
      tabHIS_STAT.FieldValues[nmStatusCode] := vOldStatus;
      tabHIS_STAT.FieldValues[nmStatusDate] := DataSet.FieldByName(nmStatusDate).OldValue;
      tabHIS_STAT.FieldValues[nmStatusTime] := DataSet.FieldByName(nmStatusTime).OldValue;
      tabHIS_STAT.FieldValues[nmStatusMaster] := DataSet.FieldByName(nmStatusMaster).OldValue;
      tabHIS_STAT.Post;
      finally
      tabHIS_STAT.Free;
      end;
      PassEvent(Event);
      trs.Commit;
      except
      trs.RollBack;
      raise;
      end;
      end;
      end;
      end;

      Удалить
  6. «Хотя на самом деле, для ускорения (и чтобы не делать лишний round-trip) можно написать на SQL так:
    update lc_cont set statuscode = :statuscode, … , statusmaster =(select mastercode from users… )»
    -- Такой UPDATE посредством SQLObjects (представленная к рассмотрению техника) записать очень легко.
    Но мы стараемся не злоупотреблять инструкцией SQL UPDATE в случае, если к этому нет прямых показаний, по ряду причин.
    Самая простая состоит в том, что UPDATE нарушает инкапсуляцию таблицы, в которую вносятся изменения - используется знание правил, как там изменять данные. Например, корректировка записи в LC_CONT может представлять собой транзакцию, в которую могут быть включены другие таблицы.
    Конечно, можно разместить соответствующую бизнес-логику на уровне триггера СУБД для модификации записей в LC_CONT, но это сразу же сделает эту бизнес-логику СУБД-зависимой, а выигрыш в производительности на атомарных операциях будет незаметным.
    С другой стороны, использование DataSet.Edit и Post позволит разместить бизнес-логику в приложении (либо толстом клиенте, либо на сервере приложений) и сохранить независимость от СУБД, поскольку при выполнении Post будут активированы триггеры уровня приложения. Примером такого триггера является обработчик hnd_CON_TIME_ApplyTriggers, пример которого приводится.
    Этот обработчик будет вызван при Post в наборе данных, открытом для таблицы CON_TIME (как следует из его имени).

    ОтветитьУдалить
  7. не-не-не. вы не так поняли.
    я имел ввиду не написание апдейта целиком, а замену строки
    contact_data.FieldValues[nmStatusMaster] := CreateDataSetSelector
    .UseDBC(DBC)
    … .
    на что-то такое, что бы сказало генратору SQL, мол в это поле подставь значение из другой таблицы без выборки его на клиент.

    (а то что у вас эмуляция триггера - это понятно. ещё интересно было бы посмтореть на случаи, когда триггеров больше одного, как тригеры у вас регистрируются и т.п. просто ради любопытства)

    ОтветитьУдалить
    Ответы
    1. «на что-то такое, что бы сказало генратору SQL, мол в это поле подставь значение из другой таблицы без выборки его на клиент.»
      -- Почему-то мне кажется, что так не получится. Потому, что значение поля с именем nmStatusMaster должно быть определено *до* выполнения contact_data.Post (за это отвечает ForcePostData(contact_data)). Здесь принципиально, что операция изменения данных должна быть инициирована средствами DataSet.

      «(а то что у вас эмуляция триггера - это понятно. ещё интересно было бы посмтореть на случаи, когда триггеров больше одного, как тригеры у вас регистрируются и т.п. просто ради любопытства)»
      -- Пример другого триггера я привёл выше, регистрируются они так:
      procedure RegisterTriggers(AConnection: TCustomConnection);
      var
      iObj: ITriggerContainer;
      begin
      if Supports(AConnection, ITriggerContainer, iObj) then
      begin
      iObj.RegisterTableTrigger(snLC_CONT, ProcToEventHandler(hnd_LC_CONT_ApplyTriggers));
      iObj.RegisterTableTrigger(snCON_TIME, ProcToEventHandler(hnd_CON_TIME_ApplyTriggers));
      end;
      end;

      Удалить
  8. а вообще, наши ораклисты внушили мне мысль, что триггеры сами по себе есть зло, и стоит их пименять либо для отладки, либо как временное решение (читай "костыль") при исправлении ошибок, но не как часть бизнес-логики.
    и я с ними начинаю соглашаться и смотреть на базы уже по другому.

    ОтветитьУдалить
    Ответы
    1. «а вообще, наши ораклисты внушили мне мысль, что триггеры сами по себе есть зло, и стоит их пименять либо для отладки, либо как временное решение (читай "костыль") при исправлении ошибок, но не как часть бизнес-логики.»
      -- Все "ораклисты" думают одинаково :-)
      Есть замечательная статья "Где наша бизнес-логика, сынок?" http://habrahabr.ru/post/65432/ - возможно, она будет "в тему"...

      Удалить
    2. спасибо за ссылку, почитал с интересом

      Удалить
  9. Сильно смахивает на попытку реализовать сишарпный LINQ, только без поддержки анонимных типов со стороны компилятора/IDE.

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