Технические эксперты Alibaba подробно рассказывают о серии DDD Лекция 3 — Режим репозитория

Архитектура
Технические эксперты Alibaba подробно рассказывают о серии DDD Лекция 3 — Режим репозитория

Автор|Инь Хао

Произведено | Alibaba New Retail Tao Department Технологический отдел

Пишите впереди: Эта статья и "Технические эксперты Alibaba объясняют вторую серию DDD - Архитектура приложений" давно разделены. С одной стороны, работа относительно загружена. Агрегированный корень), Bounded Context (ограниченный контекст) и другие понятия. Но в процессе написания я обнаружил, что просто говорить о вещах, связанных с Entity, было бы более абстрактно и сложно реализовать. Поэтому эта статья была отменена и началась с репозитория.Сначала определите вещи, которые можно реализовать и из которых можно сформировать спецификацию, и, наконец, попробуйте реализовать Entity. Это, конечно, также путь, который мы можем попробовать при ежедневном рефакторинге в соответствии с DDD.

Предупреждение: в следующей статье будет рассмотрена логика Anti-Corruption Layer, но вы обнаружите, что она очень близка к концепции шаблона Repository. После того, как все окружающие вещи будут рассмотрены, может стать менее абстрактным подробное обсуждение Entity. Макро-концепцию DDD на самом деле несложно понять, но, как и REST, DDD — это только дизайнерская идея, а отсутствие полного набора спецификаций затрудняет работу с DDD новичками. Мои предыдущие статьи об архитектуре в основном смотрели вниз с проектирования верхнего уровня.В этой статье я надеюсь заполнить некоторые спецификации реализации кода DDD, чтобы помочь студентам реализовать идеи DDD в своей повседневной работе, и я надеюсь, что с помощью набора спецификаций различные Студенты из двух компаний могут быстрее понять и освоить код друг друга. Тем не менее, правила мертвы, а люди живы. Учащиеся должны выбрать применение норм в соответствии с реальной ситуацией в своем собственном бизнесе. Нормы DDD не могут охватывать все сценарии, но я надеюсь, что с помощью объяснений учащиеся смогут понять предысторию DDD. , Некоторые мысли и компромиссы.

Зачем использовать репозиторий

Твердая модель по сравнению с моделью анемии

Первоначальное применение слова «сущность» в компьютерной области может происходить из книги Питера Чена «Модель сущности-отношения - к унифицированному представлению данных» (модель ER) в 1976 году, которая используется для описания отношений между сущностями. Модель ER постепенно превратилась в модель данных, которая представляет собой метод хранения данных в реляционной базе данных.

Стандарт JPA в 2006 году с помощью аннотаций, таких как @Entity, и реализации сред ORM, таких как Hibernate, заставил многих разработчиков Java понимать Entity на уровне отображения данных, игнорируя поведение самого Entity, в результате чего многие модели сегодня только Содержит данные сущностей и атрибуты, а вся бизнес-логика разбросана по нескольким классам инструментов Service, Controller, Utils, это то, что Мартин Фаулер назвал Anemic Domain Model (анемичная модель предметной области).

Как узнать, страдает ли ваша модель анемией? Вы можете увидеть, имеет ли ваш код следующие характеристики:

1. Имеется большое количество объектов XxxDO: Хотя DO иногда представляет объект домена, на самом деле это просто отображение структуры таблицы базы данных, которое не содержит (или содержит очень мало) бизнес-логики;

2. В сервисе и контроллере много бизнес-логики: такие как логика проверки, логика вычислений, логика преобразования формата, логика объектно-реляционных отношений, логика хранения данных и т. д.; Большое количество классов инструментов Utils и т.д.

Недостатки модели анемии очень очевидны:

1. Неспособность защитить целостность и непротиворечивость объектов модели: Поскольку все атрибуты объекта являются общедоступными, согласованность модели может поддерживать только вызывающая сторона, что не гарантируется; случай, который произошел ранее, заключается в том, что вызывающая сторона не может поддерживать согласованность данных модели, что приводит к грязным data Существуют ошибки в использовании, и такие ошибки особенно скрыты и их трудно обнаружить.

2. Обнаруживаемость объектных операций крайне плохая: Трудно увидеть, какая там бизнес-логика просто из свойств объекта, когда его можно вызывать, и какая граница может быть назначена, например, может ли значение типа Long быть 0 или отрицательным?

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

4, надежность кода плохая: Например, изменение модели данных может привести к изменению всего кода сверху донизу.

5. Сильная зависимость от базовой реализации: бизнес-код в значительной степени зависит от базовой базы данных, протокола сети/промежуточного ПО, сторонних сервисов и т. д., что приводит к жесткой базовой логике кода и высоким затратам на обслуживание.

Хотя модель анемии имеет большие недостатки, в нашем ежедневном коде 99% кодов, которые я видел, основаны на модели анемии.Почему? Резюмирую следующие пункты:

1. Мышление баз данных: Со дня создания базы данных образ мышления разработчиков постепенно изменился с «написания бизнес-логики» на «написание логики базы данных», что мы часто говорим о написании CRUD-кода.

2. Модель анемии «простая».: Преимущество модели анемии заключается в том, что она «простая», это всего лишь сопоставление полей таблицы базы данных, поэтому ее можно использовать в едином формате от начала до конца. Здесь просто кавычки, потому что это только внешне просто.На самом деле, когда в будущем произойдут изменения модели, вы обнаружите, что это не просто, и каждое изменение очень сложно.

3. Сценарное мышление: Много распространенного кода — это «скрипт» или «склеивающий код», то есть код процесса. Преимущество скриптового кода в том, что его легче понять, но ему не хватает надежности в долгосрочной перспективе, а стоимость обслуживания будет все выше и выше.

Но, возможно, основная причина в том, что на самом деле в нашем повседневном развитии мы смешиваем два понятия:

1. Модель данных: относится к тому, как должны сохраняться бизнес-данные, и взаимосвязи между данными, то есть к традиционной модели ER;2. Бизнес-модель/модель предметной области: относится к тому, как связанные данные должны быть связаны в бизнес-логике.

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

Значение репозитория

В традиционной разработке, управляемой базой данных, мы инкапсулируем операции базы данных, обычно называемые объектами доступа к данным (DAO). Основная ценность DAO заключается в том, чтобы инкапсулировать тривиальную базовую логику, такую ​​как объединение SQL, поддержание соединений с базой данных и транзакции, чтобы развитие бизнеса могло быть сосредоточено на написании кода. Но по сути, операция DAO по-прежнему является операцией с базой данных, и определенный метод DAO по-прежнему напрямую работает с базой данных и моделью данных, но с меньшим количеством написанного кода. В книге дяди Боба «Путь к очистке кода» автор использовал очень яркое описание:

1. Оборудование: Относится к чему-то, что нельзя (или трудно) изменить после создания. Для разработки БД относится к «железу».После того как БД будет выбрана, она в принципе не будет потом меняться.Например, сложно перейти на MongoDB после использования MySQL, да и стоимость трансформации слишком высока.

2. Программное обеспечение: Относится к чему-то, что можно изменить в любое время после его создания. Для разработки бизнес-код должен преследовать «программное обеспечение», потому что бизнес-процессы и правила постоянно меняются, и наш код тоже должен иметь возможность меняться в любое время.

3. Прошивка: Программное обеспечение, сильно зависящее от аппаратного обеспечения. Наши общие - это прошивка в роутере или прошивка Андроида и т.д. Особенность прошивки в том, что она абстрагирует железо, но может адаптироваться только к определенному типу железа и не может быть универсальной. Так что сегодня нет так называемой универсальной прошивки Android, а для каждого телефона нужна своя прошивка.

Из вышеприведенного описания видно, что база данных по сути относится к «аппаратному обеспечению», DAO по сути относится к «прошивке», а наш собственный код надеется принадлежать к «программному обеспечению». Однако прошивка имеет очень плохую особенность, то есть она будет распространяться, то есть когда ПО сильно зависит от прошивки, из-за ограничения прошивки ПО станет сложно менять, и со временем ПО станет такой же как и прошивка.Сложно изменить. Вот пример, когда программное обеспечение можно легко «вылечить»:

private OrderDAO orderDAO;

public Long addOrder(RequestDTO request) {
    // 此处省略很多拼装逻辑
    OrderDO orderDO = new OrderDO();
    orderDAO.insertOrder(orderDO);
    return orderDO.getId();
}

public void updateOrder(OrderDO orderDO, RequestDTO updateRequest) {
    orderDO.setXXX(XXX); // 省略很多
    orderDAO.updateOrder(orderDO);
}

public void doSomeBusiness(Long id) {
    OrderDO orderDO = orderDAO.getOrderById(id);
    // 此处省略很多业务逻辑
}

В приведенном выше простом коде объект зависит от DAO, то есть от БД. Хотя на первый взгляд в этом нет ничего плохого, если в будущем будет добавлена ​​логика кеша, код нужно изменить на следующий:

private OrderDAO orderDAO;
private Cache cache;

public Long addOrder(RequestDTO request) {
    // 此处省略很多拼装逻辑
    OrderDO orderDO = new OrderDO();
    orderDAO.insertOrder(orderDO);
    cache.put(orderDO.getId(), orderDO);
    return orderDO.getId();
}

public void updateOrder(OrderDO orderDO, RequestDTO updateRequest) {
    orderDO.setXXX(XXX); // 省略很多
    orderDAO.updateOrder(orderDO);
    cache.put(orderDO.getId(), orderDO);
}

public void doSomeBusiness(Long id) {
    OrderDO orderDO = cache.get(id);
    if (orderDO == null) {
        orderDO = orderDAO.getOrderById(id);
    }
    // 此处省略很多业务逻辑
}

