对象的协作
面向对象设计的一个重要分析方法是利用对象的职责来驱动设计。对象有了职责,才会成为具体拥有意识的对象,成为对象社区中可以独立完成,或者发出指令委派别的对象协作完成职责的一员。将对象看成是“具有职责的东西”。对象应该自己负责自己,而且应该清楚地定义职责。这就是对象设计的关键。好的软件设计者应该像牧羊人一般放牧自己的牛羊,只需要给它们一片丰沃的草地,它们就能自己觅食生活。
Rebecca认为:对象在履行职责时有3种选择,你可以任选其一:(1)亲自完成所有的工作。(2)请求其他对象帮忙完成部分工作(和其他对象协作)。(3)将整个服务请求委托给另外的帮助对象。在分析对象职责时,可以考虑“专家”模式,即信奉专业的事情交给专家来完成,既不互相推诿,却也不能越俎代庖。专家有其擅长的领域,如果把专家错放在他不熟悉的领域,不仅会降低工作效率,还可能引入潜在危机。所谓“尺有所长,寸有所短”,含义正在于此。
对象的能力总是有限的,正如我们不能将所有的鸡蛋放在一个篮子里,我们也不能将所有的职责交给“上帝”。这既避免单一功能点带来的风险,又能实现职责的分权。Christepher Alexander建议,在遇到设计问题时“尽量少用集权的机制”。软件设计同样如此。职责总是可大可小,面对复杂的职责行为,总是需要多个对象的协作才能完成,就像机器中的零部件,按部就班,各司其职。
让我们来设计一个数据分析器,它通过分析算法对输入数据进行分析,并将结果保存在输出集中。根据业务需求的不同,执行的分析算法也不相同。同时,为了提高分析性能,我们还需要采用多线程方式执行分析任务。从调用者的角度来看【即Martin Fowler提到的规约视角】,我们只关心分析需要的数据以及分析后的结果。那么,谁应该来承担分析的职责呢?毫无疑问,我们可以定义分析器对象来履行这一职责。此外,调用者其实并不会关心分析算法的实现以及分析的过程,他希望分析是易于执行的,这一愿望通过有效的封装完全可以满足。我们可以将分析的职责封装在DataAnalyzer类中,同时隐藏具体的分析算法。
public class Client {
public static void run(InputData input) {
DataAnalyzer analyzer = new DataAnalyzer();
AnalyticResult result = analyzer.analysis(input);
//做其它事情
//处理分析后的数据
handleOutput(analyzer.output(result));
}
}
public class DataAnalyzer {
public AnalyticResult analysis(InputData input) {}
public OutputData output(AnalyticResult result) {}
}
然而,数据的分析并不是一件简单的事情。分析器虽然是分析的专家,却不精通多线程的执行。它自身无法提供分析的异步操作,这时就需要其他对象的协作。分析器将这一职责委派给TaskScheduler对象。TaskScheduler是一个任务调度器,可以发起任务的运行,并在运行完毕的时候,通知任务的发起者。对于TaskScheduler而言,并不会关心具体执行了何种任务。这样的设计能够遵循Demeter法则,让对象尽量保持无知,以避免不必要的依赖。
public class TaskScheduler {
private Task task;
public AnalyticResult beginRun(Task task) {
AnalyticResult result = new AnalyticResultImpl();
task.setResult(result);
this.task = task;
Thread thread = new Thread(this.task);
thread.start();
return result;
}
public OutputData endRun(AnalyticResult result) {
while (!result.beFinished()) {
result.wait();
}
return task.getOutputData();
}
}
TaskScheduler引入AnalyticResult对象的目的是为了判断任务执行的状态。它封装了当前的线程,从而可以获得当前线程的状态,并能够操作线程。
public interface AnalyticResult {
public void setCurrentThread(Thread thread);
public boolean isFinished();
public void finish();
public void wait();
}
DataAnalyzer的内部封装了对TaskScheduler对象的调用:
public class DataAnalyzer {
private TaskScheduler scheduler = new TaskScheduler();
public AnalyticResult analysis(InputData input) {
Task task = createTask(input);
return scheduler.beginRun(task);
}
public OutputData output(AnalyticResult result) {
return scheduler.endRun(result);
}
}
Task任务对象负责完成分析任务。虽然在之前的分析中,我们认为DataAnalyzer对象承担了分析数据的职责。不过,这一职责仅就调用者而言是可行的,对于DataAnalyzer的内部实现则不然。这基于两种原因。其一是分析过程的复杂程度,它需要对输入数据进行多个步骤的处理,包含数据的转换、过滤、运算以及存储。如果让DataAnalyzer一力承担,则可能导致职责过重,形成一个庞大的复杂对象。这既有碍于代码的阅读性,也不利于处理过程或算法的重用。其二是分析过程的可变性。根据不同的输入数据,需要不同的分析算法。DataAnalyzer不应该负责对算法的决策与选择。而且,随着需求的变化,数据分析器可能需要扩展。将任务抽象出来,可以很好地应对变化。
引入Task对象可以将DataAnalyzer从繁重的分析任务中解放出来,同时又能够保证它对分析任务的封装,是很好的对象协作表现。由于Task对象需要支持多线程,我们还需要它实现Runnable接口。至于ExecutionCallback是一个回调对象,注册该对象可以在任务执行完毕后调用它:
public interface Task extends Runnable {
public InputData getInputData();
public void setInputData(InputData input);
public OutputData getOutputData();
public void setOutputData(OutputData output);
public OutputData execute(InputData input);
public void setResult(AnalyticResult result);
public ExecutionCallback getCallback();
public void setCallback(ExecutionCallback callback);
}
public interface ExecutionCallback {
public void callback();
}
例如分析销售量波动的任务:
public class SaleWaveTask implements Task {
private InputData inputData;
private OutputData outputData;
private AnalyticResult result;
public OutputData execute(InputData input) {
inputData = input;
//分析输入数据,获得销售量波动的情况
result.finished();
if (getCallback() != null) {
getCallback().callback();
}
return output;
}
public void run() {
this.setOutputData(execute(getInputData());
}
public ExecutionCallback getCallback() {
return new ExecutionCallback() {
public void callback() {
System.out.println(
"The sale's wave task is completed.");
}
}
}
}
之所以要在Task中为InputData和OutputData定义get、set访问器,是因为Runnable接口提供的run()方法不允许传入参数和返回结果。而在TaskScheduler中又只接受Task对象,利用这些方法可以在Task中存储和传递这些数据。
在DataAnalyzer类的定义中,不能忽略的另一个职责是Task对象的创建。虽然,DataAnalyzer的调用者也可以完成对Task对象的创建,并将创建好的对象传递给DataAnalyzer。然而,更好的做法是DataAnalyzer能够根据传入的InputData来决定创建哪一种Task对象。这样可以减轻调用者的负担。然而,一旦这样定义,DataAnalyzer的职责就显得混淆不清了。一方面它负责执行分析任务,另一方面又要承担创建Task对象的职责。这意味着它既是Task对象的使用者,又是Task对象的创建者。此外,DataAnalyzer对象并没有持有创建Task对象所必须的数据,违背了将数据与行为封装在一起的原则。因此,我们应该将创建的职责委派给其他对象。还有什么对象比得上“工厂专家”更适宜做创建的工作呢?最简单的做法是定义一个静态工厂:
public class TaskFactory {
public static Task create(InputData input) {
Task task = null;
if (input.getType().equals(SALE_WAVE)) {
task = new SaleWaveTask();
task.setInputData(input);
}
return task;
}
}
为了更好地应对任务的变化,我们也可以引入配置文件来管理任务。Task对象的创建因配置信息的不同而变化。create()方法能够读取配置文件,然后根据配置信息来决定创建哪一种Task对象。
数据的分析任务事实上是按照一定的步骤来完成的。这些步骤可能会采取不同的顺序或组合方式来执行。每一种执行步骤就是一个算法,完成数据的收集、筛选、计算、分析和存储。Task对象负责将这些步骤整合起来,并封装到接口中,将这些算法实现以及执行算法的顺序隐藏起来。为此,我们可以定义一个实现了Task接口的抽象类,统一完成整合分析步骤的工作。
public abstract class AbstractTask implements Task {
protected AnalyticResult result;
protected List<Algorithm> algos;
public void registerAlgorithm(Algorithm algo) {
algos.add(algo);
}
public OutputData execute(InputData input) {
AnalyticData data = new AnalyticData(input,null);
processAlgorithms(data);
afterExecute();
return data.getOutputData();
}
protected void processAlgorithms(AnalyticData data) {
for (Algorithm algo : algos) {
algo.process(data);
}
}
private void afterExecute() {
result.finished();
if (getCallback() != null) {
getCallback().callback();
}
}
public void run() {
this.setOutputData(execute(getInputData());
}
}
public class AnalyticData {
public AnalyticData(InputData input, OutputData output) {}
public InputData getInputData() {}
public void setInputData(InputData input) {}
public OutputData getOutputData() {}
public void setOutputData(OutputData output) {}
public void prepare() {}
public void complete() {}
}
任务的执行步骤被抽象为Algorithm接口,而AnalyticData则用于协调输入数据和输出数据,因为Algorithm对象在Task中形成了一条流水线,上一个Algorithm的输出是下一个Algorithm的输入。输入数据和输出数据形成了一种不可协调的共扼性,需要提供prepare()方法和complet()方法来完成二者之间的转换。
public interface Algorithm {
public void process(AnalyticData data);
}
public abstract class AbstractAlgorithm implements Algorithm {
public void process(AnalyticData data) {
data.prepare();
process(data.getInputData(), data.getOutputData());
data.complete();
}
protected abstract void process(
InputData input, OutputData output);
}
我们发现Task与AnalyticData都重复定义了InputData与OutputData的get、set方法。在设计和实现时,必须避免这样的重复代码。我们发现,这样的输入输出数据体现了数据池的概念,因此可以定义一个抽象的DataSink接口:
public interface DataSink {
public InputData getInputData() {}
public void setInputData(InputData input) {}
public OutputData getOutputData() {}
public void setOutputData(OutputData output) {}
}
AnalyticData类实现了该接口,而Task则可以持有该对象。
public class AnalyticData implements DataSink {}
public interface Task extends Runnable {
public void setDataSink(DataSink dataSink);
public OutputData execute(InputData input);
public void setResult(AnalyticResult result);
public ExecutionCallback getCallback();
public void setCallback(ExecutionCallback callback);
}
public abstract class AbstractTask implements Task {
protected DataSink dataSink;
protected AnalyticResult result;
protected List<Algorithm> algos;
public OutputData execute(InputData input) {
processAlgorithms(dataSink);
afterExecute();
return dataSink.getOutputData();
}
protected void processAlgorithms(AnalyticData data) {
for (Algorithm algo : algos) {
algo.process(data);
}
}
public void run() {
execute(dataSink.getInputData());
}
}
传递InputData对象的方式也发生了变化:
public class TaskFactory {
public static Task create(InputData input) {
Task task = null;
DataSink dataSink = new AnalyticData(input, null);
if (input.getType().equals(SALE_WAVE)) {
task = new SaleWaveTask();
task.setDataSink(dataSink);
}
return task;
}
}
通过对分析器职责的分析,我们引入了DataAnalyzer、TaskScheduler、Task以及Algorithm等对象。这些对象的协作顺序如下图所示:
DataAnalyzer相当于分析器的外观,它总揽全局,管理着各种对象之间的协作,共同实现分析工作。TaskScheduler是任务的调度器,负责启动任务和结束任务,而它主要的职责则是封装了对多线程的处理,用以完成任务的异步调用。如果未来需求需要强化任务的调度模式,例如增加任务队列,以调度和管理多个任务的执行,则可以修改TaskScheduler而不影响它的调用者。Task对象体现了任务的独立性,同时又利用抽象统一了任务的执行方式,有利于任务的扩展。Algorithm则完成对任务的切分,将任务步骤单独封装,有利于各种算法的重用。这些对象的协作以一种层层委派的方式,实现了职责的分离,避免了“集权式”的对象。不同的职责可以分别演化,又能很好地协作,共同完成数据分析的整体职责。