【IT168 技术文章】 这是一个会议管理系统。该系统记录每个参会者的ID、姓名、电话和所属地区。参会者ID是会议组织者分配的唯一的数字标识。参会者的姓名是必须要提供的。电话则不是必填的。所有的参会者只能来自三个地域:中国、美国或者欧洲。
现在我们在数据库创建一个表来存储参会者的这些信息。表结构是这样的:
create table Participants (
id int primary key,
name varchar(20) not null,
telNo varchar(20),
region varchar(20)
);
为了让系统的使用者可以增加新参会者,我们需要下面这样的代码。系统会自动从已有的所有参会者中找出最大的ID,加1作为新的参会者的默认ID,并显示在界面的输入框内。用户可以直接使用这个ID,也可以自行输入一个特定的ID。请认真读下面代码:
class AddParticipantDialog extends JDialog {
Connection dbConn;
JTextField id;
JTextField name;
JTextField telNo;
JTextField region;
AddParticipantDialog() {
setupComponents();
dbConn = ...;
}
void setupComponents() {
...
}
void show() {
showDefaultValues();
setVisible(true);
}
void showDefaultValues() {
int nextId;
PreparedStatement st = dbConn.prepareStatement("select max(id) from participants");
try {
ResultSet rs = st.executeQuery();
try {
rs.next();
nextId = rs.getInt(1)+1;
} finally {
rs.close();
}
} finally {
st.close();
}
id.setText(new Integer(nextId).toString());
name.setText("");
region.setText("中国");
}
void onOK() {
if (name.getText().equals("")) {
JOptionPane.showMessageDialog(this, "名称不能为空");
return;
}
if (!region.equals("中国") && !region.equals("美国") && !region.equals("欧洲")) {
JOptionPane.showMessageDialog(this, "Region is unknown");
return;
}
PreparedStatement st =
dbConn.prepareStatement("insert into from participants values(?,?,?,?)");try {
st.setInt(1, Integer.parseInt(id.getText()));
st.setString(2, name.getText());
st.setString(3, telNo.getText());
st.setString(4, region.getText());
st.executeUpdate();
} finally {
st.close();
}
dispose();
}
}
这段代码看起来还正常吧?但其实这里将下面三个方面的代码混在了一起:
1. UI:JDialog、JTextField、响应用户事件的代码。
2. 数据库访问:Connection、PreparedStatement、SQL statements和ResultSet等。
3. 域逻辑:新参会者的默认ID、参会者姓名是必填的,以及所属地区的限制等。域逻辑又称为“域模型”或者“业务逻辑”。
这三方面的代码混在一起,会造成下面的问题:
1. 代码很复杂。
2. 代码很难重用。如果我们想创建一个EditParticipantDialog,让用户更改参会者的信息,我们就想重用部分域逻辑(比如地区的限制)。但实现这部分域逻辑的代码与AddParticipantDialog混在一起,根本不能重用。如果是在一个Web系统中,就更难重用了。
3. 代码很难测试。每次要测这样一段代码,我们都要建一个数据库,还要通过一个用户操作界面进行测试。
4. 如果数据库表结构更改,AddParticipantDialog这个类和其他很多地方都要跟着更改。
5. 它导致我们一直在考虑一些低层的、细节的概念,比如数据库字段、表的记录之类,而不是类、对象、方法和属性这些类的概念。或者说,一直在考虑怎么往数据库里面装数据,而没有面向对象的概念,没有建立业务模型的思维。
因此,我们应该将UI、数据库访问和域逻辑这三种类别的代码分离开。
抽取访问数据库的代码
我们先抽取出访问数据库的代码。先将数据库中所有的参会者当作一个集合体,这个集合体里面没有重复的元素,元素的排列也没有顺序。这个集合体支持增加、删除、更新和罗列的操作,可以将它命名为Participants。
class Participant {
int id;
String name;
String telNo;
String region;
...
}
interface ParticipantIterator {
boolean next();
Participant getParticipant();
}
class Participants {
Connection dbConn;
Participants() {
dbConn = ...;
}
void addParticipant(Participant part) {
PreparedStatement st =
dbConn.prepareStatement("insert into from participants values(?,?,?,?)");try {
st.setInt(1, part.getId());
st.setString(2, part.getName());
st.setString(3, part.getTelNo());
st.setString(4, part.getRegion());
st.executeUpdate();
} finally {
st.close();
}
}
void removeParticipant(int partId) {
...
}
void updateParticipant(Participant part) {
...
}
ParticipantIterator getAllParticipantsById() {
...
}
ParticipantIterator getParticipantsWithNameById(String name) {
...
}
}
在AddParticipantDialog中,我们还需要一个找出当前最大参会者ID的功能。因此,还要在这个集合体中定义一个getMaxId方法:
class Participants {
Connection dbConn;
void addParticipant(Participant part) {
...
}
int getMaxId() {
PreparedStatement st = dbConn.prepareStatement("select max(id) from participants");
try {
ResultSet rs = st.executeQuery();
try {
rs.next();
return rs.getInt(1);
} finally {
rs.close();
}
} finally {
st.close();
}
}
...
}
现在,AddParticipantDialog这个类可以简化为:
class AddParticipantDialog extends JDialog {
Participants participants;
JTextField id;
JTextField name;
JTextField telNo;
JTextField region;
AddParticipantDialog(Participants participants) {
this.participants = participants;
setupComponents();
}
void setupComponents() {
...
}
void show() {
showDefaultValues();
setVisible(true);
}
void showDefaultValues() {
int nextId = participants.getMaxId()+1;
id.setText(new Integer(nextId).toString());
name.setText("");
telNo.setText("");
region.setText("中国");
}
void onOK() {
if (name.getText().equals("")) {
JOptionPane.showMessageDialog(this, "名称不能为空");
return;
}
if (!region.equals("中国") && !region.equals("美国") && !region.equals("欧洲")) {
JOptionPane.showMessageDialog(this, "Region is unknown");
return;
}
Participant part = new Participant(
Integer.parseInt(id.getText()),
name.getText(),
telNo.getText(),
region.getText());
participants.addParticipant(part);
dispose();
}
}
现在,AddParticipantDialog类已经简单很多。从这里面,我们看不到任何跟数据库有关的东西存在了。
抽取访问数据库代码后得到的灵活性
因为AddParticipantDialog类现在操作的是一个参会者的集合体Participants,而不再是数据库中的表。就算现在我们想把这些参会者的信息存储在一个XML文件或一个简单的文本文件中也可以。我们只需要修改Participants这个类里面每个方法的具体实现就行,不用修改AddParticipantDialog。
class Participants {
void addParticipant(Participant part) {
//将参会者信息存在XML文件里
}
void getMaxId() {
//从XML文件中找出最大ID
}
}
甚至,如果我们同时要用数据库存储和XML文件存储时,我们就将Participants类变成一个接口,然后在一个实现类中用数据库实现这个接口,另一个实现类中用XML文件:
interface Participants {
void addParticipant(Participant part);
int getMaxId();
...
}
class ParticipantsInDB implements Participants {
void addParticipant(Participant part) {
...
}
int getMaxId() {
...
}
}
class ParticipantsInXMLFile implements Participants {
void addParticipant(Participant part) {
//将参会者信息存在XML文件里
}
int getMaxId() {
//从XML文件中找出最大ID
}
}
class AddParticipantDialog extends JDialog {
AddParticipantDialog(Participants participants) {
...
}
...
}
将域逻辑与UI分离
现在,我们分离域逻辑和UI:
class Participant {
int id;
String name;
String telNo;
String region;
...
static Participant makeDefaultParticipant() {
return new Participant(0, "", "", "中国");
}
void assertValid() throws ParticipantException {
if (name.equals("")) {
throw new ParticipantException("名称不能为空");
}
if (!region.equals("中国") && !region.equals("美国") && !region.equals("欧洲")) {
throw new ParticipantException("Region is unknown");
}
}
}
class ParticipantException extends Exception {
ParticipantException(String msg) {
super(msg);
}
}
现在,AddParticipantDialog可以简化为:
class AddParticipantDialog extends JDialog {
Participants participants;
JTextField id;
JTextField name;
JTextField telNo;
JTextField region;
AddParticipantDialog(Participants participants) {
this.participants = participants;
setupComponents();
}
void setupComponents() {
...
}
void show() {
showDefaultValues();
setVisible(true);
}
void showDefaultValues() {
Participant newPart = Participant.makeDefaultParticipant();
newPart.setId(participants.getMaxId()+1);
showParticipant(newPart);
}
void showParticipant(Participant part) {
id.setText(new Integer(part.getId()).toString());
name.setText(part.getName());
telNo.setText(part.getTelNo());
region.setText(part.getRegion());
}
Participant makeParticipant() throws ParticipantException {
Participant part = new Participant(
Integer.parseInt(id.getText()),
name.getText(),
telNo.getText(),
region.getText());
part.assertValid();
return part;
}
void onOK() {
try {
participants.addParticipant(makeParticipant());
dispose();
} catch (Exception e) {
JOptionPane.showMessageDialog(this, e.getMessage());
}
}
}
现在,AddParticipantDialog类更加简单。准确地说,这个类基本上要做的事情就是,通过多个不同的UI组件显示一个参会者对象的信息(showParticipant),根据UI组件上的内容生成一个参会者对象(makeParticipant)。就此无他。
改进后的代码
下面看一下改进完的代码:
class Participant {
int id;
String name;
String telNo;
String region;
...
static Participant makeDefaultParticipant() {
return new Participant(0, "", "", "中国");
}
void assertValid() throws ParticipantException {
if (name.equals("")) {
throw new ParticipantException("名称不能为空");
}
if (!region.equals("中国") && !region.equals("美国") && !region.equals("欧洲")) {
throw new ParticipantException("Region is unknown");
}
}
}
class ParticipantException extends Exception {
ParticipantException(String msg) {
super(msg);
}
}
class Participants {
Connection dbConn;
Participants() {
dbConn = ...;
}
void addParticipant(Participant part) {
PreparedStatement st =
dbConn.prepareStatement("insert into from participants values(?,?,?,?)")try {
st.setInt(1, part.getId());
st.setString(2, part.getName());
st.setString(3, part.getTelNo());
st.setString(4, part.getRegion());
st.executeUpdate();
} finally {
st.close();
}
}
int getMaxId() {
PreparedStatement st = dbConn.prepareStatement("select max(id) from participants");
try {
ResultSet rs = st.executeQuery();
try {
rs.next();
return rs.getInt(1);
} finally {
rs.close();
}
} finally {
st.close();
}
}
}
class AddParticipantDialog extends JDialog {
Participants participants;
JTextField id;
JTextField name;
JTextField telNo;
JTextField region;
AddParticipantDialog(Participants participants) {
this.participants = participants;
setupComponents();
}
void setupComponents() {
...
}
void show() {
showDefaultValues();
setVisible(true);
}
void showDefaultValues() {
Participant newPart = Participant.makeDefaultParticipant();
newPart.setId(participants.getMaxId()+1);
showParticipant(newPart);
}
void showParticipant(Participant part) {
id.setText(new Integer(part.getId()).toString());
name.setText(part.getName());
telNo.setText(part.getTelNo());
region.setText(part.getRegion());
}
Participant makeParticipant() throws ParticipantException {
Participant part = new Participant(
Integer.parseInt(id.getText()),
name.getText(),
telNo.getText(),
region.getText());
part.assertValid();
return part;
}
void onOK() {
try {
participants.addParticipant(makeParticipant());
dispose();
} catch (Exception e) {
JOptionPane.showMessageDialog(this, e.getMessage());
}
}
}
SQLException的陷阱
事实上,代码里还有一个问题。我们先认真地看一下Participants这个类:
class Participants {
...
int getMaxId() {
PreparedStatement st = dbConn.prepareStatement("select max(id) from participants");
try {
ResultSet rs = st.executeQuery();
try {
rs.next();
return rs.getInt(1);
} finally {
rs.close();
}
} finally {
st.close();
}
}
}
每当我们调用prepareStatement、executeQuery、next和close这样的方法,JDBC都会抛出SQLException这个异常。但是,getMaxId这个方法本身不能处理该异常,我们只好将它抛出去,让外部调用getMaxId方法的地方处理。所以,我们要将代码改成:
class Participants {
...
void getMaxId() throws SQLException {
PreparedStatement st = dbConn.prepareStatement("select max(id) from participants");
try {
ResultSet rs = st.executeQuery();
try {
rs.next();
return st.getInt(1);
} finally {
rs.close();
}
} finally {
st.close();
}
}
}
问题就在这里,每次AddParticipantDialog里调用getMaxId方法,它都要处理这个异常(要么catch住自己处理,要么继续向上抛)。
class AddParticipantDialog extends JDialog {
...
void showDefaultValues() {
newPart = Participant.makeDefaultParticipant();
try {
newPart.setId(participants.getMaxId()+1);
} catch (SQLException e) {
...
}
showParticipant(newPart);
}
}
因为AddParticipantDialog现在要处理SQLException这样的异常,我们一眼就可以看出这个类又与数据库藕合,它不再是单纯操作参与者的集合体或者一个XML文件。也就是说,SQLException强制性地让我们的AddParticipantDialog陷入调用数据库的坑中。如果别的环境中不用数据库存储的话,这段客户端代码就无法重用,即使很勉强地重用了,也会变成很糟糕的代码。
为了解决这样的问题,当Participants类碰到一个数据库的异常时,它不应该直接将这个异常抛出去,而是要抛出一个跟participants这个集合体本身有关的异常:
class ParticipantsException extends Exception {
ParticipantsException(Throwable cause) {
super(cause);
}
}
class Participants {
...
int getMaxId() throws ParticipantsException {
try {
PreparedStatement st =
dbConn.prepareStatement("select max(id) from participants");try {
ResultSet rs = st.executeQuery();
try {
rs.next();
return rs.getInt(1);
} finally {
rs.close();
}
} finally {
st.close();
}
} catch (SQLException e) {
//抛出和Participants有关的异常
throw new ParticipantsException(e);
}
}
}
现在,AddParticipantDialog类就不用处理SQLException这样的异常了:
class AddParticipantDialog extends JDialog {
...
void showDefaultValues() {
Participant newPart = Participant.makeDefaultParticipant();
try {
newPart.setId(participants.getMaxId()+1);
} catch (ParticipantsException e) {
...
}
showParticipant(newPart);
}
}
给系统分层
我们可以将上面这个会议系统分为三层:数据库访问层、域逻辑层和UI层。
因为Participants类访问了数据库,所以我们将它放在数据库访问层。因为Participant、ParticipantException和ParticipantsException处理了与域逻辑有关的事情,我们将它们放在域逻辑层。因为AddParticipantDialog是管理与用户交互的界面的类,我们将它放在UI 层。(如图1)

