Технические эксперты Alibaba объясняют второй пункт серии DDD — архитектуру приложений

Архитектура
Технические эксперты Alibaba объясняют второй пункт серии DDD — архитектуру приложений

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

Произведено | Alibaba New Retail Tao Technology

Подробное объяснение DDD первая бомба - Примитив домена

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

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

1. Независимость от фреймворка: архитектура не должна зависеть от внешней библиотеки или фреймворка и не должна быть связана со структурой фреймворка.

2. Независимость от пользовательского интерфейса: стиль, отображаемый на стойке регистрации, может измениться в любое время (сегодня это может быть веб-страница, завтра — консоль, послезавтра — независимое приложение), но базовая архитектура не должна меняться соответствующим образом.

3. Независимость от базового источника данных. Независимо от того, используете ли вы сегодня MySQL, Oracle, MongoDB, CouchDB или даже файловую систему, архитектура программного обеспечения не должна сильно меняться из-за различных базовых методов хранения данных.

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

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

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

анализ случая

Давайте сначала рассмотрим простое требование case:

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

В то же время из-за требований надзора и согласования эта деятельность по передаче должна быть зарегистрирована.

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

1. Найдите учетные записи исходящего и входящего перевода в базе данных MySql и выберите использование преобразователя MyBatis для реализации DAO 2. Получите информацию об обменном курсе перевода из службы обменного курса, предоставляемой Yahoo (или другой каналы) (нижний уровень — открытый интерфейс http);

3. Рассчитайте сумму перевода, чтобы убедиться, что на счете достаточно средств и не превышается дневной лимит перевода;

4. Реализовать операции ввода и вывода, вычесть комиссию за обработку и сохранить базу данных;

5. Отправлять аудиторские сообщения Kafka для аудита и сверки;

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

public class TransferController {

    private TransferService transferService;

    public Result<Boolean> transfer(String targetAccountNumber, BigDecimal amount, HttpSession session) {
        Long userId = (Long) session.getAttribute("userId");
        return transferService.transfer(userId, targetAccountNumber, amount, "CNY");
    }
}

public class TransferServiceImpl implements TransferService {

    private static final String TOPIC_AUDIT_LOG = "TOPIC_AUDIT_LOG";
    private AccountMapper accountDAO;
    private KafkaTemplate<String, String> kafkaTemplate;
    private YahooForexService yahooForex;

    @Override
    public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
        // 1. 从数据库读取数据,忽略所有校验逻辑如账号是否存在等
        AccountDO sourceAccountDO = accountDAO.selectByUserId(sourceUserId);
        AccountDO targetAccountDO = accountDAO.selectByAccountNumber(targetAccountNumber);

        // 2. 业务参数校验
        if (!targetAccountDO.getCurrency().equals(targetCurrency)) {
            throw new InvalidCurrencyException();
        }

        // 3. 获取外部数据,并且包含一定的业务逻辑
        // exchange rate = 1 source currency = X target currency
        BigDecimal exchangeRate = BigDecimal.ONE;
        if (sourceAccountDO.getCurrency().equals(targetCurrency)) {
            exchangeRate = yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
        }
        BigDecimal sourceAmount = targetAmount.divide(exchangeRate, RoundingMode.DOWN);

        // 4. 业务参数校验
        if (sourceAccountDO.getAvailable().compareTo(sourceAmount) < 0) {
            throw new InsufficientFundsException();
        }

        if (sourceAccountDO.getDailyLimit().compareTo(sourceAmount) < 0) {
            throw new DailyLimitExceededException();
        }

        // 5. 计算新值,并且更新字段
        BigDecimal newSource = sourceAccountDO.getAvailable().subtract(sourceAmount);
        BigDecimal newTarget = targetAccountDO.getAvailable().add(targetAmount);
        sourceAccountDO.setAvailable(newSource);
        targetAccountDO.setAvailable(newTarget);

        // 6. 更新到数据库
        accountDAO.update(sourceAccountDO);
        accountDAO.update(targetAccountDO);

