实现架构现代化

 


建设新城区

绞杀植物模式(Strangler Fig)

绞杀植物模式需要注意的是增量演进并行运行。不可一股脑的构建出新的系统或服务,然后直接替换,这样做的后果是新构建的系统或者服务往往与原系统有很大差异,甚至不可用。

优势:第一,不会遗漏原有需求;第二,可以稳定地提供价值,频繁地交付版本,更好地监控其改造进展。第三,避免“闭门造车”。

劣势:劣势主要来自迭代的风险和成本,绞杀的时间跨度会很大,存在一定风险,而且还会产生一定的迭代成本。

气泡上下文(Bubble Context)

气泡上下文中的气泡指的是用防腐层(Anticorruption Layer)隔离开一个小的限界上下文。

当你遇到一个新的需求时,可以评估这个需求,如果它适合的话,可以不在遗留系统中开发这个需求,而是将它放到气泡上下文中,在一个全新的环境内开发这个需求。由于防腐层隔离了遗留系统,因此你可以在气泡中相对自由地进行领域建模,而不必受到遗留系统的限制。

 

 就像一些新企业建厂办公(DDD 落地),老城区的空间不够或者条件不适合。那么就会建立一个新城区(气泡上下文),按照更适合这些企业需求的方式规划和布局(应用 DDD 的各种战术模式)。当新城区需要供电供水供暖(数据)时,就拉一条新的管线(基于防腐层的仓库)从老城区来获取这些资源。自治气泡

自治气泡(Autonomous Bubble)

由于新需求中必然有需要建新表的时候,如果建立再遗留系统中,只会让遗留系统更加混乱,因此引入第二种模式:自治气泡。

自治气泡模式有它自己的数据库,与遗留系统是弱耦合的。不直接访问遗留系统的数据和服务,而是通过同步防腐层(Synchronizing ACL),将遗留系统中的数据同步到自治气泡中。同步的方式可以是轻量级的每日同步脚本,也可以是消息或领域事件。

 

 这就像是新城区中建好了水电气厂,也建好了医院、学校等其他基础设施(数据库),但是工厂的员工、医院的医生、学校的老师(数据)仍然住在老城区,他们需要每天辛苦地通勤到新城区工作(同步数据)。

变动数据捕获(Change Data Capture)

变动数据捕获简称CDC,它能识别和跟踪数据库中的数据变动,并捕获这种变动,完成一系列后续处理,比如:将变动内容发布到一个事件总线中,再由气泡上下文去消费这个事件,从而同步数据;或者干脆直接连接其他数据库进行同步。

一般来说,有两种捕获变动数据的方法。

1.数据库触发器

优势是使用简单。

劣势是大量触发器,很难管理。基于大量数据库触发器的系统,认知负载非常高。

因此使用数据库触发器要慎重。

2.轮询数据库的事务日志(Event Interception)

由于日志本身包含了数据的变动,你根本不需要去解析。工具本身也运行在单独的进程中,也不用担心和数据库产生耦合和竞争。轮询事务日志的做法,可以称得上是最整洁的 CDC 方案。

事件拦截

如果遗留系统是事件驱动(Event-Driven Architecture)的架构,可以使用事件拦截模式拦截系统中已有的事件,为它们编写新的监听程序来进行数据的同步或开始连带的业务处理。不要的时候可以在遗留系统中补充一些缺失的事件。

遗留系统封装API

如果你的一六兄台那个是一个web系统,看最简洁的方式是将遗留系统封装为若干个 API,对外提供业务能力,供各个气泡上下文访问。

 

 封装API是,要新建API,不要复用老的API。一方面老API是为特定的页面编写的,很难被其他气泡服用。另一方面老页面与气泡的需求变化方向和速率也是不同的,很可能出现为了满足老页面的需求变化而改了API,结果气泡上下文中的功能被破坏了。

 但你仍然需要在气泡上下文中提供一个防腐层,只不过这个防腐层不再直连遗留系统的数据库,而是去访问遗留系统封装的 API。

