ibcadmin 发表于 2019-10-12 10:23:24

后端开发实践系列之四——简单可用的CQRS编码实践

<p>本文只讲了一件事情:软件模子中存在读模子和写模子之分,CQRS便为此而生。</p>
<p>20多年前,Bertrand Meyer在他的《Object-Oriented Software Construction》一书中提出了CQS(Command Query Seperation,下令查询分离)的概念,指出:</p>
<blockquote>
<p>Every method should either be a command that performs an action, or a query that returns data to the caller, but never both. (一个方法要么作为一个“下令”实验一个操作,要么作为一次“查询”向调用方返回数据,但两者不能共存。)</p>
</blockquote>
<p>这里的“下令”可以理解为更新软件状态的写操作,Martin Fowler将此称为“Modifier”;而“查询”即为读操作,是无副作用的。这种分离的好处在于使步调变得更轻易推理与维护,由于查询操作不会更新软件状态,在编码时我们将更加有信心。试想,假如步调中出了一个bug,假如这个bug出如今查询过程中,那么我们至少可以消除这个bug大概给软件带来脏数据的恐惊。</p>
<p>后来,Greg Young在此基础上提出了CQRS(Command Query Resposibility Segregation,下令查询职责分离),将CQS的概念从方法层面提拔到了模子层面,即“下令”和“查询”分别使用差别的对象模子来表示。</p>
<p>接纳CQRS的驱动力除了从CQS那边继承来的好处之外,还旨在管理软件中日益复杂的查扣题目,比如偶然我们渴望从差别的维度查询数据,或者需要将各种数据进行组合后返回给调用方。此时,将查询逻辑与业务逻辑糅合在一起会使软件迅速腐化,诸如逻辑混乱、可读性变差以及可扩展性降低等等一些列题目。</p>
<h1 id="一个例子">一个例子</h1>
<p>设想电商体系中的订单(Order)对象,一开始其对应的OrderRepository类可以简单到只包罗2个方法:</p>
<code>public interface OrderRepository {
    void save(Order order);
    Order byId(String id);
}</code>
<p>在项目标演进中,你大概需要依次实现以下需求:</p>
<ol>
<li>查询某个Order详情,详情中不消包罗Order的某些字段;</li>
<li>查询Order列表,列表中所展示的数据比Order详情更少;</li>
<li>根据时间、种别和金额等多种筛选条件查询Order列表;</li>
<li>展示Order中的产品(Product)概要信息,而Product属于另一个业务实体;</li>
<li>展示Order下单人的昵称,下单人信息属于另一个单独的账户体系,用户修改昵称之后,Order下单人昵称也需要相应更新;</li>
<li>......</li>
</ol>
<p>当这些需求实现完后,你大概会发现OrderRepository和范畴模子已经被各种“查询”功能淹没了。什么?OrderRepository不是给范畴模子提供Order聚合根对象的吗,为什么却充斥着如此多的查询逻辑?</p>
<p>CQRS通过单独的读模子管理上述题目,其大抵的架构图如下:</p>
<p><div align="center"></div></p>
<p>对于Command侧,紧张的讲究是将业务用例建模成对应的Command对象,然后在对Command的处理流程中应用焦点的业务逻辑,此中最紧张的是范畴模子的建模,关于此的内容请参考笔者的《范畴驱动筹划(DDD)编码实践》文章,本文偏重先容Query侧的编码实践。</p>
<p>在本文中,查询模子(Query Model)也被表达为读模子(Read Model);下令模子(Command Model)也被表达为写模子(Write Model)。</p>
<h1 id="cqrs实现模式概览">CQRS实现模式概览</h1>
<h4 id="常见误解">常见误解</h4>
<p>在网上搜索一番,你会发现很多关于CQRS的文章都将CQRS与Event Sourcing(事故溯源)结合起来使用,这轻易让人以为接纳CQRS就肯定需要同时使用Event Sourcing,毕竟上这是一种误解。CQRS究其本意只是要求“读写模子的分离”,并未要求使用Event Sourcing;再者,Event Sourcing会极大地增加软件的复杂度,而本文寻求的是“简单可用的CQRS”,因此本文将不会涉及Event Sourcing相关内容。更多内容,请参考简化版CQRS的文章。</p>
<p>别的需要指出的是,读写模子的分离并不肯定意味着数据存储的分离,不外在现实应用中,数据存储分离是一种常见的CQRS实践模式,在这种模式中,写模子的数据会同步到读模子数据存储中,同步过程通常通过消息机制完成,在DDD场景下,消息通常承载的是范畴事故(Domain Event)。</p>
<h4 id="查询模子的数据来源">查询模子的数据来源</h4>
<p>无论是单体还是微服务,所读数据的唯一正确来源(Single Source of Truth)终极都来自于业务实体(Entity)对象(比如DDD中的聚合根),基于此,所读数据的来源形式大抵分为以下几种:</p>
<ul>
<li>所读数据来源于同一个历程空间的单个实体(后文简称“<strong>单历程单实体</strong>”),这里的历程空间指某个单体应用或者单个微服务;</li>
<li>所读数据来源于同一个历程空间中的多个实体(后文简称“<strong>单历程跨实体</strong>”);</li>
<li>所读数据来源于差别历程空间中的多个实体(后文简称“<strong>跨历程跨实体</strong>”)。</li>
</ul>
<p><div align="center"></div></p>
<h4 id="读写模子的分离形式">读写模子的分离形式</h4>
<p>CQRS中的读写分离存在2个条理,一层是代码中的模子是否需要分离,另一层是数据存储是否需要分离,总结下来有以下几种:</p>
<ul>
<li><strong>共享存储/共享模子</strong>:读写模子共享数据存储(即同一个数据库),同时也共享代码模子,数查询据通过模子转换(Projection)后返回给调用方,毕竟上这不能算CQRS,但是对于很多中小型项目而言已经足够;</li>
<li><strong>共享存储/分离模子</strong>:共享数据存储,代码中分别创建写模子和读模子,读模子通过最恰当于查询的方式进行建模;</li>
<li><strong>分离存储/分离模子</strong>:数据存储和代码模子都是分离的,这种方式通常用于需要聚合查询多个子体系的情况,比如微服务体系。</li>
</ul>
<p><div align="center"></div></p>
<p>将以上“查询模子的数据来源”与“读写模子的分离形式”相组合,我们可以得到以下差别的CQRS模式及着实用范围:</p>
<table>
<thead>
<tr >
<th>数据来源形式</th>
<th>模子分离形式</th>
<th>实用范围</th>
</tr>
</thead>
<tbody>
<tr >
<td>单历程单实体</td>
<td>共享存储/共享模子</td>
<td>着实算不上CQRS,但对于很多中小型项目已经足够</td>
</tr>
<tr >
<td>单历程单实体</td>
<td>共享存储/分离模子</td>
<td>实用于单实体查询比力复杂或者对查询服从要求较高的场景</td>
</tr>
<tr >
<td>单历程单实体</td>
<td>差别存储/分离模子</td>
<td>实用于对单个实体的查询非常复杂的场景</td>
</tr>
<tr >
<td>单历程跨实体</td>
<td>共享存储/共享模子</td>
<td>不实用</td>
</tr>
<tr >
<td>单历程跨实体</td>
<td>共享存储/分离模子</td>
<td>实用于查询比力复杂的场景,比如需要做多表join操作</td>
</tr>
<tr >
<td>单历程跨实体</td>
<td>分离存储/分离模子</td>
<td>实用于复杂查询或者对查询服从要求较高的情况</td>
</tr>
<tr >
<td>跨历程跨实体</td>
<td>共享存储/共享模子</td>
<td>不实用</td>
</tr>
<tr >
<td>跨历程跨实体</td>
<td>共享存储/分离模子</td>
<td>不实用</td>
</tr>
<tr >
<td>跨历程跨实体</td>
<td>分离存储/分离模子</td>
<td>紧张用于微服务中需要对多个服务进行聚合查询的场景</td>
</tr>
</tbody>
</table>
<p>总结下来,有以下几种常见做法:</p>
<ul>
<li>单历程单实体 + 共享存储/共享模子</li>
<li>单历程单实体 + 共享存储/分离模子</li>
<li>单历程跨实体 + 共享存储/分离模子</li>
<li>单历程跨实体 + 分离存储/分离模子</li>
<li>跨历程跨实体 + 分离存储/分离模子</li>
</ul>
<p>接下来,针对以上几种常见做法,本文将依次给出编码示例。</p>
<h1 id="cqrs编码实践">CQRS编码实践</h1>
<p>本文的示例是一个简单的电商体系,此中包罗以下微服务:</p>
<table>
<thead>
<tr >
<th>服务</th>
<th>用途</th>
<th>所含实体</th>
<th>Git地址</th>
</tr>
</thead>
<tbody>
<tr >
<td>订单服务</td>
<td>用于用户下单</td>
<td>Order</td>
<td>ecommerce-order-service</td>
</tr>
<tr >
<td>订单查询服务</td>
<td>用于订单的CQRS查询操作</td>
<td>无</td>
<td>ecommerce-order-query-service</td>
</tr>
<tr >
<td>产品服务</td>
<td>用于管理/展示产品信息</td>
<td>Product<br>Category(产品目录)</td>
<td>ecommerce-product-service</td>
</tr>
<tr >
<td>库存服务</td>
<td>用于管理产品对应的库存</td>
<td>Inventory</td>
<td>ecommerce-inventory-service</td>
</tr>
</tbody>
</table>
<p>示例代码请参考:</p>
<blockquote>
<p>https://github.com/e-commerce-sample</p>
</blockquote>
<p>请注意,本文的示例电商项目只是一个假造出来的简单项目,仅仅用于演示CQRS的各种编码模式,并不具备现实参考价值。</p>
<p>针对以上各种CQRS模式组合,本文将使用电商体系中的以下业务用例进行演示:</p>
<table>
<thead>
<tr >
<th>CQRS模式</th>
<th>业务查询用例</th>
<th>所属服务</th>
</tr>
</thead>
<tbody>
<tr >
<td>单历程单实体 + 共享存储/共享模子</td>
<td>Inventory详情查询</td>
<td>库存服务</td>
</tr>
<tr >
<td>单历程单实体 + 共享存储/分离模子</td>
<td>Product择要查询</td>
<td>产品服务</td>
</tr>
<tr >
<td>单历程跨实体 + 共享存储/分离模子</td>
<td>Product详情查询(包罗Category信息)</td>
<td>产品服务</td>
</tr>
<tr >
<td>单历程跨实体 + 分离存储/分离模子</td>
<td>Product详情查询(包罗Category信息)</td>
<td>产品服务</td>
</tr>
<tr >
<td>跨历程跨实体 + 分离存储/分离模子</td>
<td>Order详情查询(包罗Product信息)</td>
<td>订单查询服务</td>
</tr>
</tbody>
</table>
<h4 id="单历程单实体-共享存储共享模子">1. <strong>单历程单实体</strong> + <strong>共享存储/共享模子</strong></h4>
<p>对于简单的单体或者微服务应用,这种方式是最自然最直接的方式,毕竟上我们并不需要太多筹划上的思考便能想到这种方式。在这种方式中,存在单个范畴实体模子同时用于读写操作,在向调用方返回查询数据时,需要针对性地对范畴模子进行转换,转换的目标在于:</p>
<ul>
<li>调用方所需的数据模子与范畴模子大概不同等;</li>
<li>有些敏感信息是不能返回给调用方的,需要屏蔽;</li>
<li>从筹划上讲,范畴模子不能直接返回给调用方,否则会产生范畴模子的走漏</li>
<li>将范畴模子直接返回给调用方会在范畴模子与对外接口间产生强耦合,倒霉于范畴模子自身的演进。</li>
</ul>
<p><div align="center"></div></p>
<p>这里,我们以“库存(Inventory)详情查询”为例进行演示,<code>Inventory</code>范畴模子定义如下:</p>
<code>public class Inventory{
    private String id;
    private String productId;
    private String productName;
    private int remains;
    private Instant createdAt;
}</code>
<p>在获取Inventory详情时,我们并不需要返回范畴模子中的<code>productId</code>和<code>createdAt</code>字段,于是在<code>Inventory</code>中创建相应的转换方法如下:</p>
<code>    public InventoryRepresentation toRepresentation() {
      return new InventoryRepresentation(this.id,
                this.productName,
                this.remains);
    }</code>