图1
如果类A引用了类B,我们就从A画一个箭头到B。图2就是这个系统中类之间的引用(依赖)关系:

图2
而层之间的依赖关系如图3:

图3
从图中我们可以看出:
1. 域逻辑层是唯一没有依赖于其他层的分层,所以这一层最容易重用。
2. 数据库访问层依赖于域逻辑层,并没有依赖UI层。所以,不论是在文本文件存储系统中还是在XML存储系统中,这一层都可以重用。
3. UI层依赖于其他两层,因为这一层最难重用。
对于第3个观点(UI层依赖于其他两层),我们再想一下:我们不是已经让AddParticipantDialog(UI层)不知道数据库的存在了吗?怎么图里面还是显示这样的依赖关系?根据我们前面讲的,我们是让Participants变成一个接口,它只是定义了一些业务逻辑操作,但并没有具体的实现。具体的实现有多种情况。现在我们在数据库环境的系统中创建一个实现类叫ParticipantsInDB,这样,很明显的,Participants就属于域逻辑层,而ParticipantsInDB则属于数据库访问层(如图4)。

图4
而层之间的依赖关系如图5:

图5
现在,各个层次间就只有两种依赖关系:
1. UI层依赖于域逻辑层。
2. 数据库访问层依赖于域逻辑层。
并不是所有的系统都可以做到分层间只有两个依赖关系。比如,我们刚开始的AddParticipantDialog类就不属于这样的好系统,我们甚至想把它归入某一层都做不到。只有结构比较好的系统才有可能做到这样。而这就是我们设计结构的目标(特别是大项目)。
很多东西都属于UI层
很多东西都属于UI层。不仅仅窗口、按钮之些属于UI,报表、servlet、jsp和文本控制台等也属于UI层。因此,请不要在servlets里放置任何的域逻辑或者数据库访问代码。特别要关注那些Servlets里有很多代码的系统,它们是最有嫌疑的。也不要在域逻辑中调用System.out.print。
其他方法
将数据库看作一个对象的集合体,只是我们在UI层隐藏数据库层很多方法中的一个。其他的方法可以从以下链接学习:
http://c2.com/cgi/wiki?ScatterSqlEverywhere
http://c2.com/cgi/wiki?ModelFirst
http://c2.com/cgi/wiki?ObjectRelationalMapping
http://www.agiledata.org/essays/mappingObjects.html
http://www.martinfowler.com/articles/dblogic.html
http://www.martinfowler.com/eaaCatalog
http://www.objectmentor.com/resources/articles/Proxy.pdf