Как постоянство вписывается в чисто функциональный язык?

18

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


При реализации доменно-управляемого проектирования на объектно-ориентированном языке обычно используется шаблон Command / Handler для выполнения изменений состояния. В этом дизайне обработчики команд располагаются поверх ваших доменных объектов и отвечают за скучную логику, связанную с постоянством, такую ​​как использование репозиториев и публикация событий домена. Обработчики являются публичным лицом вашей доменной модели; Код приложения, такой как пользовательский интерфейс, вызывает обработчики, когда ему нужно изменить состояние объектов домена.

Эскиз в C #:

public class DiscardDraftDocumentCommandHandler : CommandHandler<DiscardDraftDocument>
{
    IDraftDocumentRepository _repo;
    IEventPublisher _publisher;

    public DiscardDraftCommandHandler(IDraftDocumentRepository repo, IEventPublisher publisher)
    {
        _repo = repo;
        _publisher = publisher;
    }

    public override void Handle(DiscardDraftDocument command)
    {
        var document = _repo.Get(command.DocumentId);
        document.Discard(command.UserId);
        _publisher.Publish(document.NewEvents);
    }
}

Объект documentдомена отвечает за реализацию бизнес-правил (например, «пользователь должен иметь разрешение на удаление документа» или «вы не можете удалить документ, который уже был удален») и за генерацию событий домена, которые нам нужно опубликовать ( document.NewEventsбудет быть IEnumerable<Event>и, вероятно, будет содержать DocumentDiscardedсобытие).

Это хороший дизайн - его легко расширять (вы можете добавлять новые сценарии использования, не изменяя модель домена, добавляя новые обработчики команд), и он не зависит от того, как объекты сохраняются (вы можете легко поменять репозиторий NHibernate для Mongo репозиторий или поменяйте местами издателя RabbitMQ на издателя EventStore), что упрощает тестирование с использованием подделок и издевательств. Он также подчиняется разделению модель / представление - командный обработчик понятия не имеет, используется ли он пакетным заданием, GUI или REST API.


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

newtype CommandHandler = CommandHandler {handleCommand :: Command -> IO Result)
data Result a = Success a | Failure Reason
type Reason = String

discardDraftDocumentCommandHandler = CommandHandler handle
    where handle (DiscardDraftDocument documentID userID) = do
              document <- loadDocument documentID
              let result = discard document userID :: Result [Event]
              case result of
                   Success events -> publishEvents events >> return result
                   -- in an event-sourced model, there's no extra step to save the document
                   Failure _ -> return result
          handle _ = return $ Failure "I expected a DiscardDraftDocument command"

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

Насколько я могу судить, здесь есть две противоположные силы: одна - это разделение модели / вида, а другая - необходимость сохранения модели. Для сохранения модели где-то должен быть код ввода-вывода , но разделение модели / представления говорит о том, что мы не можем поместить его на уровень представления вместе со всем другим кодом ввода-вывода.

Конечно, на «нормальном» языке IO может (и происходит) где угодно. Хороший дизайн требует, чтобы различные типы ввода-вывода оставались раздельными, но компилятор не применяет их.

Итак: как мы можем согласовать разделение модели / представления с желанием перенести код ввода-вывода на самый край программы, когда модель должна сохраняться? Как сохранить два разных типа ввода-вывода отдельно , но все же от всего чистого кода?


Обновление : срок действия награды истекает менее чем за 24 часа. Я не чувствую, что ни один из текущих ответов вообще ответил на мой вопрос. Комментарий Flame от @ Ptharien's acid-stateкажется многообещающим, но он не является ответом и ему не хватает деталей. Я бы не хотел, чтобы эти пункты пропали даром!

Бенджамин Ходжсон
источник
1
Возможно, было бы полезно взглянуть на дизайн различных постоянных библиотек в Haskell; в частности, acid-stateпохоже, что это близко к тому, что вы описываете .
Пламя Птариена
1
acid-stateвыглядит довольно здорово, спасибо за эту ссылку. С точки зрения дизайна API это все еще кажется связанным IO; мой вопрос о том, как постоянная структура вписывается в большую архитектуру. Знаете ли вы о каких-либо приложениях acid-stateс открытым исходным кодом, которые используют наряду с уровнем представления, и преуспели в сохранении двух отдельных?
Бенджамин Ходжсон
На самом деле Queryи Updateмонады довольно далеки от IOэтого. Я постараюсь привести простой пример в ответ.
Пламя Птариена
Риск быть не по теме, для любых читателей, которые используют шаблон Command / Handler таким образом, я действительно рекомендую проверить Akka.NET. Актерская модель чувствует себя здесь как нельзя лучше. На Pluralsight есть отличный курс для этого. (Клянусь, я просто фанат, а не рекламный бот.)
RJB