В это время вы обнаружите, что, поскольку логика вставки изменилась, вам нужно перейти с 1 строки кода как минимум на 3 строки кода во всех местах, где используются данные. А когда объем вашего кода становится относительно большим, то если вы где-то забыли проверить кеш, или забыли где-то обновить кеш, вам нужно проверить базу данных на лайт, иначе кеш и база данных несовместимы, вызывая баги. Когда объем кода становится все больше и больше, а мест для прямого вызова DAO и кэша становится все больше, каждое базовое изменение будет становиться все более и более сложным, и оно будет все чаще вызывать ошибки. Это является следствием «закалки» программного обеспечения. Поэтому нам нужна модель, которая может изолировать наше программное обеспечение (бизнес-логику) и прошивку/аппаратное обеспечение (DAO, DB), чтобы сделать наше программное обеспечение более надежным, и в этом основная ценность репозитория.

Спецификация кода объекта модели

тип объекта

Прежде чем говорить о спецификации репозитория, нам нужно прояснить разницу между тремя моделями: Entity, Data Object (DO) и Data Transfer Object (DTO):

1. Объект данных (DO, объект данных): на самом деле это наиболее распространенная модель данных, которую мы используем в нашей повседневной работе. Однако в спецификации DDD DO должен использоваться только как отображение физической таблицы базы данных и не может участвовать в бизнес-логике. Для простоты и ясности тип поля и имя DO должны соответствовать один к одному типу поля и имени физической таблицы базы данных, чтобы нам не нужно было обращаться в базу данных для проверки типа и имени поле. (Конечно, на самом деле не обязательно быть точно таким же, пока вы делаете сопоставление полей на слое Mapper)

2. Сущность (объект сущности): Объект сущности — это бизнес-модель, которую должен использовать наш обычный бизнес.Его поля и методы должны соответствовать бизнес-языку и не иметь ничего общего с методом персистентности. Другими словами, Entity и DO, скорее всего, будут иметь совершенно разные имена и типы полей и даже вложенные отношения. Жизненный цикл Entity должен существовать только в памяти, он не должен быть сериализуемым и постоянным.

3. DTO (Передача объекта): в основном используемые в качестве входных и выходных параметров прикладного уровня, таких как команда, запрос, событие, запрос, ответ и т. д. в CQRS, все относятся к категории DTO. Ценность DTO заключается в адаптации к входным и выходным параметрам различных бизнес-сценариев, чтобы не превращать бизнес-объект в универсальный большой объект.

Отношения между объектами модели

В реальной разработке DO, Entity и DTO не обязательно находятся в соотношении 1:1:1. Вот некоторые распространенные отношения, отличные от 1: 1:

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

Несколько связанных сущностей объединяются в одну таблицу базы данных: эта ситуация обычно возникает, когда существует сложная связь «Корень агрегации — сущность», и требуются подбаза данных и подтаблица, чтобы избежать несоответствий, вызванных несколькими запросами и подбазой данных и подтаблицы В нем жертвуется простота одной таблицы для повышения производительности запросов и вставок. Распространенным случаем является модель мастер-подзаказ:

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

Объединение нескольких объектов в один DTO: в этом случае, чтобы снизить затраты на передачу по сети, уменьшить количество запросов к серверу, объединить и сериализовать несколько объектов, DP и других объектов, а также разрешить DTO вкладывать другие DTO. Тот же самый распространенный случай заключается в том, что информация о продукте должна отображаться в деталях заказа:

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

Поскольку один объект теперь заменяется на 3+ объекта, объекты необходимо конвертировать друг в друга через конвертер (Converter/Mapper). Позиции этих трех объектов в коде также различны, что можно кратко резюмировать следующим образом:

DTO Assembler: на уровне приложения преобразователь Entity в DTO имеет стандартное имя DTO Assembler. Описание Мартина Фаулера DTO и ассемблера в P of EAA:Data Transfer Object. Основной функцией ассемблера DTO является преобразование одного или нескольких связанных объектов в один или несколько DTO.

Преобразователь данных: на уровне инфраструктуры преобразователь сущности в DO не имеет стандартного имени, но для того, чтобы отличить преобразователь данных, мы называем этот преобразователь преобразователем данных. Здесь следует отметить, что Data Mapper обычно относится к DAO, например Mapper of Mybatis. Источник Data Mapper также находится в книге P EAA:Data Mapper

Если Ассемблер пишется вручную, мы обычно реализуем два типа методов, как показано ниже, логика Конвертера Данных похожа на эту, пропускаем.

public class DtoAssembler {
    // 通过各种实体,生成DTO
    public OrderDTO toDTO(Order order, Item item) {
        OrderDTO dto = new OrderDTO();
        dto.setId(order.getId());
        dto.setItemTitle(item.getTitle()); // 从多个对象里取值,且字段名称不一样
        dto.setDetailAddress(order.getAddress.getDetail()); // 可以读取复杂嵌套字段
        // 省略N行
        return dto;
    }