        // 7. 发送审计消息
        String message = sourceUserId + "," + targetAccountNumber + "," + targetAmount + "," + targetCurrency;
        kafkaTemplate.send(TOPIC_AUDIT_LOG, message);

        return Result.success(true);
    }

}

Мы видим, что часть бизнес-кода часто включает в себя различную логику, такую ​​как проверка параметров, чтение и хранение данных, бизнес-вычисления, вызов внешних служб и отправка сообщений. В этом случае, хотя он написан одним и тем же методом, в реальном коде он часто разбивается на несколько подметодов, но фактический эффект тот же.В нашей повседневной работе большая часть кода более или менее или менее близка. к такой структуре. В книге Мартина Фаулера P of EAA этот очень распространенный стиль кода называется Transaction Script. Хотя этот метод написания, подобный сценарию, не имеет функциональных проблем, в долгосрочной перспективе он имеет следующие основные проблемы: плохая ремонтопригодность, плохая масштабируемость и плохая тестируемость.

Проблема 1 — Плохая ремонтопригодность

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

Ремонтопригодность = сколько кода нужно изменить при изменении зависимостей.

Ссылаясь на приведенный выше код случая, код класса сценария транзакции сложно поддерживать из-за следующих моментов:

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

2. Обновление зависимых библиотек: AccountMapper зависит от реализации MyBatis.Если MyBatis будет обновлен в будущем, это может привести к другому использованию (см. стоимость перехода на обновление iBatis до MyBatis на основе аннотаций). Точно так же, если система ORM будет изменена в будущем, стоимость миграции будет огромной.

3. Неопределенность зависимости от сторонних сервисов. Сторонние сервисы, такие как сервис обменного курса Yahoo, вероятно, изменятся в будущем: начиная от изменений подписи API и заканчивая непригодными для использования сервисами, вам необходимо найти другие альтернативные сервисы. Затраты на модернизацию и миграцию в этих случаях огромны. В то же время необходимо соответствующим образом изменить такие решения, как восходящее, ограничение тока и объединение внешних зависимостей.

4. Изменения интерфейса API сторонних сервисов: результат, возвращаемый YahooForexService.getExchangeRate, представляет собой десятичную точку или процент? Входной параметр (источник, цель) или (цель, источник)? Кто может гарантировать, что интерфейс не изменится в будущем? Если он изменен, логика расчета основной суммы должна быть изменена соответствующим образом, иначе это приведет к потере капитала.

5. Замена промежуточного ПО: сегодня мы используем Kafka для отправки сообщений, а что, если завтра мы захотим использовать RocketMQ в облаке Alibaba? Что, если послезавтра метод сериализации сообщения изменится со String на Binary? Как изменить, если требуется фрагментация сообщения?

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

Проблема 2 — Плохая масштабируемость

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

Масштабируемость = сколько кода необходимо добавить/изменить при внесении новых требований или логических изменений.

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

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

2. Бизнес-логика не может быть использована повторно: проблема несовместимых форматов данных приведет к невозможности повторного использования основной бизнес-логики. Следствием того, что каждый вариант использования является специальной логикой, является то, что в конечном итоге это приведет к большому количеству операторов if-else, и эта логика со многими ответвлениями сильно усложнит анализ кода, и легко пропустить пограничные случаи и вызывать ошибки.

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

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

Проблема 3. Плохая тестируемая производительность

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

Тестируемость = время, затрачиваемое на выполнение каждого тестового примера * количество дополнительных тестовых случаев, необходимых для каждого требования.

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

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

2. Длительное время выполнения. Большинство внешних вызовов зависимостей требуют интенсивного ввода-вывода, например межсетевые вызовы, дисковые вызовы и т. д., и для тестирования таких вызовов ввода-вывода требуется много времени. Еще одна частая зависимость — от тяжелых фреймворков, таких как Spring, запуск контейнера Spring часто занимает много времени. Когда выполнение тестового примера занимает более 10 секунд, большинство разработчиков не проводят тестирование слишком часто.