小结

微服务

基于组件的单体架构

把业务模块之间的耦合解开,消除掉模块间的相互依赖关系。同时,也要将数据的所有权分开,让不同的模块拥有不同的数据。

但达到这样的理想情况实际很难。因为一个模块想不依赖另一个模块的数据,这不太可能。但我们可以使一个模块提供一个外部模块可以访问的接口,通过防腐层去调用这个接口,这样就不在直接依赖外部模块的数据表了。

通过防腐层对不同模块进行隔离,一个模块中模型的修改,不会影响到另一个模块。

基于服务的分布式架构

按业务领域给这些模块分组,将它们拆分出来,形成服务。这种架构叫做基于服务的分布式架构。基于服务的分布式架构既可以作为一个过渡架构,也可以作为目标架构。

微服务架构

如果基于服务的分布式架构仍然无法满足需求,比如同一服务中,不同模块之间弹性需求的差异越来越大,那我们就不得不对模块继续拆分。

在微服务架构下,业务边界变得十分清晰,每个服务可以独立部署和演进,并且可以选择不同的技术栈。

遗留系统的演进方案

 

 

 判断系统是否适合进行模块化的驱动因素:

 

 

如果系统适合模块化,下一步还要判断代码库是否可拆分,也就是是否有可能把一个大泥球代码库拆分成多个小的代码库。三种特征指标分别是:传入传出耦合(Afferent and Efferent Coupling)、抽象性和不稳定性,以及和主序列的距离。

如果代码库可拆分,下一步就是判断系统的各个模块之间是否具有清晰的组件边界。如果有,就可以选择基于组件的分解(Compnent-based Decomposition)模式,否则可以使用战术分叉(Tactical Forking)模式。

基于组件的分解

1. 识别和调整组件大小:统计各个模块的代码语句数,拆分那些过于庞大的组件,使所有组件的语句数趋于一致。

2. 收集公共领域组件:在基于组件的单体架构中,很多组件的功能是类似的,比如邮件通知和短信通知,或者订单通知模块和物流通知模块。识别这些模块并进行合并,有助于消除重复。

3. 展平组件:让组件中的类都位于叶子节点的包中,不要出现孤儿类(即类和其他包平级)。

4. 明确组件依赖:分析组件之间的依赖关系。

5. 构建领域组件:在逻辑上将属于同一领域的组件组合在一起。

6. 创建领域服务:当组件的大小适中、结构扁平,并且按领域分组后,就可以在此基础上拆分出领域服务,构建基于服务的分布式架构了。

战术分叉

当我们考虑从一个大的整体中,把一个小的部分挪出去的时候,方法都是“拆”。但当“拆”不动的时候,你可以变换一下思路,用“删”的方式来实现拆分。这种模式,就叫做战术分叉。

先把系统整体复制一份,然后在复制出来的系统中删掉不需要的代码,保留下来的就是我们希望拆分出来的部分了。

 

改造老城区前端

1.梳理业务

2.模块化

按职责把原先冗长的 JSP 页面拆分出来,分解成多个小的 JSP 页面,比如 header、footer、content 等,并将它们 include 到大页面中。

例如下面的这段代码,可以很明显地看出它由 4 个部分组成:一个包含若干 hidden 字段的 form、一个包含一段文字的 header、一个包含 to-do 列表的 section 和一个包含删除按钮的 footer:

复制代码
<% List<Todo> todoList = (List<Todo>) request.getAttribute("todoList"); %>
<section class="todoapp">
    <form name="todoForm" action="" method="post">
        <input type="hidden" name="sAction"/>
        <input type="hidden" name="title"/>
        <input type="hidden" name="id"/>
    </form>
    <header class="header">
        <h1>todos</h1>
        <input class="new-todo" placeholder="What needs to be done?" autofocus>
    </header>
    <section class="main">
        <input id="toggle-all" class="toggle-all" type="checkbox">
        <label for="toggle-all">Mark all as complete</label>
        <ul class="todo-list">
            <% for (int i = 0; i < todoList.size(); i++) {
                Todo todo = todoList.get(i);
            %>
            <li <%if (todo.getCompleted()) {%> class="completed"<%}%> data-id="<%=todo.getId()%>">
                <div class="view" id="todo_<%=todo.getId()%>">
                    <input class="toggle" id="todo_toggle_<%=todo.getId()%>" onchange="toogle(this)"
                           type="checkbox" <%if (todo.getCompleted()) {%> checked <%}%> />
                    <label><%=todo.getTitle()%>
                    </label>
                    <button class="destroy" onclick="deleteTodo(this)"></button>
                </div>
                <%--            <input class="edit" value="<%=todo.getTitle()%>">--%>
            </li>
            <%}%>
        </ul>
    </section>
    <footer class="footer">
        <%
            boolean hasCompleted = false;
            for (Todo todo : todoList) {
                if (todo.getCompleted()) {
                    hasCompleted = true;
                    break;
                }
            }
        %>
        <%if(hasCompleted) {%>
           <button class="clear-completed" onclick="deleteCompletedTodo()">Clear completed</button>
        <%}%>
    </footer>
</section>
复制代码

对于这段代码,我们就可以将它提取成 4 个小的 JSP 页面,重构之后的代码就相当清爽了。

<section class="todoapp">
    <%@ include file="todoForm.jspf" %>
    <jsp:include page="todoHeader.jsp"/>
    <jsp:include page="todoList.jsp"/>
    <jsp:include page="todoFooter.jsp" />
</section>

3.重构JSP中的JavaScript代码

长函数是遗留系统前端最常见的坏味道。需要按职责来将它分解为若干个小的函数,每个函数只做一件事情。

以如下代码为例,大体业务是,某家公司在组织团建时需要选择一个团建活动,而不同的团建活动之间有一些校验逻辑