<p>这里的<code>InventoryRepresentation</code>即表示读模子,后缀<code>Representation</code>取自REST中的“R”,表示读模子是一种数据显现,下文将相沿这种定名形式。在<code>InventoryApplicationService</code>服务中返回<code>InventoryRepresentation</code>:</p>
<code>    public InventoryRepresentation byId(String inventoryId) {
      return repository
                .byId(inventoryId)
                .toRepresentation();
    }</code>
<p>值得一提的是,在查询Inventory时,我们使用了应用服务(ApplicationService)-<code>InventoryApplicationService</code>,此时的<code>InventoryApplicationService</code>同时负担了读操作和写操作的业务入口,在实践中也可以将此二者分脱离来,即让<code>InventoryApplicationService</code>只负责写操作,而另行创建<code>InventoryRepresentationService</code>专门用于读操作。</p>
<p>别的,抛开CQRS,为了包管每一个聚合根实体自身的完备性,即便在没有调用方查询的情况下,笔者也发起为每一个聚合根提供一个<code>Representation</code> 并对外袒露查询接口。因此每一个聚合根中都会有一个<code>toRepresentation()</code>方法,该方法仅仅返回当前聚合根的状态,而不会关联其他实体对象(比如下文提到的“单历程跨实体”)。</p>
<h4 id="单历程单实体-共享存储分离模子">2. 单历程单实体 + 共享存储/分离模子</h4>
<p>偶然,即便是对于单个实体,其查询也会变得复杂,为了维护读写过程相互的清晰性,我们可以对读模子和写模子分别建模,毕竟上这也是CQRS的本意。</p>
<p><div align="center"></div></p>
<p>在Product服务中,需要返回Product的择要信息,并对返回列表进行分页处理,为此独立于ApplicationService创建<code>ProductRepresentationService</code>,直接从数据库读取数据构建<code>ProductSummaryRepresentation</code>。</p>
<code>    @Transactional(readOnly = true)
    public PagedResource<ProductSummaryRepresentation> listProducts(int pageIndex, int pageSize) {
      MapSqlParameterSource parameters = new MapSqlParameterSource();
      parameters.addValue("limit", pageSize);
      parameters.addValue("offset", (pageIndex - 1) * pageSize);

      List<ProductSummaryRepresentation> products = jdbcTemplate.query(SELECT_SQL, parameters,
                (rs, rowNum) -> new ProductSummaryRepresentation(rs.getString("ID"),
                        rs.getString("NAME"),
                        rs.getBigDecimal("PRICE")));

      int total = jdbcTemplate.queryForObject(COUNT_SQL, newHashMap(), Integer.class);
      return PagedResource.of(total, pageIndex, products);
    }</code>
