Developers that rock

home feed

手把手教你重构教务助手 (二)

01 Sep 2014 by hbc

目录

蓝图

虽然上回说道可以开始写代码了,但其实我们都知道写代码和写作文一样,是需要立提纲的。 所以不妨让我们先拿出一张纸和一支笔,列下助手需要的功能。(天灵灵地灵灵,PM 快点上我身)

鄙校教务系统和天下所有政企网站一样,完全不把交互体验放在眼内,例如:

根据这些吐槽我们可以整理出以下几个功能点:

P.S. 想拥有更多功能?请猛戳 issue 目录进行添加!

重构第一步:从结构入手

我们先看重构前的代码(内容从下面开始包含若干不负责任吐槽,请未成年人在监护人陪同下观看):

传送门

程序最开始的入口就是 init 这里:依次进入各个函数,然后分别检查当前页面是否是对应功能执行的目标。

这里马上就能发现一个可以改善的地方:不应该让子程序去找我们,而是让我们去主动找子程序。 这样的话可以去除子程序中冗余的操作啦。(有点那么个 mediator 的意思啦)

所以第一行代码我们先写一个操作执行管理器:Page.

代码传送门

然后代码的结构就可以变成类似这样的形式:

var page = new Page;

page.on('成绩查询页面', function () {
  // 给这个页面加上好用的 GPA 计算功能!
});

page.on('教师评价页面', function () {
  // 随手按给老师打个分!
});

page.on('我都不知道什么情况的错误页', function () {
  // 解决这个错误咯
});

是不是有种“复行数十步,扩然开朗的感觉呢”!

第一个功能

确定好结构以后,我们可以把目光移向真正的功能啦,为了好好享受暑假,我决定从比较简单的做起: GPA 计算器。 (不过后来发现假如预先做好错误页自动跳转会多么的幸福啊 QAQ)

根据助手第一个版本,我们很快地就按照正常人的思维:

  1. 获取课程列表中的所有成绩
  2. 每个(行)课程计算出对应的绩点、学分绩点;插入到该行中
  3. 计算所有课程的平均绩点、平均分和加权平均分;插入到表头中
  4. 监听鼠标时间,取消/勾选某个课程的时候重新计算(重新执行 3)

写出了这样的代码

然后打包这新增加的 163 行代码,放到浏览器里进行人工测试:

It Works!

Recap 1

虽然代码能运行,但其实有很多潜在问题。如果直接按照上面的思维来写的话,很容易就把计算功能和页面处理功能混淆在一起; 代码也就变成和 DNA 双螺旋结构一样的形式:各部分紧紧耦合(大家还记得 DNA 两条链是怎么连接的么?)。

这在软件开发中是很明显的 bad smell 噢! O.O

另外一个问题就是代码变得不太容易测试。写过浏览器插件的同学都应该知道,这种和具体行为紧关联的代码是很难进行测试的, 只能通过人手进行测试,所以会让你的开发流程变成:

  1. 写写写 / 改改改
  2. 打包
  3. 刷新页面,查看效果;发现问题?回去 1

当你遇到像鄙校教务系统一样的网站的时候,也真是醉了。_(:з」∠)_

所以接下来我们要重构这段用来重构的代码(递归开始)。


FYI: 什么鬼是 code smell?

这里;看完分分钟变敏捷开发狗 :doge:。


第一个重构

重新审视上面的代码,我们可以发现写出来的代码可以分为两部分:

修改页面这个涉及到具体的页面效果,所以还是比较适合进行人肉测试; 而计算部分其实是很好做单元测试的,所以我们接下来就应该大刀阔斧地抽离这部分的逻辑。

程序 = 数据结构 + 算法, 好程序 = 好数据结构 + 准确的算法

有关数据结构的名言就不搬了,对程序进行抽象的一个显而易见方法就是使用对的数据结构。 在助手这个 case 里,我们不妨先把每门课程抽象成一个简单的属性集合 —— 这样课程数据立刻变成了生猛的、区别明显的实体了。(好吧又是我脑补的,画面有些惊悚。)

而具体的 GPA 计算我们也应该抽离出一套策略,大家使用策略就好,不用关心提供者怎么实现这个策略。(另外一层抽象)

根据上面的分析我们可以分离出如下两个大头:

// ## GPA 计算器
var GPA = {
    // 从分数或等级计算绩点
    fromScoreOrGradeLevel: function (score) {},

    // 计算一门课程的学分绩点
    creditGPA: function (lecture) {},

    // 计算若干门课程的平均分
    avgScore: function (lectures) {},

    // 计算若干门课程的平均学分绩点
    avgCreditGPA: function (lecutres) {},

    // 计算若干门课程的加权平均分
    avgWeightedScore: function (lectures) {}
};


// ## 课程成绩记录定义
// 
// * code        :  课程代码
// * name        :  课程名称
// * type        :  课程性质(公共基础?专业基础?)
// * attribution :  课程归属(人文社科?工程基础?)
// * is_minor    :  是否是辅修专业课?
// * grade:
//    - score    :  课程成绩
//    - makeup   :  补考成绩
//    - rework   :  重修成绩
// * credit      :  学分
// * gpa         :  绩点
function Lecture() {
    this.code = null;
    this.name = null;
    this.type = null;
    this.attribution = null;
    this.isMinor = false;
    this.credit = 0.0;
    this.grade = {
        score: 0.0,
        makeup: 0.0,
        rework: 0.0
    };
    this.gpa = 0.0;
}

// 从 `table tr` 中获取一个课程信息
Lecture.fromTableRow = function (row) {

};

// 从 `table` 中获取一系列课程信息
Lecture.fromRows = function (table) {

};

测试先行

为了更好地确保代码是正确的,我们改变一下之前需求导向的写代码方法,改用测试导向的风格。

在助手这个项目中,我们选用了 mochashould.js 来做测试。 (都是 TJ 大大的作品呢 OvO)

测试导向的开发流程比较有意思的地方在于我们不先写代码,而是先写测试;然后再写代码把这些测试跑通 —— 就可以算作我们的代码满足需求了。 鉴于此,我们会采用 BDD 的方式来描述助手的测试:

describe('page', function () {
    describe('on', function () {
        it('should return page for chain usage', function () {
            var page = new Page;
            page.on('test-page', testCallback).should.exactly(page);
        });
    });
});

写出这个测试之后,打开本地测试服务器查看运行结果;注意我们还没写具体的实现(严格来说是还没把这个实现写对):

bdd-1

然后我们再去完成实现具体的接口:

bdd-2

(成就感油然而生,掌声鼓励谢谢!:clap:)

这样我们就比较有信心进行其他修改啦:

bdd-3


问:这些 BDD / TDD 看起来好像很棒噢,我以后写代码是不是都应该这么做啊?

答:

test in production


在完成这些测试之后,我们就可以着手把之前的 DNA 双螺旋清理掉:

代码看这里

虽然代码好像变多了,但至少我们可以比较容易发现出问题是出现在哪里 —— 因为我们有测试保佑啦!

Recap 2

今天的重构过程中我们始终强调贯彻了以下几个原则:

并且加大了认真写代码的力度。

下一篇就写写怎么完成剩下的功能啦。

comments powered by Disqus