从单体到微服务:用DDD思想拆分臃肿的在线图书馆系统实战

当你的代码库膨胀到每次修改都像在拆解一颗定时炸弹时,就该考虑架构转型了。我最近主导了一个在线图书馆系统的重构项目,这个拥有十年历史的单体应用已经发展到任何功能变更都需要跨五个模块协调的境地。本文将分享我们如何运用领域驱动设计(DDD)方法论,将这个庞然大物拆分为六个松耦合的微服务,过程中产生的代码对比和架构决策或许能给你带来启发。

1. 识别限界上下文:解剖图书馆业务领域

在项目启动阶段,我们组织了为期两周的"领域探索工作坊",邀请业务专家、产品经理和核心开发人员共同梳理业务流程。通过事件风暴(Event Storming)工作法,最终识别出六个核心子域:

  • 图书编目服务:管理ISBN元数据、分类体系和实体馆藏
  • 借阅管理服务:处理借书、还书、续借等核心流程
  • 用户档案服务:存储读者信息、借阅历史和信用评级
  • 预约调度服务:管理热门书籍的预约队列和通知
  • 罚款计算服务:根据规则计算逾期费用
  • 报表分析服务:生成运营统计和读者行为分析
// 重构前的单体结构典型问题示例
public class LibraryMonolith {
    private List<Book> books;
    private List<User> users;
    private List<Loan> loans;
    private List<Reservation> reservations;
    private List<Fine> fines;
    
    // 混杂的业务方法
    public void processReturn(Book book, User user) {
        // 更新图书状态
        book.setAvailable(true);
        
        // 计算可能产生的罚款
        Fine fine = calculateFine(book, user);
        if(fine != null) {
            user.addFine(fine);
            sendFineNotice(user, fine);
        }
        
        // 检查预约队列
        processReservationQueue(book);
        
        // 更新用户信用评分
        updateUserCreditScore(user);
    }
}

通过上下文映射图,我们明确了各子域间的交互模式:借阅服务需要与用户服务保持强一致性(合作关系),而报表服务只需定期从其他服务同步数据(客户-供应商关系)。特别值得注意的是,图书的物理位置信息被划定为"共享内核",因为编目和借阅服务都需要访问这些数据。

2. 战术设计落地:从聚合根到微服务接口

在确定限界上下文后,我们开始运用DDD战术模式进行详细设计。以借阅管理服务为例,其核心聚合根Loan的设计经历了三次迭代:

第一版设计问题

// 初始设计的Loan聚合根承担了过多职责
public class Loan {
    private Book book;  // 包含完整图书信息
    private User user;  // 包含完整用户信息
    private LocalDate dueDate;
    
    // 业务逻辑混杂
    public boolean isOverdue() {
        return LocalDate.now().isAfter(dueDate);
    }
    
    public double calculateFine() {
        return isOverdue() ? 
            ChronoUnit.DAYS.between(dueDate, LocalDate.now()) * 0.5 : 0;
    }
}

最终版设计

// 重构后的Loan聚合根只关注核心状态和行为
public class Loan {
    private LoanId id;
    private BookId bookId;  // 只存储引用ID
    private UserId userId;  // 只存储引用ID
    private LoanPeriod period;
    private LoanStatus status;
    
    public static Loan create(BookId bookId, UserId userId, LoanPolicy policy) {
        // 验证业务规则
        return new Loan(bookId, userId, policy.determinePeriod());
    }
    
    public void renew(RenewalPolicy policy) {
        this.period = policy.calculateNewPeriod(period);
    }
}

// 领域服务处理跨聚合逻辑
public class LoanProcessingService {
    private final FineCalculator fineCalculator;
    
    public LoanReceipt returnBook(LoanId loanId) {
        Loan loan = loanRepository.findById(loanId);
        loan.complete();
        
        Fine fine = fineCalculator.calculate(loan);
        if(fine != null) {
            eventPublisher.publish(new FineIssuedEvent(fine));
        }
        
        return new LoanReceipt(loan, fine);
    }
}

我们为每个微服务设计了明确的API边界。以借阅服务为例,其接口遵循以下原则:

  1. 使用RESTful风格但避免贫血模型
  2. 每个端点对应一个明确的领域能力
  3. 采用HATEOAS提供可发现性
@RestController
@RequestMapping("/loans")
public class LoanController {
    @PostMapping
    public ResponseEntity<LoanResponse> borrowBook(
        @RequestBody BorrowCommand command) {
        
        Loan loan = loanService.borrow(
            command.getBookId(), 
            command.getUserId());
            
        return ResponseEntity.created(
            linkTo(methodOn(LoanController.class)
                .getLoan(loan.getId())).toUri())
            .body(LoanResponse.from(loan));
    }
    
    @PostMapping("/{id}/returns")
    public ResponseEntity<ReturnReceipt> returnBook(
        @PathVariable LoanId id) {
        
        ReturnReceipt receipt = loanService.returnBook(id);
        return ResponseEntity.ok(receipt);
    }
}

3. 解决分布式系统挑战:一致性模式与防腐层实现

微服务架构最棘手的挑战是如何在服务间维持数据一致性。我们采用了多种模式组合的方案:

对于强一致性要求

  • 使用Saga模式处理跨服务事务
  • 实现命令查询职责分离(CQRS)
// Saga执行器示例
public class BookBorrowingSaga implements Saga<BorrowingSagaData> {
    private final CommandDispatcher dispatcher;
    
    @Override
    public void onSagaStarted(BorrowingSagaData data) {
        dispatcher.send(new CheckBookAvailability(data.getBookId()));
    }
    