<p>这里,我们绕过了范畴模子<code>Product</code>,也绕过了其对应的<code>ProductRepository</code>,以最快速的方式从数据库中直接获取数据。</p>
<h4 id="单历程跨实体-共享存储分离模子">3. 单历程跨实体 + 共享存储/分离模子</h4>
<p>既然单个实体都有必要使用分离模子,那么在同一个历程空间中的跨实体查询更有来由使用分离模子的形式。对于简单形式跨实体查询,还用不着使用分离的存储,只需要做一些join团结查询即可。</p>
<p><div align="center"></div></p>
<p>在Product服务中,存在<code>Product</code>和<code>Category</code>两个聚合根对象, 在查询<code>Product</code>时,我们渴望一并带上<code>Category</code>的信息,为此创建<code>ProductWithCategoryRepresentation</code>如下:</p>
<code>@Value
public class ProductWithCategoryRepresentation {
    private String id;
    private String name;
    private String categoryId;
    private String categoryName;
}</code>
<p>在<code>ProductRepresentationService</code>中,直接从数据库获取<code>Product</code>和<code>Category</code>数据,此时需要对<code>PRODUCT</code>和<code>CATEGORY</code>两张表做join操作:</p>
<code>    @Transactional(readOnly = true)
    public ProductWithCategoryRepresentation productWithCategory(String id) {
      String sql = "SELECT PRODUCT.ID, PRODUCT.NAME, CATEGORY.ID AS CATEGORY_ID, CATEGORY.NAME AS CATEGORY_NAME FROM PRODUCT JOIN CATEGORY ON PRODUCT.CATEGORY_ID=CATEGORY.ID WHERE PRODUCT.ID=:productId;";
      return jdbcTemplate.queryForObject(sql, of("productId", id),
                (rs, rowNum) -> new ProductWithCategoryRepresentation(rs.getString("ID"),
                        rs.getString("NAME"),
                        rs.getString("CATEGORY_ID"),
                        rs.getString("CATEGORY_NAME")));
    }</code>