复制代码
function clickActivityCheck(activityCheckedObject, packageId, activityId) {
    if (activityCheckedObject.checked) {
        var countInput = document.getElementById("activity_" + activityId + "_count");
        var count = !countInput.value ? -1 : parseInt(countInput.value);
        if (count < 1 || count > 50) {
            alert("参加人数必须在1到50之间!");
            activityCheckedObject.checked = false;
            return;
        }
        if (activityId === 1) {
            var checkBox2 = document.getElementById("activity_2");
            if (checkBox2 && checkBox2.checked) {
                alert("冬奥两日游和户外探险一日游不能同时选择!");
                activityCheckedObject.checked = false;
                return;
            }
        }
        if (activityId === 2) {
            var checkBox1 = document.getElementById("activity_1");
            if (checkBox1 && checkBox1.checked) {
                alert("户外探险一日游和冬奥两日游不能同时选择!");
                activityCheckedObject.checked = false;
                return;
            }
        }
        if (activityId === 5) {
            var checkBox1 = document.getElementById("activity_1");
            if (!checkBox1 || !checkBox1.checked) {
                alert("选择住宿前必须选择冬奥两日游!");
                activityCheckedObject.checked = false;
                return;
            }
        }
        var result = createActivity(activityCheckedObject, packageId, activityId);
        if (!result.success) {
            alert(result.errorMessage);
            activityCheckedObject.checked = false;
            return;
        }
    } else {
        var countInput = document.getElementById("activity_" + activityId + "_count");
        countInput.value = "";
        activityCheckedObject.checked = false;
        if (activityId === 1) {
            var checkBox5 = document.getElementById("activity_5");
            if (checkBox5 && checkBox5.checked) {
                checkBox5.click();
            }
        }
        cancelActivity(activityCheckedObject, packageId, activityId);
    }
复制代码

这段代码的主要逻辑是在选择团建活动的时候,用户可以在“冬奥两日游”、“户外探险一日游”、“唱歌”、“吃饭”、“住宿”等活动中做出选择。在勾选 checkbox 的时候,会触发这个函数来进行校验。它首先会校验所填的人数,然后校验所选活动之间的关系,比如冬奥和户外探险不能同时选择,选住宿则必须选冬奥。此外,当取消勾选的时候,也会触发一个联动逻辑,也就是取消冬奥的时候,会连带着一起取消住宿。

所有的校验逻辑大体上可以分为3中:校验人数,校验互斥的活动,校验有依赖的活动。对校验逻辑做了抽象之后,就可以把代码重构为一下的形式:

复制代码
function validateActivities(activityId) {
    var result = validateCount(activityId);
    if (result.success) {
        result = validateMutexActivities(activityId);
        if (result.success) {
            result = validateReliedActivities(activityId);
        }
    }
    return result;
}

function selectActivity(activityId) {
    var result = validateActivities(activityId);
    if (result.success) {
        result = createActivity(activityId);
    }
    return result;
}

function clickActivityCheck(activityId) {
    let activityInfoRow = new ActivityInfoRow(activityId);
    if (activityInfoRow.isChecked()) {
        var result = selectActivity(activityId);
        if (!result.success) {
            alert(result.errorMessage);
            activityInfoRow.setChecked(false);
        }
    } else {
        unSelectActivity(activityInfoRow, activityId);
    }
}
复制代码

这里面还提取了一个 ActivityInRow 对象,用于保存每一行的活动元素。这样,我们就把页面元素和判断逻辑分离出来了。

4.移除JSP中的Java代码

JSP 中,用 <% %> 括起来的 Java 代码叫做 Scriptlet,正是这样的代码,把 JSP 变成了 Smart UI。

JSP 中的 Scriptlet 大致可以分为这么几类:

1. 对所有请求执行相同的 Java 代码,如权限验证。这类 Scriptlet 可以迁移到后端,写到一个 Filter 里。

2. 直接与数据库交互的 Java 代码,如从数据库中查询出数据并显示在 table 中,或登录页面中验证用户名和密码等。这类 Java 代码其实处理的都是 GET/POST 请求,也可以迁移到后端,实现一个新的 Servlet,将代码迁移到 doGet/doPost 中。

3. 控制页面显示逻辑的 Java 代码,如上面 to-do 的例子中,从后端拿到一个 Todo 对象的列表,然后遍历这个列表,用<li>便签展示出来。

对于第三种Java代码,可以用JSTL和EL来替换,如下:

<section class="main">
    <ul>
        <c:forEach var="todoItem" items="${todoList}">
            <li>${todoItem.title}</li>
        </c:forEach>
    </ul>
</section>

5.引入前段框架

引入Vue框架,以todoList为例:

复制代码
<%
    List<Todo> todoList = (List<Todo>) request.getAttribute("todoList");
    ObjectMapper objectMapper = new ObjectMapper();
    String todoListString = objectMapper.writeValueAsString(todoList);
%>
<div id="todoListContainer"></div>
<script>
    (function () {
        var todos = JSON.parse('<%=todoListString%>');
        new Vue({
            el: "#todoListContainer",
            data: function () {
                return {
                    todos
                }
            },
            template:`
<section class="main" v-show="todos.length">
    <ul class="todo-list">
        <li v-for="todo in todos" :key="todo.id" :class="{completed: todo.completed}">
            <div class="view">
                <input class="toggle" v-model="todo.completed" type="checkbox" @change="toggleComplted(todo)"/>
                <label>{{todo.title}}</label>
                <button class="destroy" @click="deleteTodo(todo)"></button>
            </div>
        </li>
    </ul>
</section>
            `,
            methods: {
                toggleComplted: function (todo) {
                    var sAction = "markDone";
                    if (!todo.completed) {
                        sAction = "markUnfinished";
                    }
                    rootPage.toggleTodo(todo.id, sAction);
                },
                deleteTodo: function (todo) {
                    rootPage.deleteTodo(todo.id);
                }
            }
        });
    })();
</script>
复制代码

6.前段组件化

引入前段框架之后,进一步重构,将前面拆分出来的各个模块转换为组件。比如上面的Vue可以转换为下面这样的组件:

复制代码
var todoListComponent = {
    props:{
        todos: {
            type: Array
        }
    },
    template:`
<section class="main" v-show="todos.length">
    <ul class="todo-list">
        <li v-for="todo in todos" :key="todo.id" :class="{completed: todo.completed}">
            <div class="view">
                <input class="toggle" v-model="todo.completed" type="checkbox" @change="toggleComplted(todo)"/>
                <label>{{todo.title}}</label>
                <button class="destroy" @click="deleteTodo(todo)"></button>
            </div>
        </li>
    </ul>
</section>
            `,
    methods: {
        toggleComplted: function (todo) {
            var sAction = "markDone";
            if (!todo.completed) {
                sAction = "markUnfinished";
            }
            this.$emit("toggle-todo", todo.id, sAction);
        },
        deleteTodo: function (todo) {
            this.$emit("delete-todo", todo.id);
        }
    }
}
复制代码

再index.jsp中,就可以使用这种方式来引用这个组件:

<div id="app">
    <todo-list-component :todos="todos" v-on:toggle-todo="toggleCompleted" v-on:delete-todo="deleteTodo" >
    </todo-list-component>
</div>

初始化数据还是从 request 中获取的,要把它们替换成对后端的 Ajax 调用。这需要你改造一下原有的 Servlet,让原本在 request 中设置 attribute 的 Servlet 返回 json:

ObjectMapper objectMapper = new ObjectMapper();
PrintWriter out = response.getWriter();
response.setContentType("application/json;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
response.setStatus(HttpServletResponse.SC_OK);
List<Todo> todoList = todoRepository.getTodoList();
out.write(objectMapper.writeValueAsString(todoList));
out.flush();

这时,前端页面中的所有 Scriptlet 都清除干净了,你可以将文件名的后缀从 jsp 改为 html 了。

7.前段工程化

引入Vue CLI,它能更好的管理Vue组件。比如之前的todoList.js,将会变成下面这样的TodoList.vue:

复制代码
<template>
  <section class="main" v-show="todos.length">
    <ul class="todo-list">
      <li v-for="todo in todos" :key="todo.id" :class="{completed: todo.completed}">
        <div class="view">
          <input class="toggle" v-model="todo.completed" type="checkbox" @change="toggleComplted(todo)"/>
          <label>{{todo.title}}</label>
          <button class="destroy" @click="deleteTodo(todo)"></button>
        </div>
      </li>
    </ul>
  </section>
</template>
<script>
import $ from 'jquery';
export default {
  name: "TodoList",
  props:{
    todos: {
      type: Array
    }
  },
  methods: {
    toggleComplted: function (todo) {
      console.log("toggleComplted ")
      console.log(todo)
      var sAction = "markDone";
      if (!todo.completed) {
        sAction = "markUnfinished";
      }
      $.ajax({
        url: "/todo-list/ajax?sAction=" + sAction,
        method: 'post',
        data: {
          id: todo.id
        },
        error: function () {
          todo.completed = !todo.completed;
        }
      })
    },
    deleteTodo: function (todo) {
      var _this = this;
      $.ajax({
        url: "/todo-list/ajax?sAction=delete",
        method: 'post',
        data: {
          id: todo.id
        },
        success: function () {
          _this.$emit('delete-todo', todo.id);
        }
      })
    }
  }
}
</script>
复制代码

8. API治理

完成了前面的七个步骤,前端的工作基本上告一段落,接下来要治理的就是后端的 API 了。在第七步,我们已经将部分 Servlet 改造成了对 Ajax 调用友好的 API,但这还不够,你可以更进一步,将它们改写为 REST API,并且对后端进行分层,以消除事务脚本。

小结 

改造老城区后端

修缮者模式

绞杀植物模式适用于新的系统和服务,替换旧的系统或旧系统中的一个模块。在旧系统内部,也可以使用类似的思想来替换一个模块,只不过这个模块仍然位于旧系统中,而不是外部。

我们把这种方式叫做修缮者模式。

 

 在修缮时,我们通过开关隔离旧系统待修缮的部分,并采用新的方式修改。在修缮的过程中,模块仍然能通过开关对外提供完整功能。

 即是将要修缮的模块复制出来一份,以保证原有功能正常运行。等原模块修缮好之后,通过开关决定调用新老模块,从而实现安全的重构。

复制代码
// 旧方法
public List<int[]> getThem() {
 List<int[]> list1 = new ArrayList<int[]>();
 for (int[] x : theList)
   if (x[0] == 4)
    list1.add(x);
 return list1;
}
// 新方法
public List<Cell> getFlaggedCells()  {
  return gameBoard.stream().filter(c -> c.isFlagged()).collect(toList());
}
// 调用端
List<int[]> cells;
List<Cell> cellsRefactored;
if (toggleOff) {
  cells = getThem();
  // 其他代码
}
else {
  cellsRefactored = getFlaggedCells();
  // 其他代码
}
复制代码

抽象分支

把要重构的方法重构成一个方法对象,然后提取出一个接口,待重构的方法是接口的一个实现,重构后的方法是另一个实现。按这种方式重构之后的代码如下所示:

复制代码
public interface CellsProvider {
  List<int[]> getCells();
}

public class OldCellsProvider implements CellsProvider {
  @Override
  public List<int[]> getCells() {
    List<int[]> list1 = new ArrayList<int[]>();
    for (int[] x : theList)
      if (x[0] == 4)
        list1.add(x);
    return list1;
  }
}
public class NewCellsProvider implements CellsProvider {
  @Override
  public List<int[]> getCells() {
    return gameBoard.stream().filter(c -> c.isFlagged()).map(c -> c.getArray()).collect(toList());
  }
}
复制代码

在调用端,你只需要通过工厂模式,来根据开关得到 CellIndexesProvider 的不同实现,其余的代码都保持不变。在通过 A/B 测试之后,再删除旧的实现和开关。这种方法不但可以进行安全地重构,还可以用新的实现替换旧的实现,完成功能或技术的升级。我们把这种模式叫做抽象分支(Branch by Absctration)

扩张与收缩

有的时候我们要修改的是接口本身(这里的接口是指方法的参数和返回值),这时候就不太容易通过抽象分支去替换了。我们还是拿前面第六节课用过的例子做讲解,以前返回的是 List<int[]>,而现在我们想打破这个接口,返回 List。因为 List<int[]> 仍然存在严重的基本类型偏执的坏味道,而且本来已经提取了 Cell 类,又通过 getArray 返回数组,简直是多此一举。

这时你可以使用扩张 - 收缩(expand-contract)模式,也叫并行修改(Parallel Change)模式。它一般包含三个步骤,即扩张、迁移和收缩。这里的扩张是指建立新的接口,它相比原来旧的代码新增了一些东西,因此叫做“扩张”;而收缩是指删除旧的接口,它比之前减少了一些东西,因此叫“收缩”。

一般来说,它会在类的内部新建一些方法,以提供新的接口(即扩张),然后再逐步让调用端使用新的接口(即迁移),当所有调用端都使用新的接口后,就删除旧的接口(即收缩)。

复制代码
public class CellsProvider {
  public List<int[]> getCells() {
    List<int[]> list1 = new ArrayList<int[]>();
    for (int[] x : theList)
      if (x[0] == 4)
        list1.add(x);
    return list1;
  }
}
复制代码

你可以在这个方法对象中进行扩张,新增一个方法,以提供不同的接口:

public class CellsProvider {
  public List<int[]> getCells() {
    // 旧方法
  }
  public List<Cell> getFlaggedCells() {
    return theList.stream().filter(c -> c.isFlagged()).collect(toList());
  }
}

然后,我们让调用端都调用这个新的 getFlaggedCells 方法,而不是旧的 getCells 方法。在替换的过程中,新老方法是同时存在的,这也是为什么这个模式也叫并行修改。等所有调用端都修改完毕,就可以删掉旧方法了。

 

 小结

 无论是绞杀植物、修缮者、抽象分支还是扩张收缩,它们在实施的过程中,都允许新旧实现并存,这种思想叫做并行运行(Parallel Run)。这是我们贯彻增量演进原则的基本思想。

旧的不变,新的创建,一步切换,旧的,再见。

拆分数据

共享数据库

如果系统不需要不停机更新,没有严苛的可用性和弹性需求,或者数据量没有大到无法接受的成都,就没必要拆分数据库。这时,共享数据库(Shared Database)也是一个可以接受的选择。

共享的数据分成两种情况。第一种是不同的服务访问同一数据库的不同 Schema,第二种是不同的服务访问同一数据库的同一 Schema。

数据库视图

如果时外部系统需要连接你的数据库来读写它所需要的数据,就绝对要避免共享数据库。因为这种情况下,数据的所有权将不再仅属于当前系统,不同的团队都能随意修改数据,很快就会变得混乱不堪,不同系统间的集成也会成为大问题。

这是应该为不同的外部系统创建不同的Schema,在Schema中提供数据库视图。这样一来,外部系统就能以只读的方式访问你的数据了。

由于视图提供的是全部数据的一个有限的子集,外部系统只能访问你想让它访问的数据,比如部分表以及表中的部分字段,其他数据得以隐藏。这样就能最大程度地避免数据所有权的模糊。

 

 

 数据库包装服务

数据库视图只能提供只读数据,如果外部系统希望写入数据,可以对数据库进行一层薄薄的封装,形成一个服务,将数据库的细节隐藏在这个数据库包装服务之后,将数据库依赖转换成服务依赖。通过在数据库外放置一个明确的包装层,你可以很清楚地知道哪些数据是属于你的,哪些数据是别人的。

 

 

 报表数据库

如果外部系统或者旗袍上下文时一个报表系统或服务,需要读取大量的数据,需要使用报表数据库(Reporting Database)模式,也叫做数据库即服务接口(Database-as-a-Service Interface),它会为报表这类只读的服务单独构建一个数据库。

这个数据库可以时业务数据库的远程副本,也可以是一个完全不同的、更适合报表的数据结构(如大宽表),并通过某种方式来做数据的转换和映射。对于后一种实现,你可以使用与业务数据库完全异构的数据库,这样更加灵活。但它也带来了一定的开销,就是你需要自己去实现一个数据映射的工具。

 

 

 变更数据所有权

使用战术分叉的方式将“拆”变为“删”。将整个 Schema 复制一份,在新的 Schema 中删除相应的领域服务没有访问到的表,剩下的就是与领域服务有关的所有表了。

 

 

接下来,我们再对访问的表做个分组处理。需要依据的原则是,谁写数据谁就拥有这张表。因此,我们可以把执行写操作的表,当作是真正归属于当前领域服务的,保持不动即可;而只读的表应该归其他领域服务,所以我们可以把这些表调整成视图。

如果目标是拆分 Schema,到这一步就差不多结束了。但如果目标是独立的数据库,你还要在独立的数据库中将这些视图转换为表,将原数据库中的数据冗余到新库中,并通过 CDC 和事件拦截等方式同步数据。

如果不想冗余数据,你还可以将连表查询转换为 API 调用。具体来说就是,拆分新库和老库中不同表的连表查询,提取出新库的查询,在单体或其他领域服务中把老库的表封装成 API。然后在独立出来的领域服务中,把新库的数据和调用 API 得到的数据组装起来。

在封装老库的 API 时,你可以使用数据库包装服务模式,也可以使用更加开放的聚合 API 模式。后者不像前者那样只提供基础的 CRUD 服务,而是将一个聚合的所有操作都暴露为 API。

比如在订单服务中,下完一个订单后,会连带着对库存表进行操作。用数据库包装服务的话,库存服务就会封装一个修改库存表的 API,而聚合 API 则会提供一个减库存的 API。两者乍看上去似乎差别不大,但其实体现出了完全不同的封装策略。

这种将混杂在一起的数据拆分出来,各自归属各自服务的过程,叫做变更数据所有权(Change Data Ownership)

在应用中同步数据

在拆分数据的时候,你的出发点可能并不是解耦,而是想换一个更加合适的数据库,来解决特定的问题。比如社交领域中的好友关系,你可能想用图数据库来替换关系型数据库,来得到更好的查询性能。

那么当数据库拆分出来之后,如何切换到异构数据库呢?我这里教给你一个比较稳妥的办法——在应用中同步数据。让我们来看看它的增量演进方案,一共分四步。

第一步,批量地复制数据。如果老库在业务上是允许停机的,可以直接停机导数据。如果不允许停机,在复制数据的过程中会产生新的数据。这就需要通过 CDC 等方式来保证这部分变动也能同步到新库中。

第二步,同时写入新旧两个库,但只从旧库中读数据。由于新库刚刚部署不久,很可能会出问题,所以我们要在应用程序中“双写”新旧两个库,以确保两个库中都有同样的业务数据。一旦新库出现问题,业务也不至于受影响。

第三步,同时写入新旧两个库,但只从新库中读数据。当我们对新库的基础设施有了信心之后,就可以把读操作也转移到新库中。这时我们仍然双写数据,因此出现任何问题都可以回退。

第四步,当新旧两库同时运行一段时间后,我们对新库的方方面面都有了十足的信心,此时就可以删掉旧库(或 Schema),彻底迁移到新库中了。

先拆代码还是先拆数据库?-先拆代码,后拆数据,严禁同时拆分

如果我们的目标是微服务架构,那么只有代码和数据库都拆分出来且独立部署了,整个任务才算结束。因此拆分工作,你就有三种顺序可选:先拆数据库、先拆代码或同时拆分。

拆分数据库(包括拆分成单独的库或拆分出新的 Schema),意味着以前的事务性操作会变成非事务的,你需要访问新旧两个库,然后在代码中对数据进行集成。这会造成新旧两个库的不一致。

虽然早晚都会遇到这样的问题,但我仍然建议你先拆分代码,因为拆分代码的认知负载相对低一些,采用战术分叉的方式拆分,也会更简单。

这能让你快速得到一些短期收益,比如代码的解耦、服务的独立部署。而且从单体到基于服务的分布式架构这条演进路线,也是十分清晰和成熟的。你可以随时停止,随时重启。

而数据库拆分则要困难得多,一旦先拆数据库,又发现很长时间看不到收益,团队的士气会严重受挫。

不过无论如何,我都不建议你同时拆分。一次只做一件事,是我们的原则。有些架构师可能还希望在拆分数据库的同时重新设计数据库,增加或修改一些表,我通常都建议他们不要贪心,保持克制,尽量先拆再改。

一次做多件事,任务的范围会越来越发散,导致最终迷失方向,忘了初心。遗留系统本身就是认知负载非常高的系统,不要再人为地增加认知负载了。

小结

拆分数据库时,可用于数据同步的几种模式:

 

拆分服务时,可用于数据共享的几种模式:

 

 

posted @   李琦贝尔蒙特  阅读(118)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
点击右上角即可分享
微信分享提示