3. Высокая степень связи: если в сценарии есть три подшага A, B и C, и каждый шаг имеет N возможных состояний, когда степень связи нескольких подшагов высока, чтобы полностью охватить все вариантов использования, не более N *N*N тестовых случаев. При объединении большего количества подэтапов количество необходимых тестовых примеров растет экспоненциально.

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

Проанализировано

Давайте еще раз проанализируем, почему возникают вышеуказанные проблемы? Потому что приведенный выше код нарушает как минимум следующие принципы проектирования программного обеспечения:

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

2. Принцип инверсии зависимостей. Принцип инверсии зависимостей требует, чтобы в коде использовались абстракции, а не конкретные реализации. В этом случае все внешние зависимости являются конкретными реализациями.Например, хотя YahooForexService является интерфейсным классом, он соответствует конкретным службам, предоставляемым Yahoo, поэтому его можно рассматривать как зависящий от реализации. Одни и те же DAO-реализации KafkaTemplate и MyBatis являются конкретными реализациями.

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

Нам нужно провести рефакторинг кода, чтобы решить эти проблемы.

Схема рефакторинга

Перед рефакторингом давайте нарисуем блок-схему, описывающую каждый шаг, выполняемый текущим кодом:

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

абстрактный уровень хранения данных

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

1. Создайте новый объект-сущность Пользователя: Сущность (Entity) — это объект домена с идентификатором.Помимо данных, он также имеет поведение. Сущность не имеет ничего общего с форматом хранения базы данных и основана на вездесущем языке в этой области.

2. Создайте новый класс интерфейса хранилища объектов AccountRepository: Repository отвечает только за хранение и чтение объектов Entity, а класс реализации Repository дополняет детали хранилища базы данных. Добавляя интерфейс репозитория, базовое соединение с базой данных можно заменить другим классом реализации.

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

Класс объекта учетной записи:

@Data
public class Account {
    private AccountId id;
    private AccountNumber accountNumber;
    private UserId userId;
    private Money available;
    private Money dailyLimit;

    public void withdraw(Money money) {
        // 转出
    }

    public void deposit(Money money) {
        // 转入
    }
}

И классы реализации AccountRepository и MyBatis:

public interface AccountRepository {
    Account find(AccountId id);
    Account find(AccountNumber accountNumber);
    Account find(UserId userId);
    Account save(Account account);
}

public class AccountRepositoryImpl implements AccountRepository {

    @Autowired
    private AccountMapper accountDAO;

    @Autowired
    private AccountBuilder accountBuilder;

    @Override
    public Account find(AccountId id) {
        AccountDO accountDO = accountDAO.selectById(id.getValue());
        return accountBuilder.toAccount(accountDO);
    }

    @Override
    public Account find(AccountNumber accountNumber) {
        AccountDO accountDO = accountDAO.selectByAccountNumber(accountNumber.getValue());
        return accountBuilder.toAccount(accountDO);
    }

    @Override
    public Account find(UserId userId) {
        AccountDO accountDO = accountDAO.selectByUserId(userId.getId());
        return accountBuilder.toAccount(accountDO);
    }

    @Override
    public Account save(Account account) {
        AccountDO accountDO = accountBuilder.fromAccount(account);
        if (accountDO.getId() == null) {
            accountDAO.insert(accountDO);
        } else {
            accountDAO.update(accountDO);
        }
        return accountBuilder.toAccount(accountDO);
    }

}

Сравнение между классом сущности Account и классом данных AccountDO выглядит следующим образом:

1. Класс данных объекта данных: AccountDO представляет собой простое отношение сопоставления с таблицей базы данных.Каждое поле соответствует столбцу таблицы базы данных.Этот объект называется объектом данных. DO имеет только данные, а не поведение. Роль AccountDO заключается в том, чтобы быстро сопоставить базу данных и избежать написания SQL непосредственно в коде. Независимо от того, используете ли вы ORM, например MyBatis или Hibernate, данные из базы данных должны быть сначала напрямую сопоставлены с DO, но в коде следует полностью избегать прямых манипуляций с DO.

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

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

1. DAO соответствует операции определенного типа базы данных, что эквивалентно инкапсуляции SQL. Объектами всех операций являются классы DO, и все интерфейсы могут быть изменены в соответствии с реализацией базы данных. Например, вставка и обновление являются специфическими для базы данных операциями.

