第三篇 设计篇
读者叙
在原始社会,人类作为灵长类生物天生具有更强的思考能力,开始学会钻木取火,开始
制造狩猎的工具。人类通过不断思考、探索、实验,对各项工具开始具备了外形、材质的形
象定义,也许这就是人类最初对工具的设计过程。
现在的世界,有了各种交通工具、电灯、网络。在很多人心目中这些东西是被制造出来
的,但在制造之前,是需要根据社会的需求以及物理学所能支撑的技术范畴综合考量后,加
以推敲、思考然后进行工艺、外形的设计,才会有制造的成果。
写程序依然如此,我们今天只是换了一种方式来制造世界上更多的虚拟内容,因此我认
为设计的思路对每一位程序员来讲都是十分重要的,在本篇,将与大家交流一些设计的心得
和思路,包含 Java 代码的设计、软件架构设计、产品设计、交互设计方面。
作为一个程序员需要了解的设计吗?
设计的思路让我们站在换一个角度看待技术和业务问题,或者说将这些问题的整体看得
更加清楚,可以让我们逐步具备从上往下看待问题的思路。另外,通过设计,可以让一个团
队对共同的目标达成共识,一个团队做的事情才能像一个人做的事情一样,有机会去达到在
效率上的最大化。
设计篇会有那些设计知识?
程序员的设计可以说无处不在,从上层架构到最基础的代码结构设计,甚至于对复杂的
代码还会继续分层设计。总之,设计的基本原则是让每一个层次的逻辑尽量简单清晰。按照
宏观上分层的结构,本篇将分 3 个部分:
第 1 部分讲解代码及软件结构设计希望更加贴近程序员本身。
第 2 部分探讨产品设计的一些理念和思考,但我们不细谈产品经理应该做的事情。
第 3 部分探讨一些交互设计理念和思考,同样我们不详细探讨交互设计师做的工作。
Java 设计是谈设计模式吗?
胖哥也不太懂什么设计模式,^_^,我只是根据在工作中的一些经历,与大家一起探讨
一些代码案例如何设计会更好一些,而且,你可能会有更好的方案哦!
第 1 章 代码及软件架构设计
章节简介
本章将从接口和抽象类开始与大家探讨软件设计的基础理论的一些细节,通过一些简单案例,
探索于设计的意义和原理。
后续将以开源框架拓展、模块化、组件化、代码分层几个角度阐述框架搭建的意义和基础方法。
最后我们将阐述产品业务发展过程中必然面临的重构、拆分,其时机与方法基础。
1.1 接口及抽象类
接口和抽象类是耳熟能详的 Java 专业术语:
Java WEB 代码会惯性去写接口和实现类。
大学的课本里面会反复提到。
Java 提到多态、设计模式等会随处可见。
面试官通常会问这样的问题。
许多程序员这个时候会做一个“乖孩子”去遵循非常多在其中的许多条款,也会用“规
范”二字去让更多人无条件使用。当然按照小胖的习惯,这本书里面肯定是非常规的手段来
与你探讨这些事情,希望大家通过这些内容开启对有封装、继承、多态的原理的理解。
1.1.1 接口无处不在
接口的思想并非 Java 独有,Java 语言本身也借鉴于现实世界成熟思路进行模拟。现实
生活中体现得较为明显的是制造业。例如生活中所熟知的一些事物:计算机、汽车、商品房
建造等等。下面给大家逐步举几个例子来探讨一下:
计算机:
计算机的内部几大核心部件:主板、CPU、内存、显卡等,这些部件通常由不同的生产
厂家来制造,但是又能粘合在一起,其原因是插口都有标准。随着业务的快速发展,这样做
的好处被推广,逐步成为一种社会普遍的规范,每一个想参与这个行业的公司,都会按照指
定的规范去生产具体的部件,组装过程就会比较顺利了,而且这些公司之间也不需要太多会
议上的沟通。
在现实社会中,类似的思路都是相通的,例如前文中提到的汽车,现在国内的许多“合
资车”厂商根据进口发动机规格等信息开始批量生产其它部件,发动机进口后根据相应接口
标准对接上即可。
管道:
现在我们从另一个角度来谈接口,联想一下生活中的所用到的水管,从水厂开始净化水
后,经过大小管道层层分流达到各个家庭,大小管道的设计提前有了规格设计,那么在部署
和施工过程中就会有精准地调度过程。不同规格的管道之间的连接需要有一些转换的过程,
同规格的管道在拐弯处也需要转换地过程。
从这个角度,可以理解管道之间的适配是通过一种转换处理的方式来完成。
从现实世界回到程序中:
Java 语言中的接口利用是非常多的,只要你用心去看工作中的代码,就应该会发现很多
设计上的技巧。我并没有大家的业务代码,因此无法与你的工作绝对相关,将会以一些较为
熟知的通用组件来给大家讲解,希望能够帮助你去挖掘业务代码中的设计思路。
JDBC 标准的定义就是一种接口,语言的作者首先会了解到数据库的常用操作,因此定
义了一套 java.sql.*的接口出来,这样可以让 Java 的程序设计者拿到驱动后,就会比较简单
地使用 SQL 访问数据库,而不需要关心通信细节。但是语言的作者并不想涉及到各种数据
库的通信细节,这样它会花费大量的时间和精力,因此它将细节由各个厂家去实现,各个厂
家或第三方去保证稳定性。
在这个目录下还有连接池 DataSource 的接口定义,同样的,DataSource 的实现者、驱
动研发者、基于连接池的框架(如 ibatis)、业务程序员就可以并行发展。其原因就是它们之
间的衔接通过一种接口的方式来完成,而不会相互依赖导致串行化发展。
这样的案例也不仅仅局限于 Java 语言本身,例如 Spring 的事务管理器为了面对不同的
事务场景,抽象了 PlatformTransactionManager 相关的方法,具体的 commit、rollback 等动
作由具体的事务组件来完成,而 Spring 在这一块将更加专注于事务对于业务代码的切入、
封装处理。
Java 中用到的例子还很多
WEB 容器中划分的 Filter、Servlet、Listener 标准。
Hadoop 中定义的 Map 和 Reduce 标准。
Spring 的拦截器(interceptor)定义。
…….
回到现实,程序员朋友们,我们有些时候跳出自己代码逻辑的视野,就会看到更高的维
度的事情,而下面的事情会更加抽象,事情的全盘也会因此更加清晰明朗一些。正所谓“站
得越高看得越远”。
1.1.2 抽象类是接口的“好基友”
上面提到接口,自然少不了它的一个好基友“抽象类”,因为经常会有一些面试官问“抽
象类和接口的区别是什么”这样的问题。
从本质上来讲,这两者还谈不上啥区别。从语法上看,抽象类比起接口的区别在于可以
编写一些实现方法,介于接口和实体类之间的一种类。这样的说法你难道不觉得这也太表面
了吗?
在上一节描述的接口实例中,如果我们去读一读其中的源码会发现实现类中大多都会出
现抽象类,这些抽象类通常扮演的角色是接口方法的通用逻辑实现的内容,由于它是抽象多
个子类的通用逻辑,那么子类必然就有一些特殊的变化。
例如:一台固定品牌的汽车生产过程中会有一个大致一样的流程,但是当这台汽车到了
控制台配置、颜色、发动机排量等信息的时候,会不太一样,那么工艺流程中就类似于公共
父类定义了模板化的工艺步骤,或者说主体步骤是早已固定好的,但是工艺步骤中并不知道
喷漆的颜色、发动机的排量、各项其它配置,将在对应的步骤中根据车辆需求决定,然后到
了具体的步骤后,由根据需求来决定需要选用的“子组件”来完成对应的车辆配置工作。
在设计模式中通常叫做“模板方法”,从程序设计的角度来讲,它有这样一些好处:
抽象类中可以实现一些通用的组件方法,让代码变少维护成本低。--继承也可实现
抽象类更为清晰明确地定义该接口所需要总体逻辑步骤,公共的逻辑步骤由父类完
成,非公共的组件将“明确要求”子类来完成。---明确要求这一点普通继承做不到。
子类的方法单纯清晰,要实现的方法已被明确定义(就先工艺流程中该步骤只做喷
漆操作),整个代码成组件思路,这样一来,子类的方法在拓展的时候代码不至于
与各种不同的业务绕在一起,导致可读性很低。
回到与接口的关系上来讲,抽象类更多是在实现层做了一些实体类无法完成的“明确定
义子类工艺”的动作,和接口的关系更多是为了达到业务目的配合使用,谈不上什么区别可
言,如果你不介意,也可以用实体类来完成,未实现的方法都用空 Body 来完成,子类 Ovrride
该方法也一样可以实现。但这样的操作大家可以对比一下,写代码的过程通常不会是设计的
思路,子类继承父类的方法也会很随机,最后会感觉子类不知道是什么的定义,可能是父亲
类的一个随机补充。这样反过来我们才能看出抽象类的实用价值。
1.1.3 设计者角度思考问题
通过前两个小节的分析,抽象类和接口更多是为设计者思路提供了实现上的便利,即:
设计者会从整体结构上把握体系,在乎主干而并非肉体本身。在主体结构存在的前提下,从
上向下去考虑问题,当前这一层只关注这个抽象层次的思维方式并编写相应的逻辑,相关的
抽象方法会由接口调用来完成。
这个道理有点像工厂的车间主任关心车间工人的头、技术负责人、机器整体情况,技术
头会关注自己所涉及技术高级问题和带徒弟做事情,徒弟们通常会关注自己做事情的每一个
细节。
在设计代码前,如果根据深入了解业务背景,进而不断细化,就逐步能得到许多业务对
象、对象依赖、调用关系、动作、步骤、流程等元素,从而可以在代码设计之初将许多框架
体系想得比较清楚。
在这个过程可以用一些伪代码来提升设计上的可读性(关键是要将问题讲清楚),也不
用拘泥于一定要在整个产品或项目的前期才这样做---这样做似乎是给客户或老板看的,每一
个相对复杂的模块都可以尝试这样去做,它可以将许多问题提前得更清楚一点---在很多时候
它能指导很多大体方向的可行性及难度。
Java 所提供的设计思路天生似乎就是这样的,这使得相对之间的抽象层次很简单和清晰,
大家分工协作但目标一致,使得每一个人的思路非常的清晰从而效率较高。这比较著名的就
是 Java 的分层开发,关于这方面的内容,将在本章 1.6 小节与大家有更多的沟通。
既然是一种设计者的思路,因此希望每一位开发者在使用的时候理解它的存在价值,这
样才有机会在较为合适的时机选择适当的设计思路。这样的分层体系会让很多非 Java 的开
发者非常的困惑,尤其是阅读很多源代码时会感觉 Java 将许多简单的问题复杂化了。
从上述的结论来看,在阅读 Java 代码的时候如果懂设计思路的基础上先关注整体架构
逐层阅读每一层次的抽象意义会容易多了,反过来看我们自己,如果编写的代码每一层的抽
象意义不明确,通常来讲我们也就没有太明白分层的价值。
上面我们人云亦云,不知道读者朋友有没有一些感觉,如果还没有,来看看我们下面的
一些小例子,再反过来看上面的概括。
1.2 抽象代码的小例子
在本节,将从一个非常简单的例子开始,逐步提升一些相对复杂的例子,希望大家能从
代码中感受一些代码抽象的感觉,真正体会到一些与没有设计的代码之间的区别在那里。实
际的案例也与真实的场景差距很大,我们在这里更多的是探讨一个思路和感觉。
1.2.1 星座代码的简单改变
下面这个星座的例子是一个小伙伴写的小案例,主要是需要写了一个关于根据“月份+
日期”返回“星座”的简单函数,当时这位小伙伴提出的问题还并不是设计上的问题,而是:
“无法得到星座的返回值”,这显然是代码本身有问题,下面来看看代码,你能一眼看出代
码那里有问题吗?
代码清单 1-1 有问题的判定星座原始代码
public String getConstellationValue(Integer month, Integer day) {
String Constellation = null;
if ((month == 12 && day >= 22) || (month == 1 && day <= 20)) {
Constellation = Constellation.Capricorn.getValue();
} else if ((month == 1 && day >= 21) && (month == 2 && day <= 19)) {
Constellation = Constellation.Aquarius.getValue();
} else if ((month == 2 && day >= 20) && (month == 3 && day <= 20)) {
Constellation = Constellation.Pisces.getValue();
} else if ((month == 3 && day >= 21) && (month == 4 && day <= 20)) {
Constellation = Constellation.Aries.getValue();
} else if ((month == 4 && day >= 21) && (month == 5 && day <= 21)) {
Constellation = Constellation.Taurus.getValue();
} else if ((month == 5 && day >= 22) && (month == 6 && day <= 21)) {
Constellation = Constellation.Gemini.getValue();
} else if ((month == 6 && day >= 22) && (month == 7 && day <= 22)) {
Constellation = Constellation.Cancer.getValue();
} else if ((month == 7 && day >= 23) && (month == 8 && day <= 22)) {
Constellation = Constellation.Leo.getValue();
} else if ((month == 8 && day >= 23) && (month == 9 && day <= 22)) {
Constellation = Constellation.Virgo.getValue();
} else if ((month == 9 && day >= 23) && (month == 10 && day <= 23)) {
Constellation = Constellation.Libra.getValue();
} else if ((month == 10 && day >= 24) && (month == 11 && day <= 22)) {
Constellation = Constellation.Scorpio.getValue();
} else if ((month == 11 && day >= 23) && (month == 12 && day <= 21)) {
Constellation = Constellation.Sagittarius.getValue();
}
return Constellation;
}
这段代码看似非常简单,但是这位小伙伴问这个问题的时候,大多数人都没有看出啥问
题,因为大家心里都觉得是不是漏掉了某个时间段没写,而忽略了一些低级问题。
但是问题的根本原因往往很“菜”,就是从第一个 else if 逻辑开始,日期判定逻辑之间
的“||”写成了“&&”,而且还是千篇一律的写法。这也许而是我们写代码最爱干的事情吧:
“复制粘贴”。
这是一个比较低级的问题,但是通常会让我们很麻木,而最直接的解法是将这个符号换
成“||”就解决问题了,但是如果这样的代码只有一两个逻辑错误其实是很难发现的,我们
是否可以重新设计一下这段代码呢?设计的目的就是让我们通过一些方法让逻辑变得简单,
让那些能“难倒高手的低级问题”尽量被扼杀在摇篮里,答案是肯定的,且听分解:
12 个星座,每年月份也是 12 个,每 1 个星座时间正好连续地横跨 2 个月
反过来看,每 1 个月也会出现 2 个星座,那么根据输入的月份就能确定到 2 个星座,
且两个星座的顺序是可人为确立的。
每一个星座有结束日期,那么根据条件 2+输入日期就能决定 2 个星座中的具体那
一个了。
既然输入日期与星座结束日期有关系,且星座是固定不变的,那么基于星座这个我们做
一 个基 于结束 日期 的星座 表格 ,然后 基于这 些枚 举对 象,建 立月份 维度 的星 座表格
MONTH_ARRAY(这个建立表格的过程,就是一个比与、或、非简单得多的编辑逻辑),通过
这个表格,就可以根据月份+日期得到其在表格中的坐标:
代码清单 1-2 修改后的星座代码
public enum Constellation {
//入口参数为星座结束的日期,不带月份
Capricorn(20),
Aquarius(19),
Pisces(20),
Aries(20),
Taurus(21),
Gemini(21),
Cancer(22),
Leo(22),
Virgo(22),
Libra(23),
Scorpio(22),
Sagittarius(21);
//记录月份中的两个星座的“月中日”的分割点,也就是记录每个星座的“月中日”
private final int endDay;
private final static Constellation[][]MONTH_ARRAY = {
{Capricorn , Aquarius} , //1月
{Aquarius , Pisces} , //2月
{Pisces , Aries} , //3月
{Aries , Taurus} , //4月
{Taurus , Gemini} , //5月
{Gemini , Cancer} , //6月
{Cancer , Leo} , //7月
{Leo , Virgo} , //8月
{Virgo , Libra} , //9月
{Libra , Scorpio} , //10月
{Scorpio , Sagittarius} , //11月
{Sagittarius , Capricorn} , //12月
};
private Constellation(int endDay) {
this.endDay = endDay;
}
public static Constellation fromValue(int month , int day) {
Constellation[]tempArray = MONTH_ARRAY[month - 1];
if(tempArray == null) return null;
return day <= tempArray[0].endDay ? tempArray[0] : tempArray[1];
}
}
代码分析:
这段代码似乎为了解决一个简单问题变得复杂了?小胖可不这么认为,有些时候叫前人
种 树后 人乘凉 ,我 们将逻 辑封 装后, 变成了 简单 的表 格填写 和坐标 定位 ,逻 辑只有
fromValue(int month , int day)里面的一点点,从设计来降低逻辑成本,同时也降低了低级问
题出现的概率。