<p>需要注意的是,假如join的级联太多,那么会大大影响查询的服从,并且使步调变得更加复杂。一般来讲,假如join次数达到了3次及其以上,发起思量接纳分离存储的形式。</p>
<h4 id="单历程跨实体-分离存储分离模子">4. 单历程跨实体 + 分离存储/分离模子</h4>
<p>依然以返回<code>ProductWithCategoryRepresentation</code>为例,假设我们以为先前的join操作太复杂或者太低效了,需要接纳专门的数据库来简化查询提拔服从。</p>
<p><div align="center"></div></p>
<p>为此创建单独的读模子数据库表<code>PRODUCT_WITH_CATEGORY</code>:</p>
<code>CREATE TABLE PRODUCT_WITH_CATEGORY
(
PRODUCT_ID    VARCHAR(32)NOT NULL,
PRODUCT_NAMEVARCHAR(100) NOT NULL,
CATEGORY_ID   VARCHAR(32)NOT NULL,
CATEGORY_NAME VARCHAR(100) NOT NULL,
PRIMARY KEY (PRODUCT_ID)
) CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
</code>
<p>读写同步通常通过范畴事故的形式完成,由于是在同一个历程空间中,因此读写同步相比于跨历程的同步来说,可以有更多的选择:</p>
<ul>
<li>使用历程内事故机制(比如Guava的EventBus),在与写操作雷同的事件中同步,这种方式的好处是可以包管写操作与同步操作的原子性进而确保读写间的数据同等性,缺点是在写操作过程中存在额外的数据库同步开销进而增加了写操作的延迟时间;</li>
<li>使用历程内事故机制,独立事件同步(比如Guava的AsyncEventBus),这种方式的好处是写操作和同步操作相互独立互不影响,缺点是无法包管二者的原子性进而大概使体系产生脏数据;</li>
<li>使用独立的消息机制(比如RabbitMQ/Kafka等),独立事件同步,可以将查询功能分离为单独的子体系,毕竟上这种方式已经与“跨历程跨实体 + 分离存储/分离模子”相似,因此请参考“5. 跨历程跨实体 + 分离存储/分离模子”末节。</li>
</ul>
<h4 id="跨历程跨实体-分离存储分离模子">5. 跨历程跨实体 + 分离存储/分离模子</h4>
<p>这种方式在微服务中最常见,由于微服务体系首先是多历程的,每个服务都内聚性地管理自身的聚合根对象,别的,微服务的数据存储通常也是独占式的,意味着在微服务体系中数据存储肯定是分离的,在这种场景下,跨微服务之间的查询通常接纳“API Compositon”模式或者本文的CQRS模式。</p>
<p>在"跨历程跨实体 + 分离存储/分离模子"中,存在一个单独的查询服务用于CQRS的读操作,查询所需数据通常通过事故机制从差别的其他业务服务中同步而来,读操作所返回的数据通过API Gateway或者BFF向外袒露,示意图如下:</p>
<p><div align="center"></div></p>
<p>在本文的示例电商项目中,需要在查询Order的时间同时带上Product的信息,但是由于Order和Product分别属于差别的服务,为此创建<code>ecommerce-order-query-service</code>查询服务,该服务负责吸收Order和Product服务发布的范畴事故以同步其自身的读模子<code>OrderWithProductRepresentation</code>。</p>
<p>在<code>ecommerce-order-query-service</code>服务中,在吸收到<code>OrderEvent</code>事故后,<code>OrderQueryRepresentationService</code>负责分别调用Order和Product的接口完成数据同步:</p>
<code> public void cqrsSync(OrderEvent event) {
      String orderUrl = "http://localhost:8080/orders/{id}";
      String productUrl = "http://localhost:8082/products/{id}";

      OrderRepresentation orderRepresentation = restTemplate.getForObject(orderUrl, OrderRepresentation.class, event.getOrderId());

      List<Product> products = orderRepresentation.getItems().stream().map(orderItem -> {
            ProductRepresentation productRepresentation = restTemplate.getForObject(productUrl,
                  ProductRepresentation.class,
                  orderItem.getProductId());

            return new Product(productRepresentation.getId(),
                  productRepresentation.getName(),
                  productRepresentation.getDescription());
      }).collect(Collectors.toList());

      OrderWithProductRepresentation order = new OrderWithProductRepresentation(
                orderRepresentation.getId(),
                orderRepresentation.getTotalPrice(),
                orderRepresentation.getStatus(),
                orderRepresentation.getCreatedAt(),
                orderRepresentation.getAddress(),
                products

      );
      dao.save(order);
      log.info("CQRS synced order {}.",orderId);
    }</code>