    @SagaEventHandler
    public void on(BookAvailable event) {
        dispatcher.send(new PlaceHoldCommand(
            event.getBookId(), 
            sagaData.getUserId()));
    }
    
    @SagaEventHandler
    public void on(HoldPlaced event) {
        dispatcher.send(new CreateLoanCommand(
            event.getBookId(),
            event.getUserId()));
        markAsCompleted();
    }
}

对于最终一致性场景

  • 采用事件驱动架构
  • 实现事务性发件箱模式
// 使用Spring TransactionalEventListener实现发件箱
@Service
@RequiredArgsConstructor
public class DomainEventPublisher {
    private final EventStore eventStore;
    
    @TransactionalEventListener(phase = AFTER_COMMIT)
    public void handleDomainEvent(DomainEvent event) {
        eventStore.append(event);
    }
}

// 定时发送未发布的事件
@Scheduled(fixedRate = 5000)
public void publishPendingEvents() {
    eventStore.getUnpublishedEvents()
        .forEach(event -> {
            eventBus.publish(event);
            eventStore.markAsPublished(event);
        });
}

防腐层(ACL)的实现我们采用了多级防护策略:

  1. 接口适配层:将外部服务模型转换为领域模型
  2. 缓存层:减少对外部服务的直接依赖
  3. 熔断机制:在依赖服务不可用时提供降级方案
// 用户服务防腐层实现
public class UserServiceAntiCorruptionLayer {
    private final UserClient userClient;
    private final CacheManager cacheManager;
    
    public UserProfile getUserProfile(UserId id) {
        return cacheManager.get(id.toString(), 
            () -> userClient.getUser(id)
                .map(this::toDomainModel)
                .orElseThrow(() -> new UserNotFoundException(id)));
    }
    
    private UserProfile toDomainModel(UserDto dto) {
        return new UserProfile(
            UserId.of(dto.getId()),
            dto.getName(),
            CreditRating.from(dto.getCreditScore()));
    }
}

4. 数据存储策略:从共享数据库到独立存储

数据库拆分是微服务迁移中最具破坏性的环节。我们采用分阶段方案:

阶段一:逻辑分离

  • 为每个服务创建专属schema
  • 使用数据库视图保持兼容性
-- 原单体数据库中的books表
CREATE TABLE books (
    id BIGINT PRIMARY KEY,
    title VARCHAR(255),
    author VARCHAR(255),
    isbn VARCHAR(20),
    location_code VARCHAR(50),
    status VARCHAR(20)
);

-- 编目服务的专用schema
CREATE SCHEMA catalog;
CREATE TABLE catalog.books (
    id BIGINT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    author VARCHAR(255) NOT NULL,
    isbn VARCHAR(20) NOT NULL UNIQUE,
    metadata JSONB
);

-- 借阅服务兼容视图
CREATE VIEW lending.available_books AS
SELECT id, isbn, location_code 
FROM public.books 
WHERE status = 'AVAILABLE';

阶段二:物理分离

  • 使用Debezium实现CDC数据同步
  • 逐步迁移读操作到新数据库
// 使用Spring Data实现多数据源
@Configuration
@EnableJpaRepositories(
    basePackages = "com.library.catalog",
    entityManagerFactoryRef = "catalogEntityManager",
    transactionManagerRef = "catalogTransactionManager"
)
public class CatalogDataSourceConfig {
    
    @Bean
    @ConfigurationProperties("spring.datasource.catalog")
    public DataSource catalogDataSource() {
        return DataSourceBuilder.create().build();
    }
    
    @Bean
    public LocalContainerEntityManagerFactoryBean catalogEntityManager(
        EntityManagerFactoryBuilder builder) {
        return builder
            .dataSource(catalogDataSource())
            .packages("com.library.catalog")
            .persistenceUnit("catalog")
            .build();
    }
}

阶段三:最终切换

  • 实现双写模式过渡期
  • 使用数据比对工具验证一致性

我们为每个服务选择了最适合的存储技术:

  • 编目服务:PostgreSQL + PostGIS(支持空间查询)
  • 借阅服务:MongoDB(文档模型适合借阅记录)
  • 报表服务:Elasticsearch + ClickHouse(分析查询优化)

5. 部署架构与监控:保障演进式架构的稳定性

为支持渐进式迁移,我们设计了蓝绿部署架构:

  1. 流量路由层:使用Envoy实现基于路径的路由
# Envoy路由配置示例
routes:
- match:
    path: "/catalog/**"
  route:
    cluster: catalog-service
- match:
    path: "/legacy/**"
  route:
    cluster: monolith-service
  1. 监控体系
  • 使用Prometheus采集各服务指标
  • 通过Grafana实现业务级监控看板
  • 建立SLO(服务水平目标)来衡量迁移进度
  1. 回滚机制
  • 每个发布版本保留快照
  • 关键业务路径的自动化测试覆盖
  • 功能开关控制新老实现切换
// 功能开关示例
@RestController
@RequestMapping("/books")
public class BookSearchController {
    private final FeatureFlags featureFlags;
    private final LegacySearchService legacySearch;
    private final CatalogSearchService catalogSearch;
    
    @GetMapping
    public ResponseEntity<SearchResults> search(
        @RequestParam String query) {
        
        if(featureFlags.isEnabled("new-search")) {
            return catalogSearch.findBooks(query);
        } else {
            return legacySearch.findBooks(query);
        }
    }
}

迁移过程中我们积累了几个关键经验:每天同步的迁移状态看板让团队保持目标一致,逐步替换而非一次性重写的策略降低了风险,而严格的契约测试则确保了接口变更不会破坏现有功能。

更多推荐