2. Репозиторий соответствует абстракции чтения и хранения объектов Entity, которая унифицирована на уровне интерфейса и не обращает внимания на лежащую в основе реализацию. Например, сохраните объект Entity через save, но не важно, вставка это или обновление. Конкретный класс реализации Repository реализует различные операции, вызывая DAO, и реализует преобразование между AccountDO и Account через объекты Builder/Factory.

Репозиторий и сущность

1. Благодаря объекту Account избегается прямая связь между другими кодами бизнес-логики и базой данных, а при изменении полей базы данных также изменяется большое количество бизнес-логики.

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

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

В качестве класса интерфейса Repository может легко реализовать Mock или Stub, и его можно легко протестировать.

Класс реализации AccountRepositoryImpl, поскольку его обязанности выделены, должен обращать внимание только на отношение сопоставления между Account и AccountDO и отношение сопоставления между методами Repository и методами DAO, которые относительно легче тестировать.

Абстрактные сторонние сервисы

Подобно абстракции базы данных, все сторонние службы также должны решить проблему неконтролируемых сторонних служб и сильной связи входных и выходных параметров посредством абстракции. В этом примере мы абстрагируем службу ExchangeRateService и класс Domain Primitive ExchangeRate:

public interface ExchangeRateService {
    ExchangeRate getExchangeRate(Currency source, Currency target);
}

public class ExchangeRateServiceImpl implements ExchangeRateService {

    @Autowired
    private YahooForexService yahooForexService;

    @Override
    public ExchangeRate getExchangeRate(Currency source, Currency target) {
        if (source.equals(target)) {
            return new ExchangeRate(BigDecimal.ONE, source, target);
        }
        BigDecimal forex = yahooForexService.getExchangeRate(source.getValue(), target.getValue());
        return new ExchangeRate(forex, source, target);
    }

Антикоррозийный слой (ACL)

Этот общий шаблон проектирования называется Anti-Corruption Layer (Уровень защиты от коррупции или ACL). Много раз наша система будет зависеть от других систем, а зависимая система может содержать необоснованные структуры данных, API, протоколы или технические реализации.Если существует сильная зависимость от внешних систем, наша система будет «разъедена». В это время, добавляя антикоррозийный слой между системами, можно эффективно изолировать внешние зависимости и внутреннюю логику, независимо от того, как меняется внешний код, внутренний код может максимально оставаться неизменным.

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

1. Адаптер: во многих случаях внешние данные, интерфейсы и протоколы не соответствуют внутренним спецификациям.Благодаря режиму адаптера логика преобразования данных может быть инкапсулирована в ACL, чтобы уменьшить вмешательство бизнес-кода. В данном случае, инкапсулируя объекты ExchangeRate и Currency, мы преобразовали входные и выходные параметры друг друга, сделав входные и выходные параметры более соответствующими нашим стандартам.

2. Кэш: для внешних зависимостей с частыми вызовами и нечастыми изменениями данных путем внедрения логики кэширования в ACL можно эффективно уменьшить давление запросов для внешних зависимостей. В то же время логика кеша часто прописывается в бизнес-коде, и, встраивая логику кеша в ACL, можно уменьшить сложность бизнес-кода.

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

4. Простота тестирования. Как и в предыдущем репозитории, класс интерфейса ACL может легко реализовать Mock или Stub для модульного тестирования.

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

абстрактное промежуточное ПО

Подобно абстрагированию сторонних сервисов в версии 2.2, цель абстрагирования различного промежуточного ПО состоит в том, чтобы бизнес-код больше не зависел от логики реализации промежуточного ПО. Поскольку промежуточное ПО обычно должно быть универсальным, интерфейс промежуточного ПО обычно имеет тип String или Byte[], в результате чего логика сериализации/десериализации обычно смешивается с бизнес-логикой, что приводит к связующему коду. Уменьшите дублирование связующего кода за счет абстракции промежуточного программного обеспечения ACL.

В этом случае мы изолируем базовую реализацию kafka, инкапсулируя абстрактные объекты AuditMessageProducer и AuditMessage DP:

@Value
@AllArgsConstructor
public class AuditMessage {

