You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

23 KiB

13 | 架构现代化 :如何改造老城区前端?

你好,我是姚琪琳。

前面两节课,我们学习了架构现代化中建设新城区的一些模式。从这节课开始,我们来聊聊改造老城区相关的模式。

我们先回顾下什么是“改造老城区”。改造老城区模式是指对遗留系统内部的模块进行治理,让模块内部结构合理、模块之间职责清晰的一系列模式。也就是说,在遗留系统的单体内部,我们应该如何更好地治理架构。

我们按照从“前”往“后”的顺序,先从前端开始。

遗留系统的前端

在第十节课我们学习了一种架构反模式——Smart UI它是遗留系统最常见的前端模式。以Java Web项目为例它们往往在JSP页面中掺杂着大量的JavaScript、Java和HTML代码。其中最致命的就是Java代码因为它们可以随意访问后端的代码甚至访问数据库。我们重构前端代码最主要的工作就是移除这些Java代码。

前端的遗留代码和后端的遗留代码一样也是坏味道的重灾区。Martin Fowler在《重构第2版》中用JavaScript重写了所有代码示例这对于前端开发人员是相当友好的。它能帮助你识别出JavaScript中的坏味道并重构这些代码。

要重构前端代码最好也要优先添加测试。但不幸的是已有的前端测试工具对基于框架Angular、React、Vue的JavaScript代码是相对友好的但遗留系统中的前端代码既有JavaScript又有Java很难用前端工具去编写单元测试。

这里我推荐你编写一些E2E测试来覆盖端到端的场景。或者使用HtmlUnit 这样的工具通过编写Java代码来测试JSP。但实际上HtmlUnit也属于某种程度的E2E测试。

重构前端代码

前端JSP代码的重构和后端有相似之处但也有很多不同。我的同事王万德和胡英荣开源了一套端前端JSP到端后端Java API的遗留JSP改造方案,包括重构前后的代码对比。我将以这个代码库的代码为示例,给你讲解一下如何重构前端代码。

我们对于遗留JSP代码的重构可以分成以下八个步骤。每个步骤都可以小步迭代增量演进。

第一步,梳理业务

要想重构前端代码,你必须先搞懂它的含义。类似代码现代化时,我们用活文档工具去理清一个场景,要重构前端代码,你也得先梳理它的业务含义,搞清楚它到底做了哪些事情。遗憾的是,针对前端的活文档工具,现在我还没发现哪种比较好,因为前端有多种语言交织在一起,分析起来太麻烦。

不过好在前端并不像后端代码那样调用链很深,很多前端代码都是围绕一个页面来展开的,相对来说还算内聚,梳理起来也更容易一些。

第二步,模块化

梳理完业务逻辑下一步就是对前端代码进行模块化。这里的模块化是指按职责把原先冗长的JSP页面拆分出来分解成多个小的JSP页面比如header、footer、content等并将它们include到大页面中。

开发人员在编写JSP时很少有这种模块化的思想导致所有的东西都写到一个文件里。随着页面逻辑越来越复杂页面里的各种代码越来越多文件也越来越大。我甚至见过很多超出64KB限制的JSP文件。

模块化怎么实现我们结合例子来分析分析。从下面这段to-do list的代码示例可以很明显地看出它由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>

第三步重构JSP中的JavaScript代码

将页面拆小后我们还要继续重构各个小页面中的JavaScript代码。

长函数是遗留系统前端最常见的坏味道。早年开发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的时候会触发这个函数来进行校验。它首先会校验所填的人数然后校验所选活动之间的关系比如冬奥和户外探险不能同时选择选住宿则必须选冬奥。此外当取消勾选的时候也会触发一个联动逻辑也就是取消冬奥的时候会连带着一起取消住宿。

题面分析完了你想到重构的思路了么你脑海里涌现的第一个想法可能是这样的可以将每个if都抽取成函数。但这样抽取出来的函数仍然有很多重复代码逻辑并没有得到简化而且页面元素的读写和业务判断还是混杂在一起的。仔细观察你会发现所有的校验逻辑可以大体上分为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对象用于保存每一行的活动元素。这样我们就把页面元素和判断逻辑分离出来了。

第四步移除JSP中的Java代码

JSP中用<% %>括起来的Java代码叫做Scriptlet正是这样的代码把JSP变成了Smart UI。其实早在JSTL和EL诞生的时候就不再推荐使用Scriptlet了。然而十几年来情况不曾改观反倒是新型前端框架的兴起使前后端彻底分离才遏制住了Scriptlet的滥用之势。

但对于遗留系统来说Scriptlet仍然泛滥成灾重构前端代码的重点就是移除它们。JSP中的Scriptlet大致可以分为这么几类

1.对所有请求执行相同的Java代码如权限验证。这类Scriptlet可以迁移到后端写到一个Filter里。
2.直接与数据库交互的Java代码如从数据库中查询出数据并显示在table中或登录页面中验证用户名和密码等。这类Java代码其实处理的都是GET/POST请求也可以迁移到后端实现一个新的Servlet将代码迁移到doGet/doPost中。
3.控制页面显示逻辑的Java代码如上面to-do的例子中从后端拿到一个Todo对象的列表然后遍历这个列表

  • 便签展示出来。

    对于第三种Java代码你可以用JSTL和EL来替换就像下面这样

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

    完成替换后JSP中就只剩下了HTML、JavaScript和JSTL已经相当清爽了。如果你的工作就只是移除Java那么到此就可以告一段落。

    但如果目标是前后端分离彻底告别JSP你可能会希望使用纯JavaScript来替换。这时候就可以先保留这部分Scriptlet等下一步引入前端框架的时候再来替换。

    第五步,引入前端框架

    当Java代码移除之后我们再引入前端框架。比如对于todoList这个模块引入Vue后的代码就变成了下面这样

    <%
        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>
    
    

    注意我们这里只是使用脚本的方式引入了Vue。要想更好地使用前端框架你还需要对这些代码进行组件化和工程化。为了实现小步前进我们把这两部分内容交给第六和第七步。

    第六步,前端组件化

    引入前端框架之后我们就可以进一步重构将前面拆分出来的各个模块转换为组件。比如上面的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了。

    第七步,前端工程化

    我们上一步虽然将小模块都转换成了前端组件,但它们还是通过