    // 通过DTO,生成实体
    public Item toEntity(ItemDTO itemDTO) {
        Item entity = new Item();
        entity.setId(itemDTO.getId());
        // 省略N行
        return entity;
    }
}

Мы видим, что, абстрагируя объект Assembler/Converter, мы можем объединить сложную логику преобразования в один объект и можем хорошо проводить модульное тестирование. Это также очень хорошо сближает логику преобразования в общих кодах. Это очень удобно при использовании вызывающей стороной (пожалуйста, игнорируйте различную логику исключений):

public class Application {
    private DtoAssembler assembler;
    private OrderRepository orderRepository;
    private ItemRepository itemRepository;

    public OrderDTO getOrderDetail(Long orderId) {
        Order order = orderRepository.find(orderId);
        Item item = itemRepository.find(order.getItemId());
        return assembler.toDTO(order, item); // 原来的很多复杂转化逻辑都收敛到一行代码了
    }
}

Хотя Ассемблер/Конвертер является очень полезным объектом, когда бизнес сложен, рукописный Ассемблер/Конвертер требует много времени и подвержен ошибкам, поэтому в отрасли будет множество решений Bean Mapping, которые по существу делятся на Динамическое и статическое отображение.

Схема динамического сопоставления включает в себя относительно примитивные BeanUtils.copyProperties, Dozer, который можно настроить через xml, и т. д. Его ядром является динамическое присвоение значений в соответствии с отражением во время выполнения. Недостаток динамической схемы в том, что она имеет большое количество вызовов отражения, низкую производительность и большой объем памяти, что не подходит для приложений с особенно высоким параллелизмом.

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

После использования MapStruct будет сэкономлено много средств, а код будет выглядеть следующим образом:

@org.mapstruct.Mapper
public interface DtoAssembler { // 注意这里变成了一个接口,MapStruct会生成实现类
    DtoAssembler INSTANCE = Mappers.getMapper(DtoAssembler.class);

    // 在这里只需要指出字段不一致的情况,支持复杂嵌套
    @Mapping(target = "itemTitle", source = "item.title")
    @Mapping(target = "detailAddress", source = "order.address.detail")
    OrderDTO toDTO(Order order, Item item);

    // 如果字段没有不一致,一行注解都不需要
    Item toEntity(ItemDTO itemDTO);
}

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

Сводная спецификация модели

С точки зрения сложности использования различение DO, Entity и DTO приводит к увеличению объема кода (с 1 до 3+2+N). Однако в реальных сложных бизнес-сценариях ценность, приносимая различением моделей по функциям, заключается в функциональной единстве, проверяемости и предсказуемости и, в конечном счете, в уменьшении логической сложности.

Спецификация кода репозитория

спецификация интерфейса

Как упоминалось выше, традиционный Data Mapper (DAO) относится к «прошивке» и сильно привязан к базовой реализации (БД, Кэш, файловая система и т. д.) Если он используется напрямую, код будет «застывшим». Следовательно, чтобы отразить характеристики «программного обеспечения» в дизайне репозитория, следует обратить внимание на следующие три момента:

1. Имя интерфейса не должно использовать синтаксис базовой реализации.: Наши общие вставка, выбор, обновление и удаление относятся к синтаксису SQL Использование этих слов эквивалентно связыванию с базовой реализацией БД. Вместо этого мы должны относиться к репозиторию как к нейтральному интерфейсу, подобному коллекции, используя синтаксис вроде «найти, сохранить, удалить». Здесь следует отметить, что само различие между вставкой/добавлением и обновлением также является логикой, тесно связанной с нижележащим уровнем.Некоторые хранилища, такие как кэши, на самом деле не имеют разницы между вставкой и обновлением.В этом случае нейтральное сохранение используется интерфейс. , а затем вызовите интерфейс вставки или обновления DAO в соответствии с конкретной реализацией.

2. Входные и выходные параметры не должны использовать базовый формат данных.: Следует помнить, что Репозиторий работает с объектом Entity (на самом деле это должен быть Совокупный корень) и не должен напрямую работать с нижележащим DO. Идя дальше, интерфейс репозитория должен фактически существовать на уровне предметной области, а реализация DO вообще не видна. Это также является надежной гарантией предотвращения проникновения базовой логики реализации в бизнес-код.

3. Следует избегать так называемого «общего» шаблона репозитория.: многие фреймворки ORM предоставляют «универсальный» интерфейс репозитория, а затем фреймворк автоматически реализует интерфейс с помощью аннотаций.Типичными примерами являются Spring Data, Entity Framework и т. д. Преимущество этого фреймворка в том, что его легко реализовать посредством настройки в простых Но недостатком является то, что в принципе нет возможности расширения (например, добавления пользовательской логики кеша), и в будущем он может быть отменен и переработан. Конечно, избегание здесь общности не означает, что не может быть базовых интерфейсов и общих вспомогательных классов, как показано ниже. Сначала мы определяем базовый базовый класс интерфейса Repository и некоторые классы интерфейса Marker:

public interface Repository<T extends Aggregate<ID>, ID extends Identifier> {

    /**
     * 将一个Aggregate附属到一个Repository,让它变为可追踪。
     * Change-Tracking在下文会讲,非必须
     */
    void attach(@NotNull T aggregate);

    /**
     * 解除一个Aggregate的追踪
     * Change-Tracking在下文会讲,非必须
     */
    void detach(@NotNull T aggregate);

    /**
     * 通过ID寻找Aggregate。
     * 找到的Aggregate自动是可追踪的
     */
    T find(@NotNull ID id);

    /**
     * 将一个Aggregate从Repository移除
     * 操作后的aggregate对象自动取消追踪
     */
    void remove(@NotNull T aggregate);

    /**
     * 保存一个Aggregate
     * 保存后自动重置追踪条件
     */
    void save(@NotNull T aggregate);
}

// 聚合根的Marker接口
public interface Aggregate<ID extends Identifier> extends Entity<ID> {

}

// 实体类的Marker接口
public interface Entity<ID extends Identifier> extends Identifiable<ID> {

}

public interface Identifiable<ID extends Identifier> {
    ID getId();
}

// ID类型DP的Marker接口
public interface Identifier extends Serializable {

}

Собственный интерфейс предприятия необходимо расширить только на базовом интерфейсе, например, заказ:

// 代码在Domain层
public interface OrderRepository extends Repository<Order, OrderId> {

    // 自定义Count接口,在这里OrderQuery是一个自定义的DTO
    Long count(OrderQuery query);

    // 自定义分页查询接口
    Page<Order> query(OrderQuery query);

    // 自定义有多个条件的查询接口
    Order findInStore(OrderId id, StoreId storeId);
}

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

Следует еще раз подчеркнуть, что интерфейс репозитория находится на уровне предметной области, а класс реализации — на уровне инфраструктуры.

Реализация базы репозитория

Возьмем пример простейшей реализации репозитория. Обратите внимание, что OrderRepositoryImpl находится на уровне инфраструктуры:

// 代码在Infrastructure层
@Repository // Spring的注解
public class OrderRepositoryImpl implements OrderRepository {
    private final OrderDAO dao; // 具体的DAO接口
    private final OrderDataConverter converter; // 转化器

    public OrderRepositoryImpl(OrderDAO dao) {
        this.dao = dao;
        this.converter = OrderDataConverter.INSTANCE;
    }

    @Override
    public Order find(OrderId orderId) {
        OrderDO orderDO = dao.findById(orderId.getValue());
        return converter.fromData(orderDO);
    }

    @Override
    public void remove(Order aggregate) {
        OrderDO orderDO = converter.toData(aggregate);
        dao.delete(orderDO);
    }

    @Override
    public void save(Order aggregate) {
        if (aggregate.getId() != null && aggregate.getId().getValue() > 0) {
            // update
            OrderDO orderDO = converter.toData(aggregate);
            dao.update(orderDO);
        } else {
            // insert
            OrderDO orderDO = converter.toData(aggregate);
            dao.insert(orderDO);
            aggregate.setId(converter.fromData(orderDO).getId());
        }
    }

    @Override
    public Page<Order> query(OrderQuery query) {
        List<OrderDO> orderDOS = dao.queryPaged(query);
        long count = dao.count(query);
        List<Order> result = orderDOS.stream().map(converter::fromData).collect(Collectors.toList());
        return Page.with(result, query, count);
    }

    @Override
    public Order findInStore(OrderId id, StoreId storeId) {
        OrderDO orderDO = dao.findInStore(id.getValue(), storeId.getValue());
        return converter.fromData(orderDO);
    }

}

Из приведенной выше реализации мы можем увидеть некоторые подпрограммы: все Entity/Aggregate будут преобразованы в DO, а затем, согласно бизнес-сценарию, будет вызван соответствующий метод DAO для работы, и, если необходимо, DO будет преобразован обратно в Сущность потом. Код в основном очень прост.Единственное, на что вам нужно обратить внимание, это метод сохранения.Вы должны решить, нужно ли обновлять или вставлять агрегат, в зависимости от того, существует ли идентификатор агрегата и больше ли он 0.

Реализация репозитория

Реализация репозитория для одного объекта, как правило, относительно проста, но когда дело доходит до совокупного корня нескольких объектов, это будет более проблематично.Основная причина в том, что за одну операцию не нужно изменять все объекты в агрегате, но если вы используете простое письмо, это приведет к множеству бесполезных операций с БД.

Чтобы привести общий пример, в сценарии основного и дочернего ордеров, ордер основного ордера будет содержать несколько дочерних ордеров LineItem, Предположим, есть операция по изменению цены определенного дочернего ордера, которая изменит цену основного ордера. одновременно, но не влияет на другие дочерние заказы:

Если сделать это с очень наивной реализацией, это приведет к еще двум бесполезным операциям обновления, а именно:

public class OrderRepositoryImpl extends implements OrderRepository {
    private OrderDAO orderDAO;
    private LineItemDAO lineItemDAO;
    private OrderDataConverter orderConverter;
    private LineItemDataConverter lineItemConverter;

    // 其他逻辑省略

    @Override
    public void save(Order aggregate) {
        if (aggregate.getId() != null && aggregate.getId().getValue() > 0) {
            // 每次都将Order和所有LineItem全量更新
            OrderDO orderDO = orderConverter.toData(aggregate);
            orderDAO.update(orderDO);
            for (LineItem lineItem: aggregate.getLineItems()) {
                save(lineItem);
            }
        } else {
            // 插入逻辑省略
        }
    }

    private void save(LineItem lineItem) {
        if (lineItem.getId() != null && lineItem.getId().getValue() > 0) {
            LineItemDO lineItemDO = lineItemConverter.toData(lineItem);
            lineItemDAO.update(lineItemDO);
        } else {
            LineItemDO lineItemDO = lineItemConverter.toData(lineItem);
            lineItemDAO.insert(lineItemDO);
            lineItem.setId(lineItemConverter.fromData(lineItemDO).getId());
        }
    }
}

В этом случае это приведет к 4 операциям UPDATE, но на самом деле нужны только 2. В большинстве случаев эта стоимость невелика и приемлема, но в крайнем случае (когда много сущностей, не являющихся Aggregate Root) приведет к большому количеству бесполезных операций записи.

Отслеживание изменений

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

Решение этой проблемы заключается в том, что он должен иметь возможность определять, какие объекты были изменены, и работать только с теми объектами, которые были изменены, поэтому необходимо добавить возможность отслеживания изменений. Другими словами, логика кода, которая первоначально оценивалась многими людьми, теперь может быть автоматически реализована с помощью отслеживания изменений, так что пользователь действительно заботится только о работе Aggregate. В предыдущем случае благодаря отслеживанию изменений система может определить, что изменились только LineItem2 и Order, поэтому нужно создать только два UPDATE.

В отрасли есть два основных решения для отслеживания изменений:

1. Решение на основе моментальных снимков: После извлечения данных из БД снимок сохраняется в памяти, а затем сравнивается со снимком при записи данных. Общие реализации, такие как Hibernate

2. Решение на основе прокси: После получения данных из БД добавьте фасет ко всем сеттерам путем переплетения, чтобы определить, вызван ли сеттер и изменилось ли значение.Если оно изменилось, пометьте его как Dirty. При сохранении судите, нужно ли его обновлять по Dirty. Общие реализации, такие как Entity Framework.

Преимущество схемы Snapshot в том, что она относительно проста, а стоимость заключается в работе полного Diff (обычно Reflection) и потреблении памяти для сохранения Snapshot при каждом его сохранении. Преимущество прокси-схемы в том, что она имеет высокую производительность и почти не требует дополнительных затрат, а недостаток в том, что ее трудно реализовать, и нелегко обнаружить изменения во вложенных объектах, когда есть вложенная связь (например, добавление и удаление подсписков и т. д.), может вызвать ошибки.

Из-за сложности прокси-решения основная отрасль (включая EF Core) использует решение Snapshot. Еще одним преимуществом здесь является то, что вы можете узнать, какие поля изменились через Diff, а затем обновить только измененные поля, снова уменьшив стоимость UPDATE.

Здесь я кратко опубликую реализацию нашего собственного Snapshot. Код не сложный, а также очень простой для каждой команды реализовать самостоятельно. Некоторые коды приведены только для справки:

DbRepositorySupport

// 这个类是一个通用的支撑类,为了减少开发者的重复劳动。在用的时候需要继承这个类
public abstract class DbRepositorySupport<T extends Aggregate<ID>, ID extends Identifier> implements Repository<T, ID> {

    @Getter
    private final Class<T> targetClass;

    // 让AggregateManager去维护Snapshot
    @Getter(AccessLevel.PROTECTED)
    private AggregateManager<T, ID> aggregateManager;

    protected DbRepositorySupport(Class<T> targetClass) {
        this.targetClass = targetClass;
        this.aggregateManager = AggregateManager.newInstance(targetClass);
    }

    /**
     * 这几个方法是继承的子类应该去实现的
     */
    protected abstract void onInsert(T aggregate);
    protected abstract T onSelect(ID id);
    protected abstract void onUpdate(T aggregate, EntityDiff diff);
    protected abstract void onDelete(T aggregate);

    /**
     * Attach的操作就是让Aggregate可以被追踪
     */
    @Override
    public void attach(@NotNull T aggregate) {
        this.aggregateManager.attach(aggregate);
    }

    /**
     * Detach的操作就是让Aggregate停止追踪
     */
    @Override
    public void detach(@NotNull T aggregate) {
        this.aggregateManager.detach(aggregate);
    }