Ответы:

6

Общий способ разделения компонентов в Haskell - через монадные стеки трансформаторов. Я объясню это более подробно ниже.

Представьте, что мы создаем систему, которая имеет несколько крупных компонентов:

  • компонент, взаимодействующий с диском или базой данных (подмодель)
  • компонент, который выполняет преобразования в нашем домене (модель)
  • компонент, который взаимодействует с пользователем (просмотр)
  • компонент, который описывает связь между представлением, моделью и подмоделью (контроллером)
  • компонент, который запускает всю систему (драйвер)

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

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

  • каждая функция в подмодели имеет тип MonadState DataState m => Foo -> Bar -> ... -> m Baz
    • DataState это чистое представление снимка состояния нашей базы данных или хранилища
  • каждая функция в модели чиста
  • каждая функция в представлении имеет тип MonadState UIState m => Foo -> Bar -> ... -> m Baz
    • UIState это чистое представление снимка состояния нашего пользовательского интерфейса
  • каждая функция в контроллере имеет тип MonadState (DataState, UIState) m => Foo -> Bar -> ... -> m Baz
    • Обратите внимание, что контроллер имеет доступ как к состоянию представления, так и к состоянию подмодели
  • у драйвера есть только одно определение, main :: IO ()которое выполняет почти тривиальную работу по объединению других компонентов в одну систему
    • представление и подмодель должны быть подняты в тот же тип состояния, что и контроллер, использующий zoomили подобный комбинатор
    • модель чистая и поэтому может использоваться без ограничений
    • в конце концов, все живет (тип, совместимый с) StateT (DataState, UIState) IO, который затем запускается с фактическим содержимым базы данных или хранилища для создания IO.
Пламя Птариена
источник
1
Это отличный совет, и именно то, что я искал. Благодарность!
Бенджамин Ходжсон
2
Я перевариваю этот ответ. Не могли бы вы уточнить роль «подмодели» в этой архитектуре? Как он «общается с диском или базой данных» без выполнения ввода-вывода? Меня особенно смущает то, что вы подразумеваете под « DataStateчистым представлением снимка состояния нашей базы данных или хранилища». Предположительно, вы не хотите загружать всю базу данных в память!
Бенджамин Ходжсон
1
Я бы очень хотел увидеть ваши мысли о реализации этой логики в C #. Не думаю, что я могу подкупить тебя голосом? ;-)
RJB
1
@RJB К сожалению, вам нужно было бы подкупить команду разработчиков C #, чтобы разрешить более высокие виды в языке, потому что без них эта архитектура немного неэффективна.
Пламя Птариена
4

Итак: как мы можем согласовать разделение модели / представления с желанием перенести код ввода-вывода на самый край программы, когда модель должна сохраняться?

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

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

Сказав это, часто состояние сохраняется, но просто как снимок / кэш, чтобы избежать воспроизведения команд, а не как важные данные программы.

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

После того, как команда принята, событие передается двум адресатам (хранилище событий и система отчетов), но на одном уровне программы.

См. Также
Источник событий
Eager Read Derivation

FMJaguar
источник
2
Я знаком с источником событий (я использую его в моем примере выше!), И чтобы избежать расщепления волос, я бы все же сказал, что источник событий - это подход к проблеме постоянства. В любом случае, получение событий не избавляет от необходимости загружать ваши доменные объекты в обработчик команд . Обработчик команд не знает, были ли объекты получены из потока событий, ORM или хранимой процедуры - он просто получает их из хранилища.
Бенджамин Ходжсон
1
Кажется, ваше понимание объединяет представление и обработчик команд для создания нескольких операций ввода-вывода. Насколько я понимаю, обработчик генерирует событие и больше не интересуется. Представление в этом случае функционирует как отдельный модуль (даже если технически в том же приложении) и не связано с обработчиком команд.
FMJaguar
1
Я думаю, что мы могли бы говорить в разных целях. Когда я говорю «представление», я имею в виду весь уровень представления, который может быть REST API или системой модель-представление-контроллер. (Я согласен, что представление должно быть отделено от модели в шаблоне MVC.) Я в основном имею в виду «все, что вызывает в обработчике команд».
Бенджамин Ходжсон
2

Вы пытаетесь выделить место в интенсивном приложении ввода-вывода для всех операций, не связанных с вводом-выводом; К сожалению, типичные CRUD-приложения, о которых вы говорите, мало чем отличаются от IO.

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

Презентация и постоянство составляют в основном весь тип приложения, которое, я думаю, вы описываете здесь.

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