    private UserId userId;
    private AccountNumber source;
    private AccountNumber target;
    private Money money;
    private Date date;

    public String serialize() {
        return userId + "," + source + "," + target + "," + money + "," + date;   
    }

    public static AuditMessage deserialize(String value) {
        // todo
        return null;
    }
}

public interface AuditMessageProducer {
    SendResult send(AuditMessage message);
}

public class AuditMessageProducerImpl implements AuditMessageProducer {

    private static final String TOPIC_AUDIT_LOG = "TOPIC_AUDIT_LOG";

    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;

    @Override
    public SendResult send(AuditMessage message) {
        String messageBody = message.serialize();
        kafkaTemplate.send(TOPIC_AUDIT_LOG, messageBody);
        return SendResult.success();
    }
}

Конкретный анализ аналогичен 2.2 и здесь опущен.

Инкапсулировать бизнес-логику

В этом случае большая часть бизнес-логики смешивается с внешне зависимым кодом, включая расчет суммы, проверку баланса счета, ограничения на перевод, увеличение и уменьшение суммы и т. д. Эта логическая путаница препятствует эффективному тестированию и повторному использованию базовой вычислительной логики. Здесь наше решение состоит в том, чтобы инкапсулировать всю бизнес-логику через Entity, Domain Primitive и Domain Service:

Инкапсулируйте независимую от сущностей вычислительную логику без сохранения состояния с помощью Domain Primitive

В этом случае используйте ExchangeRate для инкапсуляции логики расчета обменного курса:

BigDecimal exchangeRate = BigDecimal.ONE;
if (sourceAccountDO.getCurrency().equals(targetCurrency)) {
    exchangeRate = yahooForex.getExchangeRate(sourceAccountDO.getCurrency(), targetCurrency);
}
BigDecimal sourceAmount = targetAmount.divide(exchangeRate, RoundingMode.DOWN);
变为:

ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());
Money sourceMoney = exchangeRate.exchangeTo(targetMoney);

Инкапсулируйте поведение одного объекта с отслеживанием состояния с помощью Entity, включая проверку бизнеса.

Используйте класс сущностей Account для инкапсуляции всего поведения Account, включая проверку бизнеса, следующим образом:

@Data
public class Account {

    private AccountId id;
    private AccountNumber accountNumber;
    private UserId userId;
    private Money available;
    private Money dailyLimit;

    public Currency getCurrency() {
        return this.available.getCurrency();
    }

    // 转入
    public void deposit(Money money) {
        if (!this.getCurrency().equals(money.getCurrency())) {
            throw new InvalidCurrencyException();
        }
        this.available = this.available.add(money);
    }

    // 转出
    public void withdraw(Money money) {
        if (this.available.compareTo(money) < 0) {
            throw new InsufficientFundsException();
        }
        if (this.dailyLimit.compareTo(money) < 0) {
            throw new DailyLimitExceededException();
        }
        this.available = this.available.subtract(money);
    }
}

Исходный бизнес-код можно упростить следующим образом:

sourceAccount.deposit(sourceMoney);
targetAccount.withdraw(targetMoney);

Инкапсуляция многообъектной логики с помощью доменной службы

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

Создаем класс AccountTransferService:

public interface AccountTransferService {
    void transfer(Account sourceAccount, Account targetAccount, Money targetMoney, ExchangeRate exchangeRate);
}

public class AccountTransferServiceImpl implements AccountTransferService {
    private ExchangeRateService exchangeRateService;

    @Override
    public void transfer(Account sourceAccount, Account targetAccount, Money targetMoney, ExchangeRate exchangeRate) {
        Money sourceMoney = exchangeRate.exchangeTo(targetMoney);
        sourceAccount.deposit(sourceMoney);
        targetAccount.withdraw(targetMoney);
    }
}

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

accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);

Анализ результатов после реконструкции

Рефакторинг кода для этого случая выглядит следующим образом:

public class TransferServiceImplNew implements TransferService {

    private AccountRepository accountRepository;
    private AuditMessageProducer auditMessageProducer;
    private ExchangeRateService exchangeRateService;
    private AccountTransferService accountTransferService;

    @Override
    public Result<Boolean> transfer(Long sourceUserId, String targetAccountNumber, BigDecimal targetAmount, String targetCurrency) {
        // 参数校验
        Money targetMoney = new Money(targetAmount, new Currency(targetCurrency));

        // 读数据
        Account sourceAccount = accountRepository.find(new UserId(sourceUserId));
        Account targetAccount = accountRepository.find(new AccountNumber(targetAccountNumber));
        ExchangeRate exchangeRate = exchangeRateService.getExchangeRate(sourceAccount.getCurrency(), targetMoney.getCurrency());

        // 业务逻辑
        accountTransferService.transfer(sourceAccount, targetAccount, targetMoney, exchangeRate);

        // 保存数据
        accountRepository.save(sourceAccount);
        accountRepository.save(targetAccount);

        // 发送审计消息
        AuditMessage message = new AuditMessage(sourceAccount, targetAccount, targetMoney);
        auditMessageProducer.send(message);

        return Result.success(true);
    }
}

Видно, что рефакторинговый код имеет следующие характеристики:

1. Бизнес-логика понятна, а хранение данных и бизнес-логика полностью разделены.

2. Entity, Domain Primitive и Domain Service — все это независимые объекты без каких-либо внешних зависимостей, но они содержат всю основную бизнес-логику и могут быть полностью протестированы по отдельности.

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

Мы можем перерисовать график на основе новой структуры:

Тогда после перестановки диаграмма становится:

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

1. Нижний уровень больше не база данных, а Entity, Domain Primitive и Domain Service. Эти объекты не зависят от каких-либо внешних служб и фреймворков, а являются исключительно данными и операциями в памяти. Мы упаковываем эти объекты как Domain Layer (доменный слой). Уровень предметной области не имеет никаких внешних зависимостей.

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

3. Последний — это конкретная реализация ACL, репозитория и т. д. Эти реализации обычно зависят от внешних конкретных технических реализаций и фреймворков, поэтому все вместе они называются уровнем инфраструктуры. Объекты в веб-фреймворках, таких как Controller, обычно также относятся к уровню инфраструктуры.

Если мы сможем переписать этот код сегодня, принимая во внимание окончательные зависимости, мы можем сначала написать бизнес-логику уровня предметной области, затем написать расположение компонентов уровня приложения и, наконец, написать конкретную реализацию каждой внешней зависимости. Эта архитектурная идея и структура организации кода называется Domain-Driven Design (проектирование, управляемое доменом, или DDD). Таким образом, DDD — это не особый архитектурный проект, а пункт назначения, которого достигает весь код Transaction Script после разумного рефакторинга.

Шестиугольная архитектура DDD

В нашем традиционном коде мы обычно обращаем внимание на детали реализации и спецификации каждой внешней зависимости, но сегодня нам нужно осмелиться отказаться от исходной концепции и пересмотреть структуру кода. В приведенном выше рефакторинге кода, если мы отбросим все конкретные детали реализации Repository, ACL, Producer и т. д., мы обнаружим, что каждый внешний абстрактный класс на самом деле является входом или выходом, подобно узлу ввода-вывода в компьютерной системе. . Эта точка зрения применима и к архитектуре CQRS, разделяющей все интерфейсы на Command (вход) и Query (выход). В дополнение к вводу-выводу другая внутренняя логика является основной логикой бизнеса приложения. Основываясь на этом фундаменте, Алистер Кокберн предложил в 2005 году гексагональную архитектуру, также известную как порты и адаптеры.

В этой картине:

1. Конкретная реализация ввода-вывода находится на самом внешнем уровне модели.

2. Адаптер для каждого ввода-вывода находится в серой области.

3. Каждое ребро Hex является портом

4. Центр Hex — это основная доменная модель приложения.

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

