实现架构现代化
建设新城区
绞杀植物模式(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),意味着以前的事务性操作会变成非事务的,你需要访问新旧两个库,然后在代码中对数据进行集成。这会造成新旧两个库的不一致。
虽然早晚都会遇到这样的问题,但我仍然建议你先拆分代码,因为拆分代码的认知负载相对低一些,采用战术分叉的方式拆分,也会更简单。
这能让你快速得到一些短期收益,比如代码的解耦、服务的独立部署。而且从单体到基于服务的分布式架构这条演进路线,也是十分清晰和成熟的。你可以随时停止,随时重启。
而数据库拆分则要困难得多,一旦先拆数据库,又发现很长时间看不到收益,团队的士气会严重受挫。
不过无论如何,我都不建议你同时拆分。一次只做一件事,是我们的原则。有些架构师可能还希望在拆分数据库的同时重新设计数据库,增加或修改一些表,我通常都建议他们不要贪心,保持克制,尽量先拆再改。
一次做多件事,任务的范围会越来越发散,导致最终迷失方向,忘了初心。遗留系统本身就是认知负载非常高的系统,不要再人为地增加认知负载了。
小结
拆分数据库时,可用于数据同步的几种模式:
拆分服务时,可用于数据共享的几种模式:
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· PowerShell开发游戏 · 打蜜蜂
· 在鹅厂做java开发是什么体验
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战