    @Override
    public T find(@NotNull ID id) {
        T aggregate = this.onSelect(id);
        if (aggregate != null) {
            // 这里的就是让查询出来的对象能够被追踪。
            // 如果自己实现了一个定制查询接口,要记得单独调用attach。
            this.attach(aggregate);
        }
        return aggregate;
    }

    @Override
    public void remove(@NotNull T aggregate) {
        this.onDelete(aggregate);
        // 删除停止追踪
        this.detach(aggregate);
    }

    @Override
    public void save(@NotNull T aggregate) {
        // 如果没有ID,直接插入
        if (aggregate.getId() == null) {
            this.onInsert(aggregate);
            this.attach(aggregate);
            return;
        }

        // 做Diff
        EntityDiff diff = aggregateManager.detectChanges(aggregate);
        if (diff.isEmpty()) {
            return;
        }

        // 调用UPDATE
        this.onUpdate(aggregate, diff);

        // 最终将DB带来的变化更新回AggregateManager
        aggregateManager.merge(aggregate);
    }

}

Потребителю нужно только наследовать DbRepositorySupport:

public class OrderRepositoryImpl extends DbRepositorySupport<Order, OrderId> implements OrderRepository {
    private OrderDAO orderDAO;
    private LineItemDAO lineItemDAO;
    private OrderDataConverter orderConverter;
    private LineItemDataConverter lineItemConverter;

    // 部分代码省略,见上文

    @Override
    protected void onUpdate(Order aggregate, EntityDiff diff) {
        if (diff.isSelfModified()) {
            OrderDO orderDO = converter.toData(aggregate);
            orderDAO.update(orderDO);
        }

        Diff lineItemDiffs = diff.getDiff("lineItems");
        if (lineItemDiffs instanceof ListDiff) {
            ListDiff diffList = (ListDiff) lineItemDiffs;
            for (Diff itemDiff : diffList) {
                if (itemDiff.getType() == DiffType.Removed) {
                    LineItem line = (LineItem) itemDiff.getOldValue();
                    LineItemDO lineDO = lineItemConverter.toData(line);
                    lineItemDAO.delete(lineDO);
                }
                if (itemDiff.getType() == DiffType.Added) {
                    LineItem line = (LineItem) itemDiff.getNewValue();
                    LineItemDO lineDO = lineItemConverter.toData(line);
                    lineItemDAO.insert(lineDO);
                }
                if (itemDiff.getType() == DiffType.Modified) {
                    LineItem line = (LineItem) itemDiff.getNewValue();
                    LineItemDO lineDO = lineItemConverter.toData(line);
                    lineItemDAO.update(lineDO);
                }
            }
        }
    }
}

Реализация AggregateManager, в основном через ThreadLocal, чтобы избежать ситуации, когда несколько потоков совместно используют один и тот же объект.

class ThreadLocalAggregateManager<T extends Aggregate<ID>, ID extends Identifier> implements AggregateManager<T, ID> {

    private ThreadLocal<DbContext<T, ID>> context;
    private Class<? extends T> targetClass;

    public ThreadLocalAggregateManager(Class<? extends T> targetClass) {
        this.targetClass = targetClass;
        this.context = ThreadLocal.withInitial(() -> new DbContext<>(targetClass));
    }

    public void attach(T aggregate) {
        context.get().attach(aggregate);
    }

    @Override
    public void attach(T aggregate, ID id) {
        context.get().setId(aggregate, id);
        context.get().attach(aggregate);
    }

    @Override
    public void detach(T aggregate) {
        context.get().detach(aggregate);
    }

    @Override
    public T find(ID id) {
        return context.get().find(id);
    }

    @Override
    public EntityDiff detectChanges(T aggregate) {
        return context.get().detectChanges(aggregate);
    }

    public void merge(T aggregate) {
        context.get().merge(aggregate);
    }
}

class DbContext<T extends Aggregate<ID>, ID extends Identifier> {

    @Getter
    private Class<? extends T> aggregateClass;

    private Map<ID, T> aggregateMap = new HashMap<>();

    public DbContext(Class<? extends T> aggregateClass) {
        this.aggregateClass = aggregateClass;
    }

    public void attach(T aggregate) {
        if (aggregate.getId() != null) {
            if (!aggregateMap.containsKey(aggregate.getId())) {
                this.merge(aggregate);
            }
        }
    }

    public void detach(T aggregate) {
        if (aggregate.getId() != null) {
            aggregateMap.remove(aggregate.getId());
        }
    }

    public EntityDiff detectChanges(T aggregate) {
        if (aggregate.getId() == null) {
            return EntityDiff.EMPTY;
        }
        T snapshot = aggregateMap.get(aggregate.getId());
        if (snapshot == null) {
            attach(aggregate);
        }
        return DiffUtils.diff(snapshot, aggregate);
    }

    public T find(ID id) {
        return aggregateMap.get(id);
    }

    public void merge(T aggregate) {
        if (aggregate.getId() != null) {
            T snapshot = SnapshotUtils.snapshot(aggregate);
            aggregateMap.put(aggregate.getId(), snapshot);
        }
    }