<p>在本例中,<code>ecommerce-order-query-service</code>查询服务使用了关系型数据库,但在现实应用中应该根据项目所需选择恰当的数据存储机制。例如,对于海量数据的查询,可以选择诸如MongoDB或者Cassandra之类的NoSQL数据库;而对于需要进行全文搜索的场景,可以接纳Elasticsearch等。</p>
<p>毕竟上,在吸收并处理事故时,存在2中风格,一种是本例中的仅将事故作为消息关照,然后调用其他服务的API接口完成同步,另一种是直接使用事故所携带的数据进行同步,更多关于这2种风格的比力,请参考笔者的《事故驱动架构(EDA)编码实践》文章。</p>
<p>事故驱动架构总是意味着异步,它将给软件带来以下方面的影响:</p>
<ul>
<li><p>读模子和写模子之间不再是强事件同等性,而是终极同等性。</p></li>
<li><p>从用户体验上讲,用户发起操作之后将不再立即返回结果数据,此时要么需要调用方(比如前端)进行轮询查询,要么需要在用户体验上[做些衡量】(http://danielwhittaker.me/2014/10/27/4-ways-handle-eventual-consistency-ui/),比如使用确认页面延迟用户对查询数据的获取。</p></li>
</ul>
<h1 id="关于representation对象的定名">关于Representation对象的定名</h1>
<p>定名总是一件令开发者头疼的事情,特别对于需要返回多种数据形式的查询接口来说。为此,笔者自己接纳以下方式定名差别的<code>Representation</code>对象,以Order为例:</p>
<ul>
<li><code>OrderRepresentation</code>:仅仅包罗聚合根实体自身状态详情,一种常见的形式是通过<code>Order.toRepresentation()</code>方法获得</li>
<li><code>OrderSummaryRepresentation</code>:用于返回聚合根的列表,仅仅包罗Order本身的状态</li>
<li><code>OrderWithProductRepresentation</code>:用于返回带有Product数据的Order详情</li>
<li><code>OrderWithProductSummaryRepresentation</code>:用于返回带有Product数据的Order列表</li>
</ul>
<p>固然,定名是一件见仁见智的事情,以上也绝非最佳方式,不外总的原则是要同等、清晰、可读。</p>
<h1 id="什么时间该接纳cqrs">什么时间该接纳CQRS</h1>
<p>毕竟上,不管是Martin Fowler、Udi Dahan还是Chris Richardson,都提醒到需要慎用CQRS,由于它会带来额外的复杂性;而另有人(比如Gabriel Schenker)却提到,当前很多软件逻辑复杂性能低下恰恰是由于没有选择CQRS造成的。</p>
<p>简直,不管在架构层面还是编码层面,接纳CQRS的都会增加步调的复杂度和代码量,不外,这种复杂性可以在很大程度上被其所带来的“条理性”所抵消,“有条理的多”恰恰是为了简单。因此,当你的项目正在承受本文一开始的“一个例子”末节中所提到的“痛楚”时,不妨试一试本文提到的几种简化版的CQRS实践。</p>
<h1 id="总结">总结</h1>
<p>本文本着“简单可用的CQRS”的目标讲到了差别的CQRS实现模式,此中包罗怎样在单体和微服务架构中进行差别的CQRS落地实践。可以看出,CQRS并不像人们想象中的那么难,通过恰当的筹划与选择,CQRS可以在很大程度大将步调架构变得更加的有条理,进而使软件项目在CQRS上的付出变成一件值得做的事情。</p><br><br/><br/><br/><br/><br/>来源:<a href="https://www.cnblogs.com/davenkin/p/cqrs-coding-practices.html" target="_blank">https://www.cnblogs.com/davenkin/p/cqrs-coding-practices.html</a>
页: [1]
查看完整版本: 后端开发实践系列之四——简单可用的CQRS编码实践