В дополнение к архитектуре Hex в 2005 году, луковая архитектура Джеффри Палермо в 2008 году и чистая архитектура Роберта Мартина в 2017 году очень похожи. За исключением разных имен и разных точек входа, другие общие архитектуры основаны на двумерных внутренних и внешних отношениях. Это также показывает, что окончательная форма архитектуры на основе DDD аналогична. У Herberto Graca есть очень подробная диаграмма, содержащая большинство реальных классов портов, на которой стоит поучиться.

организация кода

Чтобы эффективно организовать структуру кода и избежать ситуации, когда код нижнего уровня зависит от реализации верхнего уровня, в Java мы можем обрабатывать взаимные отношения через модуль POM и зависимость POM. Проблема динамического внедрения конкретных зависимостей реализации во время выполнения решается с помощью контейнера Spring/SpringBoot. Простой граф зависимости выглядит следующим образом:

Модуль типов

Модуль «Типы» — это место, где хранятся примитивы домена, которые могут быть раскрыты. Поскольку примитивы домена представляют собой логику без сохранения состояния и могут быть доступны внешнему миру, они часто включаются во внешний интерфейс API и должны быть отдельным модулем. Модуль типов не зависит ни от какой библиотеки классов, чистый POJO.

Модуль домена

Модуль домена — это концентрация основной бизнес-логики, включая Entity с отслеживанием состояния, доменную службу доменной службы и различные внешне зависимые классы интерфейса (такие как репозиторий, ACL, промежуточное ПО и т. д. Модуль домена зависит только от модуля Types, который также является чистым POJO.

Модуль приложения

Модуль приложения в основном содержит службу приложений и некоторые связанные классы. Модуль приложения зависит от модуля домена. По-прежнему не зависит ни от какого фреймворка, чистый POJO.

Инфраструктурный модуль

Модуль Infrastructure включает в себя такие модули, как Persistence, Messaging и External. Например: модуль Persistence содержит реализацию базы данных DAO, включая объект данных, ORM Mapper, класс преобразования Entity to DO и т. д. Модуль Persistence зависит от конкретной библиотеки классов ORM, такой как MyBatis. Если вам нужно использовать схему аннотаций, предоставляемую Spring-Mybatis, вам нужно полагаться на Spring.

веб-модуль

Веб-модуль содержит связанный код, например Controller. Если вы используете SpringMVC, вам нужно полагаться на Spring.

Стартовый модуль

Модуль Start — это класс запуска SpringBoot.

контрольная работа

1. Типы и модули домена — это чистые POJO без внешних зависимостей, и они могут быть на 100% покрыты модульными тестами.

2. Код модуля Application зависит от внешних абстрактных классов, ему необходимо имитировать все внешние зависимости через среду тестирования, но он все еще может быть протестирован на 100%.

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

4. Существует два метода тестирования веб-модулей: через тест Spring MockMVC или через тест интерфейса вызова HttpClient. Тем не менее, лучше всего имитировать все классы обслуживания, от которых зависит контроллер, при тестировании. Вообще говоря, когда вы отправляете логику контроллера в службу приложений, логика контроллера становится чрезвычайно простой, и ее легко охватить на 100%.

5. Стартовый модуль: интеграционный тест приложения обычно пишется в старте. Когда модульные тесты других модулей могут покрыть 100%, интеграционный тест используется для проверки подлинности всей ссылки.

Скорость эволюции/изменения кода

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

1. Уровень предметной области относится к основной бизнес-логике и часто модифицируется. Например: раньше мне не нужно было вычитать комиссию за обработку, а теперь нужно и так далее. Entity может решать логические изменения на основе одного объекта, а доменная служба может решать изменения бизнес-логики между несколькими объектами.

2. Прикладной уровень относится к варианту использования (бизнес-вариант использования). Варианты использования в бизнесе обычно описывают требования в относительно общем направлении, а интерфейс относительно стабилен, особенно внешний интерфейс обычно не меняется часто. Чтобы добавить бизнес-вариант использования, вы можете расширить функцию, добавив службу приложений или добавив интерфейс.

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

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

Суммировать

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

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

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

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

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