    public void setId(T aggregate, ID id) {
        ReflectionUtils.writeField("id", aggregate, id);
    }
}

Запустите один тест (обратите внимание, что в этом случае я объединил Order и LineItem в одну таблицу):

@Test
public void multiInsert() {
    OrderDAO dao = new MockOrderDAO();
    OrderRepository repo = new OrderRepositoryImpl(dao);

    Order order = new Order();
    order.setUserId(new UserId(11L));
    order.setStatus(OrderState.ENABLED);
    order.addLineItem(new ItemId(13L), new Quantity(5), new Money(4));
    order.addLineItem(new ItemId(14L), new Quantity(2), new Money(3));

    System.out.println("第一次保存前");
    System.out.println(order);

    repo.save(order);
    System.out.println("第一次保存后");
    System.out.println(order);

    order.getLineItems().get(0).setQuantity(new Quantity(3));
    order.pay();
    repo.save(order);

    System.out.println("第二次保存后");
    System.out.println(order);
}

Результат одного теста:

第一次保存前
Order(id=null, userId=11, lineItems=[LineItem(id=null, itemId=13, quantity=5, price=4), LineItem(id=null, itemId=14, quantity=2, price=3)], status=ENABLED)

INSERT OrderDO: OrderDO(id=null, parentId=null, itemId=0, userId=11, quantity=0, price=0, status=2)
UPDATE OrderDO: OrderDO(id=1001, parentId=1001, itemId=0, userId=11, quantity=0, price=0, status=2)
INSERT OrderDO: OrderDO(id=null, parentId=1001, itemId=13, userId=11, quantity=5, price=4, status=2)
INSERT OrderDO: OrderDO(id=null, parentId=1001, itemId=14, userId=11, quantity=2, price=3, status=2)

第一次保存后
Order(id=1001, userId=11, lineItems=[LineItem(id=1002, itemId=13, quantity=5, price=4), LineItem(id=1003, itemId=14, quantity=2, price=3)], status=ENABLED)

UPDATE OrderDO: OrderDO(id=1001, parentId=1001, itemId=0, userId=11, quantity=0, price=0, status=3)
UPDATE OrderDO: OrderDO(id=1002, parentId=1001, itemId=13, userId=11, quantity=3, price=4, status=3)

第二次保存后
Order(id=1001, userId=11, lineItems=[LineItem(id=1002, itemId=13, quantity=3, price=4), LineItem(id=1003, itemId=14, quantity=2, price=3)], status=PAID)

Другие соображения

Параллельная оптимистическая блокировка

В случае высокого параллелизма, если используется описанный выше метод отслеживания изменений, из-за данных моментального снимка в локальной памяти.возможныйНесоответствие с данными БД приведет к конфликтам параллелизма.В настоящее время при обновлении необходимо добавлять оптимистичные блокировки. Конечно, Best Practice для обычных операций базы данных также должны иметь оптимистичные блокировки, но в этом случае вам нужно помнить об обновлении значения в локальном снимке после конфликта оптимистичных блокировок.

возможная ошибка

На самом деле это не баг, но я надеюсь, каждый может обратить на это внимание отдельно.Один из побочных эффектов использования Snapshot заключается в том, что если Entity не обновляется, а затем вызывается метод сохранения, БД фактически не будет обновляться в в этот раз. Эта логика согласуется с логикой Hibernate и является неотъемлемым свойством метода Snapshot. Если вы хотите принудительно обновить БД, рекомендуется вручную изменить поле, такое как gmtModified, а затем вызвать save.

Путь миграции репозитория

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

Предположим, что существующий традиционный код содержит следующие классы (опять же на примере порядка):

1. ЗаказатьDO 2. ЗаказатьДАО

Шаблон репозитория можно реализовать постепенно, выполнив следующие шаги:

1. Создайте класс объекта Order, начальные поля могут быть согласованы с OrderDO. 2. Сгенерируйте OrderDataConverter, в основном 2 строки кода можно сделать через MapStruct. 3. Напишите модульные тесты, чтобы убедиться, что преобразование между Order и OrderDO на 100% правильно. 4. Создайте интерфейс и реализацию OrderRepository и убедитесь в правильности OrderRepository посредством модульного тестирования. 5. Измените место, где используется OrderDO в исходном коде, на Order 6. Измените места, где используется OrderDAO в исходном коде, на OrderRepository. 7. Обеспечьте согласованность бизнес-логики с помощью единого теста.

Поздравляем! Отныне класс сущности Order и его бизнес-логика могут быть изменены по желанию.Единственное, что вам нужно делать каждый раз, когда вы его модифицируете, — это менять Converter, который полностью отделен от базовой реализации.

написать на обороте

Спасибо за терпение, чтобы увидеть, что все люди здесь - настоящая любовь DDD. Один вопрос: можете ли вы часто использовать архитектуру DDD в своей повседневной работе для развития своего бизнеса? Есть ли у вас среда, в которой вы можете применить то, чему научились, на практике?