Джимми Хоффа
источник
1
Вы говорите, что для CRUD-систем нормально сочетать постоянство и представление. Это кажется разумным для меня; Однако я не упомянул CRUD. Я специально спрашиваю о DDD, где у вас есть бизнес-объекты со сложными взаимодействиями, уровень персистентности (обработчики команд) и уровень представления поверх этого. Как вы разделяете два слоя ввода-вывода, сохраняя при этом тонкую оболочку ввода-вывода?
Бенджамин Ходжсон
1
NB, домен, который я описал в вопросе, может быть очень сложным. Возможно, для отмены черновика документа требуется проверка некоторых связанных разрешений, или может потребоваться обработка нескольких версий одного и того же черновика, или необходимо отправить уведомления, или действие требует одобрения другого пользователя, или черновики проходят через ряд этапы жизненного цикла до завершения ...
Бенджамин Ходжсон
2
@ BenjaminHodgson Я бы настоятельно рекомендовал не смешивать DDD или другие методологии разработки ОО в этой ситуации в вашей голове, это только приведет в замешательство. В то время как да, вы можете создавать такие объекты, как кусочки и всплески в чистом FP, подходы к проектированию, основанные на них, не обязательно должны быть вашей первой целью. В сценарии, который вы описываете, я хотел бы представить, как я уже упоминал выше, контроллер, который обменивается данными между двумя IO и чистым кодом: Presentation IO входит и запрашивается от контроллера, контроллер передает данные в чистые секции и в секции персистентности.
Джимми Хоффа
1
@ BenjaminHodgson, вы можете представить себе пузырь, в котором живет весь ваш чистый код, со всеми слоями и фантазиями, которые вы можете пожелать в любом дизайне, который вам нравится. Точка входа для этого пузыря будет крошечной частью, которую я называю «контроллером» (возможно, неправильно), который обеспечивает связь между презентацией, постоянством и чистыми частями. Таким образом, ваша настойчивость ничего не знает о представлении или о чистоте, и наоборот - и это удерживает ваш ввод-вывод в этом тонком слое над пузырем вашей чистой системы.
Джимми Хоффа
2
@ BenjaminHodgson этот подход к «умным объектам», о котором вы говорите, изначально плохой подход для FP, проблема со смарт-объектами в FP состоит в том, что они соединяются слишком много и обобщают слишком мало. В итоге вы получаете данные и функциональные возможности, которые к нему привязаны, при этом FP ​​предпочитает, чтобы ваши данные имели слабую связь с функциональностью, чтобы вы могли реализовать свои функции для обобщения, и тогда они будут работать с различными типами данных. Прочитайте мой ответ здесь: programmers.stackexchange.com/questions/203077/203082#203082
Джимми Хоффа,
1

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

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

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

В области файловых серверов samba имеет различные способы хранения таких вещей, как списки доступа и альтернативные потоки данных, в зависимости от того, что предоставляет хост-операционная система. В идеале, samba размещается в файловой системе и предоставляет расширенные атрибуты для файлов. Пример 'xfs' в 'linux' - больше команд копируют расширенные атрибуты вместе с файлом (по умолчанию большинство утилит в linux "выросли" без дополнительных атрибутов).

Альтернативное решение, которое работает для нескольких процессов Samba от разных пользователей, работающих с общими файлами (объектами), заключается в том, что если файловая система не поддерживает подключение ресурса непосредственно к файлу, как с расширенными атрибутами, использует модуль, который реализует слой виртуальной файловой системы для эмуляции расширенных атрибутов процессов samba. Об этом знает только samba, но преимущество в том, что он работает, когда формат объекта не поддерживает его, но все же работает с различными пользователями samba (см. Процессоры команд), которые выполняют некоторую работу над файлом на основе своего предыдущего состояния. Он будет хранить метаинформацию в общей базе данных для файловой системы, которая помогает контролировать размер базы данных (и не

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

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

Если вы знаете, когда срок действия объектов истек / удален, значит, вы опередили игру и можете одновременно вывести ее из своей метабазы, но было неясно, была ли у вас такая возможность.

Ура!

Астара
источник
1
Мне это кажется ответом на совершенно другой вопрос. Я искал советы относительно архитектуры в чисто функциональном программировании в контексте доменного дизайна. Не могли бы вы уточнить ваши моменты, пожалуйста?
Бенджамин Ходжсон
Вы спрашиваете о сохранности данных в чисто функциональной парадигме программирования. Цитата из Википедии: «Чисто функциональный - это термин в вычислениях, используемый для описания алгоритмов, структур данных или языков программирования, которые исключают деструктивные модификации (обновления) сущностей в среде выполнения программы». ==== По определению, постоянство данных не имеет значения и бесполезно для того, что не изменяет данные. Строго говоря, нет ответа на ваш вопрос. Я пытался более свободно интерпретировать то, что вы написали.
Астара