Clojure
设计模式
Design Patterns
译自 Clojure Design Patterns
Author: Mykhailo Kozik
快速 了解如何在 Clojure 中运用最基本的设计模式
免责声明: 绝大多数模式非常易于实现,因为我们可以使用动态类型、函数式和……嗯,和 Clojure。 文中的个别模式实现看起来很烂。好吧我承认,这里写的每个字儿都有可能出错,有时候老司机也难免翻车。
(译注:译者水平有限,使用本文代码所导致的损失概不负责。
如果你在文中发现错误,或有任何意见或建议,请直接在文后留言。2333)
引子
我们所使用的语言本身已经完犊子了。所以我们搞出来一个叫设计模式的东西。
--- 尼古拉斯・赵四
这里有两位谦逊的程序猿 --- Pedro Veel 和 Eve Dopler 正在运用设计模式,来着手解决一些通用而常见的软件工程问题。
第一集:命令
IT 外包商大佬 "Serpent Hill & R.E.E" 新接了一个来自美国的大单子。这次的首要交付任务是完成该品牌官网的用户注册、登录、注销功能。
Pedro: 这还不简单,只需要搞一个像这样的 Command 接口……
interface Command {
void execute();
}
Pedro: 然后让每个具体的功能去实现这个接口,定义自己的 execute
行为。
public class LoginCommand implements Command {
private String user;
private String password;
public LoginCommand(String user, String password) {
this.user = user;
this.password = password;
}
@Override
public void execute() {
DB.login(user, password);
}
}
public class LogoutCommand implements Command {
private String user;
public LogoutCommand(String user) {
this.user = user;
}
@Override
public void execute() {
DB.logout(user);
}
}
Pedro: 用起来也很容易。
(new LoginCommand("django", "unCh@1ned")).execute();
(new LogoutCommand("django")).execute();
Pedro: Eve 你瞅瞅咋样?
Eve: 为啥你费那么大老劲在 LoginCommand
里面包裹了一层,为啥不直接调用 DB.login
?
Pedro: 这个包裹可重要了,因为这样就可以用同一个方法来操作任意实现 Command
的对象了。
Eve: 有啥子用呢?
Pedro: 延时调用、登陆、历史跟踪、缓存……等等一火车应用。
Eve: 好吧,那你看这样搞行么?
(defn execute [command]
(command))
(execute #(db/login "django" "unCh@1ned"))
(execute #(db/logout "django"))
Pedro: 这™是什么乱七八糟的东西?
Eve: 给出一个 Java 近似版本。
new SomeInterfaceWithOneMethod() {
@Override
public void execute() {
// do
}
};
Pedro: 和 Command
接口差不多一个意思嘛……
Eve: 还有可以有一个你想要的不太“混乱”的版本。
(defn execute [command & args]
(apply command args))
(execute db/login "django" "unCh@1ned")
Pedro: 那你怎么延后方法执行做延时调用呢?
(译注:延时调用指的是先进行参数设置,最后再调用方法的意思么?求教。)
Eve: 你自己琢磨一下。当你需要函数执行的时候,只需准备好什么呢?
Pedro: 方法名……
Eve: 还有?
Pedro: ……参数。
Eve: Bingo。你只需记得 (函数名, 参数列表) ,就可以在任何你需要的位置像这样来让函数执行 (apply function-name arguments)
。
Pedro: 嗯…… 看起来好像挺简单的样子。
Eve: 那可不, 命令模式只需一个函数而已。
第二集:策略
Sven Tori 花大价钱雇人制作一张用户表单。但是有几个要求,用户必须按照姓名排序,而且呢,会员用户必须排在所有普通用户之前。这不废话么,因为人家掏钱了。倒序排序依然要保持付费用户在上面。
Pedro: 蛤,自定义一个比较器,再调用一下 Collections.sort(users, comparator)
就能搞定了。
Eve: 那要怎么搞才能实现 自定义比较器 呢?
Pedro: 首先要实现 Comparator
接口、实现 compare(Object o1, Object o2)
方法。然后反序比较器 ReverseComparator
也需要类似的步骤来搞一哈。
Eve: 停!憋说话给我看代码!
class SubsComparator implements Comparator {
@Override
public int compare(User u1, User u2) {
if (u1.isSubscription() == u2.isSubscription()) {
return u1.getName().compareTo(u2.getName());
} else if (u1.isSubscription()) {
return -1;
} else {
return 1;
}
}
}
class ReverseSubsComparator implements Comparator {
@Override
public int compare(User u1, User u2) {
if (u1.isSubscription() == u2.isSubscription()) {
return u2.getName().compareTo(u1.getName());
} else if (u1.isSubscription()) {
return -1;
} else {
return 1;
}
}
}
// forward sort
Collections.sort(users, new SubsComparator());
// reverse sort
Collections.sort(users, new ReverseSubsComparator());
Pedro: 你能搞一个类似的功能出来么?
Eve: 当然,差不多像是这样。
(sort (comparator
(fn [u1 u2]
(cond
(= (:subscription u1) (:subscription u2))
(neg? (compare (:name u1) (:name u2)))
(:subscription u1)
true
:else
false)))
users)
Pedro: 和我写的挺像的。
Eve: 不过我还有一个改进版。
;; forward sort
(sort-by (juxt (complement :subscription) :name) users)
;; reverse sort
(sort-by (juxt :subscription :name) #(compare %2 %1) users)
Pedro: 哦我的⑦舅老爷哦,这什么可怕的一行代码。
Eve: 函数,你懂的。
Pedro: 管他什么鬼,总之这也太难理解了吧。
Eve 正在解释 juxt、complement 和 sort-by 函数的功能
10 分钟后
Pedro: 这真的是一种非常玄学的策略模式实现。
Eve: 反正对我来说,实现策略模式只需 函数传递与组合。
第三集:状态
销售员 Karmen Git 调查了市场情况之后,决定要给不同用户提供专属功能。
Pedro: 很合理的需求嘛。
Eve: 我们来仔细研究一下。
- 如果是用户是付费用户,则可以看到所有的消息记录。
- 普通用户则只能看到最近的 10 条消息。
- 如果用户进行了充值,要记录用户当前总余额。
- 如果普通用户当前总余额已经足够购买会员,那就让他(自动)升级为……
Pedro: 状态!这模式特别带劲。首先我们要搞一个表示用户状态的枚举。
public enum UserState {
SUBSCRIPTION(Integer.MAX_VALUE),
NO_SUBSCRIPTION(10);
private int newsLimit;
UserState(int newsLimit) {
this.newsLimit = newsLimit;
}
public int getNewsLimit() {
return newsLimit;
}
}
Pedro: 接下来是写用户逻辑部分。
public class User {
private int money = 0;
private UserState state = UserState.NO_SUBSCRIPTION;
private final static int SUBSCRIPTION_COST = 30;
public List newsFeed() {
return DB.getNews(state.getNewsLimit());
}
public void pay(int money) {
this.money += money;
if (state == UserState.NO_SUBSCRIPTION
&& this.money >= SUBSCRIPTION_COST) {
// buy subscription
state = UserState.SUBSCRIPTION;
this.money -= SUBSCRIPTION_COST;
}
}
}
Pedro: 开始调用吧。
User user = new User(); // create default user
user.newsFeed(); // show him top 10 news
user.pay(10); // balance changed, not enough for subs
user.newsFeed(); // still top 10
user.pay(25); // balance enough to apply subscription
user.newsFeed(); // show him all news
Eve: 你就是把有关于那些值的逻辑藏在 User
类里面而已。我们可以直接像这样使用策略模式啊 user.newsFeed(subscriptionType)
。
Pedro: 同意。状态和策略非常相似。甚至连它俩的 UML 表示形式都是一样的。 但是我们把余额信息封装了起来,这样用户接触不到啊。
Eve: 我觉得用另一套方案也能实现相同的功能。无需显式地说明使用哪个策略,而是可以依据某些状态来决定所使用的策略。在 Clojure 里面,这东西和策略模式做的事儿差不多。
Pedro: 但是(在状态模式里)如果成功调用,是可以改变对象的状态哦。
Eve: 话是这样没错,不过这和是不是策略模式没啥关系吧,只是细节实现有些不同而已。
Pedro: 话说你刚才说的 "另一种方案" 是个啥子?
Eve: 多重方法。
Pedro: 多重 啥子?
Eve: 看这个:
(defmulti news-feed :user-state)
(defmethod news-feed :subscription [user]
(db/news-feed))
(defmethod news-feed :no-subscription [user]
(take 10 (db/news-feed)))
Eve: 这里 pay
函数的任务就是改变对象的状态。虽然 Clojure 不喜欢修改对象的状态,但是非要改的话还是可以的。
(def user (atom {:name "Jackie Brown"
:balance 0
:user-state :no-subscription}))
(def ^:const SUBSCRIPTION_COST 30)
(defn pay [user amount]
(swap! user update-in [:balance] + amount)
(when (and (>= (:balance @user) SUBSCRIPTION_COST)
(= :no-subscription (:user-state @user)))
(swap! user assoc :user-state :subscription)
(swap! user update-in [:balance] - SUBSCRIPTION_COST)))
(news-feed @user) ;; top 10
(pay user 10)
(news-feed @user) ;; top 10
(pay user 25)
(news-feed @user) ;; all news
Pedro: 使用多重方法来转发,比使用枚举更好么?
Eve: 也许在上面的例子中并不是,不过通常来说用多重方法更好。
Pedro: 为啥,给解释一下。
Eve: 你知道啥是 双重分派 么?
(译注:双重分派* 原文为 double dispatch,也译为双重转发,双重分发。)*
Pedro: 不造啊。
Eve: 好吧,讲到访问者模式的时候再说吧。
第四集:访问者
Natanius S. Selbys 想要搞一个可以让用户以不同格式导出他们的消息、活动和成就的功能。
Eve: 所以这次你又有什么计划?
Pedro: 我们可以先整一个 item
类型,包括 (消息,活动)
,然后再给文件格式比如 (PDF, XML)
搞一套。
(译注:这里 item
类型没有提到“成就”,可能是原作者遗落了。)
abstract class Format { }
class PDF extends Format { }
class XML extends Format { }
public abstract class Item {
void export(Format f) {
throw new UnknownFormatException(f);
}
abstract void export(PDF pdf);
abstract void export(XML xml);
}
class Message extends Item {
@Override
void export(PDF f) {
PDFExporter.export(this);
}
@Override
void export(XML xml) {
XMLExporter.export(this);
}
}
class Activity extends Item {
@Override
void export(PDF pdf) {
PDFExporter.export(this);
}
@Override
void export(XML xml) {
XMLExporter.export(this);
}
}
Pedro: 大功告成。
Eve: 还不错,不过你怎么处理参数类型的分发呢?
Pedro: 啥子意思?
Eve: 瞅一下这样一段代码:
Item i = new Activity();
Format f = new PDF();
i.export(f);
Pedro: 没瞅出来啥毛病啊。
Eve: 其实,如果执行这段代码会产生 UnknownFormatException
。
Pedro: 蛤?真的?!
Eve: 在 Java 里只有 单一分派。这也就是说,如果你调用 i.export(f)
,只有 i
能被分派到具体实现类,而 f
无法被找到具体的实现类别。
(译注1:C++ / Java / C# 等都只支持单一分派,也就是 i
的分派,也就是我们熟悉的 多态 概念。如在上面的例子中,选择使用哪个 export
方法,取决于 i
的运行时类型。而双重分派不仅根据 i
的运行时类型,同时还取决于参数 f
的运行时类型。访问者模式实际上提供了对于支持单分派语言的双分派策略。)
(译注2:关于单一分派、双重分派与访问者模式的更多细节可以阅读一下这篇文章。)
Pedro: 我懵逼了。所以你的意思是说,这里没有根据参数类型进行分派?
Eve: 这时候就需要祭出访问者模式了。在依据 i
分派之后,紧接着手工使用 f.someMethod(i)
进行 f
的分派。
Pedro: 代码长啥样给看看呗。
Eve: 你需要在 Visitor
里给每一种类型都定义自己的导出操作。
public interface Visitor {
void visit(Activity a);
void visit(Message m);
}
public class PDFVisitor implements Visitor {
@Override
public void visit(Activity a) {
PDFExporter.export(a);
}
@Override
public void visit(Message m) {
PDFExporter.export(m);
}
}
Eve: 改造一下刚才的 Item
让它可以接收各种 Visitor
实现。
public abstract class Item {
abstract void accept(Visitor v);
}
class Message extends Item {
@Override
void accept(Visitor v) {
v.visit(this);
}
}
class Activity extends Item {
@Override
void accept(Visitor v) {
v.visit(this);
}
}
Eve: 像这样调用就可以了。
Item i = new Message();
Visitor v = new PDFVisitor();
i.accept(v);
Eve: 运转良好。你甚至无需修改 Message
和 Activity
的代码,就可以增加新的导出格式。只需增加新的访问者即可。
Pedro: 这玩意儿挺实用的。就是实现起来有点复杂啊。 用 Clojure 实现这个是不是也很复杂啊?
Eve: 并不复杂。因为 Clojure 使用多重方法来原生支持双重分派。
Pedro: 多重 啥子 ?
Eve: 不解释,看代码……首先我们定义一个分派函数。
(译注:分派 原文 dispatcher,也译为 转发。)
(defmulti export (fn [item format] [(:type item) format]))
Eve: 它依据接受的 item
和 format
进行分派。item
和 format
的格式如下:
;; Message
{:type :message :content "Say what again!"}
;; Activity
{:type :activity :content "Quoting Ezekiel 25:17"}
;; Formats
:pdf, :xml
Eve: 现在你只需提供一系列函数,来接收各种不同的分派,分派器会自动决定使用最合适的函数进行处理。
(defmethod export [:activity :pdf] [item format]
(exporter/activity->pdf item))
(defmethod export [:activity :xml] [item format]
(exporter/activity->xml item))
(defmethod export [:message :pdf] [item format]
(exporter/message->pdf item))
(defmethod export [:message :xml] [item format]
(exporter/message->xml item))
Pedro: 如果遇见未知的导出格式该怎么进行处理呢?
Eve: 我们可以定义默认情况下的分派处理。
(defmethod export :default [item format]
(throw (IllegalArgumentException. "not supported")))
Pedro: 好吧,但是 :pdf
和 :xml
没有层次继承什么之类的关系啊。就只是关键字而已?
Eve: 答对了,简单问题简单处理嘛。如果你的确需要高级特性,可以使用专设层级 或者依据 class
进行转发。
(译注:专设层级 原文 adhoc hierarchies。暂未发现准确的翻译。)
(derive ::pdf ::format)
(derive ::xml ::format)
Pedro: 双重冒号?!
Eve: 你就假装它就是个关键字。
Pedro: 好吧就当是吧。
Eve: 接下来就可以把接收分派的类型换成 ::pdf
、::xml
或者 ::format
了。
(defmethod export [:activity ::pdf])
(defmethod export [:activity ::xml])
(defmethod export [:activity ::format])
Eve: 如果系统中出现了新的格式(比如 csv):
(derive ::csv ::format)
Eve: 接受 ::csv
的函数还没有出现时,:csv
会被分派到接受 ::format
的函数那里。
Pedro: 看起来挺棒的。
Eve: 那可不,简单多了。
Pedro: 所以也就是说,如果语言本身支持多重分派,就无需访问者模式?
Eve: 完全正确。
第五集:模板方法
MMORPG 游戏 机械多米诺尔大战撒加 需要给 VIP 玩家们(单独)调整电脑难度。破坏平衡性。
Pedro: 首先,我们要搞清楚自动机器角色都应该有些什么行为。
Eve: 你以前玩过 RPG 游戏没?
Pedro: 很庆幸,没。
Eve: 我类乖……来,让你长长见识……
两星期后
Pedro: ……我去,我刚找到一把 +100 攻击力的史诗级大保健大宝剑。
Eve: 这么叼。不过……该起来干活了。
Pedro: 好啦好啦,淡定,这还不手到擒来。我们应该实现这些事件:
- 战斗
- 探索
- 开宝箱
Pedro: 不同的人物会根据不同的事件作出不同的行为,比如法师在战斗中喜欢使用远程法术,但是盗贼更偏爱安静地进行近战刺杀;绝大多数玩家对上锁的箱子束手无策,但是盗贼却可以打开它们,等等……
Eve: 看起来 模板方法 是个合适的选择?
Pedro: 嗯呢。我们先定义抽象的规则,然后在子类里实现不同的具体方法。
public abstract class Character {
void moveTo(Location loc) {
if (loc.isQuestAvailable()) {
Journal.addQuest(loc.getQuest());
} else if (loc.containsChest()) {
handleChest(loc.getChest());
} else if (loc.hasEnemies()) {
attack(loc.getEnemies());
}
moveTo(loc.getNextLocation());
}
private void handleChest(Chest chest) {
if (!chest.isLocked()) {
chest.open();
} else {
handleLockedChest(chest);
}
}
abstract void handleLockedChest(Chest chest);
abstract void attack(List enemies);
}
Pedro: 我们已经分离出了 Character
类中所有通用的方法,提供给所有的角色。接下来就可以创造子类了,定义属于他们自己的行为以适应具体情景。在我们的游戏里面就是:处理上锁的箱子 和 攻击敌人。
Eve: 那我们先来写一个法师类吧。
Pedro: 法师?好的。首先他不能打开上锁的箱子,所以重写的方法里面啥都不干 就行了。然后是攻击模式,如果遇见十个以上的敌人,就施放冰冻术把他们全冻住,然后开传送逃跑。如果遇见十个或者更少的敌人,就对敌人依次使用火球术。
public class MageCharacter extends Character {
@Override
void handleLockedChest(Chest chest) {
// do nothing
}
@Override
void attack(List enemies) {
if (enemies.size() > 10) {
castSpell("Freeze Nova");
castSpell("Teleport");
} else {
for (Enemy e : enemies) {
castSpell("Fireball", e);
}
}
}
}
Eve: 感觉很不错,盗贼类应该怎么写呢?
Pedro: 同样很容易,盗贼可以开锁,然后攻击偏好是近距离暗杀,一个一个地做掉敌人。
public class RogueCharacter extends Character {
@Override
void handleLockedChest(Chest chest) {
chest.unlock();
}
@Override
void attack(List enemies) {
for (Enemy e : enemies) {
invisibility();
attack("backstab", e);
}
}
}
Eve: 做的不错。但是这个东西和策略模式有啥区别呢?
Pedro: 啥子意思?
Eve: 我的意思是说,你用子类来重新定义行为,但是策略模式也是重新定义行为啊,只不过是使用函数来实现的。
Pedro: 那个,那个的确是另一种实现方式。
Eve: 同理,状态模式也是另一种实现方式咯。
Pedro: 你想表达什么?
Eve: 明明是同一类问题,你却使用了不同的方式去解决。
Pedro: 那 Clojure 里是怎么用策略模式来解决这个游戏角色问题的?
Eve: 只需要通过给每个角色搞一些专属函数。你看,你写的抽象 move
就会变成像这个样子:
(defn move-to [character location]
(cond
(quest? location)
(journal/add-quest (:quest location))
(chest? location)
(handle-chest (:chest location))
(enemies? location)
(attack (:enemies location)))
(move-to character (:next-location location)))
Eve: 角色需要实现函数 handle-chest
和 attack
,然后把这两个函数作为参数传递给 move-to
。
;; Mage-specific actions
(defn mage-handle-chest [chest])
(defn mage-attack [enemies]
(if (> (count enemies) 10)
(do (cast-spell "Freeze Nova")
(cast-spell "Teleport"))
;; otherwise
(doseq [e enemies]
(cast-spell "Fireball" e))))
;; Signature of move-to will change to
(defn move-to [character location
& {:keys [handle-chest attack]
:or {handle-chest (fn [chest])
attack (fn [enemies] (run-away))}}]
;; previous implementation
)
Pedro: 我的太上老君呐。这发生了什么?我要报警了。
Eve: 就是改了一下 move-to
所接受的参数啊,这样就可以接受 handle-chest
和 attack
函数了。
而且他们只是可选参数。
(move-to character location
:handle-chest mage-handle-chest
:attack mage-attack)
Eve: 这里要提一下,如果没有传进来这些函数的时候,会自动使用我们提供的默认值:handle-chest
里面什么也不做,然后 attack
里面写的是,见了敌人就跑。
Pedro: 好吧,但是好像用子类继承更好一些吧?你看你这多次调用 move-to
的时候就会产生很多重复的代码。
Eve: 这个可以改进,比如给它起个名,把它定义成一个函数。
(defn mage-move [character location]
(move-to character location
:handle-chest mage-handle-chest
:attack mage-attack))
Eve: 用多重方法也行,这样更强大一些。
(defmulti move
(fn [character location] (:class character)))
(defmethod move :mage [character location]
(move-to character location
:handle-chest mage-handle-chest
:attack mage-attack))
Pedro: 我明白了,但是你为啥觉得这样比使用子类继承更好呢?
Eve: 因为这样可以动态的改变他们的行为。假设你的法师魔法值耗尽了,就别扔火球了,他大可以开一个传送门逃跑,只需提供一个新的函数就能实现了。
Pedro: 说的对啊。函数随处可用。
第六集:迭代器
技术顾问 Kent Podiololis 正在吐槽 C 语言风格的循环。
“活在 1980 年还是咋地?” --- Kent
Pedro: 肯定要用 Java 里面的迭代器模式啊。
Eve: 别犯傻了,根本没人用 java.util.Iterator
。
Pedro: 但是大家都在 for-each
循环里隐式地使用它啊。用它来遍历容器感觉特别爽。
Eve: “遍历容器 ”是个什么意思?
Pedro: 专业点来说就是,一个容器需要提供这两个方法:
next()
,用来返回下一个元素。hasNext()
,如果容器中还存在元素就返回真。
Eve: 那个,你知道啥是链表么?
Pedro: 你说单链表?
Eve: 是的,单链表。
Pedro: 肯定知道啊。它也算一种容器,是由一系列节点组成的。每个节点包括数据部分和指向下一个节点的部分。如果是最后一个节点,那么它下个节点就是 null。
Eve: 很懂行啊。那你给我说说遍历链表和使用迭代器遍历有啥区别?
Pedro: 呃……
Pedro 写了两段遍历的代码:
- 使用迭代器遍历
Iterator i;
while (i.hasNext()) {
i.next();
}
- 遍历链表
Node next = root;
while (next != null) {
next = next.next;
}
Pedro: 你别说还真是挺像的……那 Clojure 里面有什么类似 Iterator
的东西么?
Eve: seq
函数。
(seq [1 2 3]) => (1 2 3)
(seq (list 4 5 6)) => (4 5 6)
(seq #{7 8 9}) => (7 8 9)
(seq (int-array 3)) => (0 0 0)
(seq "abc") => (\a \b \c)
Pedro: 它返回了一个列表……
Eve: 准确来说是 序列,因为(在 Clojure 里) 序列代替了迭代器。
Pedro: seq
可以操作自定义数据结构么?
Eve: 实现 clojure.lang.Seqable
接口就可以了:
(deftype RedGreenBlackTree [& elems]
clojure.lang.Seqable
(seq [self]
;; traverse element in needed order
))
Pedro: 好吧好吧。但是我听说迭代器通常用来实现惰性,比如等到 getNext()
被调用的时候才会进行求值,用列表能解决这类问题么?
Eve: 能啊,Clojure 里管它叫“惰性序列 ”。
(def natural-numbers (iterate inc 1))
Eve: 我们刚才定义了一个表示 全体 自然数的东西,但是并没有 OutOfMemory
,因为我们还没有从里面取任何值,它是惰性的。
(译注:0 是否属于自然数仍有争议。目前国际标准和中国国家标准都把 0 算作自然数。)
Pedro: 能仔细解释一下么?
Eve: 对不起哦,我好像也 “惰性” 起来了。(跑咯)
Pedro: 你给我等着我记住你了!
第七集:备忘录
一位名叫 Chad Bogue 的用户丢失了他已经写了两天的消息。给他一个保存按钮吧。
Pedro: 我简直不敢相信有人会在那个输入框里面打字打了两天,整整两天!
Eve: 让我们来拯救 他吧。
(译注:拯救 原文 save,有保存之意。双关。)
Pedro: 我刚才在 Google [1] 上查了一下。实现保存按钮的通常做法是使用备忘录模式。 需要三个东西,创作者 (originator),管理者 (caretaker),备忘录 (memento)。
Eve: 这些都是干啥用的?
Pedro: 创作者 就是我们需要保存的对象或者状态(例如输入框里面的文本就是创作者),管理者 的功能就是保存需要保存的状态(例如那个保存按钮就是管理者),最后 备忘录 就是用来存储状态的对象。
public class TextBox {
// state for memento
private String text = "";
// state not handled by memento
private int width = 100;
private Color textColor = Color.BLACK;
public void type(String s) {
text += s;
}
public Memento save() {
return new Memento(text);
}
public void restore(Memento m) {
this.text = m.getText();
}
@Override
public String toString() {
return "[" + text + "]";
}
}
Pedro: 备忘录是一个不可变的对象。
public final class Memento {
private final String text;
public Memento(String text) {
this.text = text;
}
public String getText() {
return text;
}
}
Pedro: 管家就是这样一段代码:
// open browser, init empty textbox
TextBox textbox = new TextBox();
// type something into it
textbox.type("Dear, Madonna\n");
textbox.type("Let me tell you what ");
// press button save
Memento checkpoint1 = textbox.save();
// type again
textbox.type("song 'Like A Virgin' is about. ");
textbox.type("It's all about a girl...");
// suddenly browser crashed, restart it, reinit textbox
textbox = new TextBox();
// but it's empty! All work is gone!
// not really, you rollback to last checkpoint
textbox.restore(checkpoint1);
Pedro: 这里要提个醒,如果你想要保存多次记录,那就建立一个备忘录列表。
Eve: 作家, 管家, 备忘 - 这么些专业词汇,其实本质上是为了实现 save
和 restore
这两个功能。
(def textbox (atom {}))
(defn init-textbox []
(reset! textbox {:text ""
:color :BLACK
:width 100}))
(def memento (atom nil))
(defn type-text [text]
(swap! textbox
(fn [m]
(update-in m [:text] (fn [s] (str s text))))))
(defn save []
(reset! memento (:text @textbox)))
(defn restore []
(swap! textbox assoc :text @memento))
Eve: 这是测试代码:
(init-textbox)
(type-text "'Like A Virgin' ")
(type-text "it's not about this sensitive girl ")
(save)
(type-text "who meets nice fella")
;; crash
(init-textbox)
(restore)
Pedro: 这和我的写的基本上差不多啊。
Eve: 但是你必须小心备忘录的不变性。
Pedro: 啥子意思?
Eve: 幸好这个例子里使用的是 String 类型,String 是不可变的。但是如果你还有一些内部状态可能会发生改变的对象,你就必须对这些备忘录对象进行深层克隆了。
Pedro: 哦,谢谢提醒。所以这里还需要对得到的原型递归使用 clone()
方法。
Eve: 过一会儿我们就会见到原型模式,但是一定要搞清楚,备忘录模式 的本质不是 管理者 和 创作者,而是 保存 和 恢复。
第八集:原型
经过分析之后 Dex Ringeus 发现,用户并不喜欢填写登记表。要想办法提升易用性才行。
Pedro: 所以,那个登记表问题出在哪里?
Eve: 因为烦人的表项实在是太多了啊。
Pedro: 比如说?
Eve: 比如说,体重
。这项吓跑了 90% 的女性用户。
Pedro: 但是这项对我们的分析系统来说很重要啊,推荐食品和衣服的时候要用到这一项。
Eve: 那就,把它改成非必填项吧,如果用户没有填这一项,就随便取个默认值。
Pedro: 60 千克
咋样。
Eve: 行。
Pedro: 好的,给我两分钟。
两小时后
Pedro: 我建议先建立一张*原型 *登记表,所有表项都预先填上默认值。等用户把内容填进来的时候我们再去改这些值。
Eve: 不错的建议。
Pedro: 这里就是我们的标准注册表原型了,它实现了 clone()
方法:
public class RegistrationForm implements Cloneable {
private String name = "Zed";
private String email = "[email protected]";
private Date dateOfBirth = new Date(1970, 1, 1);
private int weight = 60;
private Gender gender = Gender.MALE;
private Status status = Status.SINGLE;
private List children = Arrays.asList(new Child(Gender.FEMALE));
private double monthSalary = 1000;
private List favouriteBrands = Arrays.asList("Adidas", "GAP");
// few hundreds more properties
@Override
protected RegistrationForm clone() throws CloneNotSupportedException {
RegistrationForm prototyped = new RegistrationForm();
prototyped.name = name;
prototyped.email = email;
prototyped.dateOfBirth = (Date)dateOfBirth.clone();
prototyped.weight = weight;
prototyped.status = status;
List childrenCopy = new ArrayList();
for (Child c : children) {
childrenCopy.add(c.clone());
}
prototyped.children = childrenCopy;
prototyped.monthSalary = monthSalary;
List brandsCopy = new ArrayList();
for (String s : favouriteBrands) {
brandsCopy.add(s);
}
prototyped.favouriteBrands = brandsCopy;
return prototyped;
}
}
Pedro: 需要创建一个新表格的时候,调用 clone()
就可以得到一个同样的表格了,然后就可以对这个新表格进行修改了。
Eve: 哎呦我去太吓人了!在可变的世界里,想复制一个对象就必须依赖 clone()
方法。之所以吓人就在于复制必须要足够深,也就是说,如果你想复制一个引用,就必须递归地调用 clone()
,万一引用对象不支持 clone()
……
Pedro: 这个模式就是用来解决这个问题的。
Eve: 我不觉得每次加入新对象都必须费老劲实现新对象的 clone 方法是一个很好的解决方案。
Pedro: 那 Clojure 有啥灵丹妙药么?
Eve: Clojure 的数据结构是不可变的。就这样。
Pedro: 这样就能解决原型问题了?
Eve: 每次修改数据,你都会得到一个全新的不可变的原数据的拷贝,原来的数据不会发生任何变化。 不可变数据类型的世界里不需要原型模式。
(def registration-prototype
{:name "Zed"
:email "[email protected]"
:date-of-birth "1970-01-01"
:weight 60
:gender :male
:status :single
:children [{:gender :female}]
:month-salary 1000
:brands ["Adidas" "GAP"]})
;; return new object
(assoc registration-prototype
:name "Mia Vallace"
:email "[email protected]"
:weight 52
:gender :female
:month-salary 0)
Pedro: 厉害了!但是这东西性能咋样?复制上百万行的数据也要返回一个全新的?好像要消耗巨大的运算资源啊。
Eve: 并不是你想的那样。你可以去搜一下可持久化数据结构 (persistent data structures) 和 结构共享 (structural sharing) 的相关资料。
Pedro: 谢了啊。
第九集:中介者
最近,公司对当前代码库进行了外部代码审查,暴露出许多问题。Veerco Wierde 强调,这个聊天应用耦合度太高。
Eve: 耦合度太高是啥意思。
Pedro: 就是说,对象彼此之间的关系太过紧密。对象之间彼此知道的太多就会出问题。
Eve: 能详细说明一下么?
Pedro: 直接看一下目前的聊天代码实现:
public class User {
private String name;
List users = new ArrayList();
public User(String name) {
this.name = name;
}
public void addUser(User u) {
users.add(u);
}
void sendMessage(String message) {
String text = String.format("%s: %s\n", name, message);
for (User u : users) {
u.receive(text);
}
}
private void receive(String message) {
// process message
}
}
Pedro: 问题就在于用户必须知道其它所有的用户。这样维护起来非常的麻烦。每当有新的用户加入聊天,你必须通过 addUser
方法给所有已存在的用户的添加这个新用户的引用。
Eve: 所以,我们就把这个添加新用户的职能移动到另一个类里面?
Pedro: 是的,基本上就是这样。我们创造一个*超限の觉醒 * 类,其名为终结者(误)中介者,它把众生绑定在一起。很显然,这样每个用户只会感应到中介者的存在。
public class User {
String name;
private Mediator m;
public User(String name, Mediator m) {
this.name = name;
this.m = m;
}
public void sendMessage(String text) {
m.sendMessage(this, text);
}
public void receive(String text) {
// process message
}
}
public class Mediator {
List users = new ArrayList();
public void addUser(User u) {
users.add(u);
}
public void sendMessage(User u, String text) {
for (User user : users) {
u.receive(text);
}
}
}
Eve: 貌似就是简单的重构了一下啊。
Pedro: 看起来貌似没啥改进,但是如果你有上百个组建需要互相关联(比如 UI),伟大的救世主,中介者,就出现了。
Eve: 这倒是。
Pedro: 下面该 Clojure 出招了。
Eve: 行吧……我看看……你的中介者所拥有的能力就是保存用户列表 和*发送消息 *。
(def mediator
(atom {:users []
:send (fn [users text]
(map #(receive % text) users))}))
(defn add-user [u]
(swap! mediator
(fn [m]
(update-in m [:users] conj u))))
(defn send-message [u text]
(let [send-fn (:send @mediator)
users (:users @mediator)]
(send-fn users (format "%s: %s\n" (:name u) text))))
(add-user {:name "Mister White"})
(add-user {:name "Mister Pink"})
(send-message {:name "Joe"} "Toby?")
Pedro: 好了行了。
Eve: 不吹不黑,不就是 减小耦合 么,轻轻松松。
第十集:观察者
经探查,某第三方安全机构在黑客 Dartee Hebl 账户上发现了高达数十亿美元的不明资金。你的任务是追踪这个账户的大额资金流动。
Pedro: 我们是福尔摩斯 么?
Eve: 不是,但是这个系统里并没有日志记录,所以要想个法子去追踪这个账户上所有的资金流动。
Pedro: 我们需要增加点观察者。每当有资金变化,如果流动金额*足够大 *,就发出通知,然后追踪源头。首先我们需要一个 Observer
接口:
public interface Observer {
void notify(User u);
}
Pedro: 然后实现两个具体观察者。
class MailObserver implements Observer {
@Override
public void notify(User user) {
MailService.sendToFBI(user);
}
}
class BlockObserver implements Observer {
@Override
public void notify(User u) {
DB.blockUser(u);
}
}
Pedro: Tracker
类的职责就是用来管理这些观察者。
public class Tracker {
private Set observers = new HashSet();
public void add(Observer o) {
observers.add(o);
}
public void update(User u) {
for (Observer o : observers) {
o.notify(u);
}
}
}
Pedro: 最后的步骤就是:开启账户追踪,对 addMoney
方法做点手脚。如果账户的流动金额高于 100$
,通知 FBI,冻结他的账户。
public class User {
String name;
double balance;
Tracker tracker;
public User() {
initTracker();
}
private void initTracker() {
tracker = new Tracker();
tracker.add(new MailObserver());
tracker.add(new BlockObserver());
}
public void addMoney(double amount) {
balance += amount;
if (amount > 100) {
tracker.update(this);
}
}
}
Eve: 为啥你分别搞了两个观察者?我觉得一个就行了啊。
class MailAndBlock implements Observer {
@Override
public void notify(User u) {
MailService.sendToFBI(u);
DB.blockUser(u);
}
}
Pedro: 单一职责原则。
Eve: 哦,对。
Pedro: 这样就可以动态地对观察者的功能进行搭配组合了。
Eve: 我懂你的意思了。
;; Tracker
(def observers (atom #{}))
(defn add [observer]
(swap! observers conj observer))
(defn notify [user]
(map #(apply % user) @observers))
;; Fill Observers
(add (fn [u] (mail-service/send-to-fbi u)))
(add (fn [u] (db/block-user u)))
;; User
(defn add-money [user amount]
(swap! user
(fn [m]
(update-in m [:balance] + amount)))
;; tracking
(if (> amount 100) (notify)))
Pedro: 基本上没看出差别啊?
Eve: 对啊,实际上观察者就是把一些函数记录下来,然后这些函数就可以等着被其它函数调用了。
Pedro: 这不还是一种模式啊。
Eve: 对,不过我们可以借助 Clojure 自带的观察者功能对其进行进一步地改进。
(add-watch
user
:money-tracker
(fn [k r os ns]
(if (< 100 (- (:balance ns) (:balance os)))
(notify))))
Pedro: 这样写有啥优点呢。
Eve: 首先是,我们的 add-money
方法更干净了,只负责增加金额。然后是,这种方式可以监听到*所有 * 的状态改变,不仅仅是那个我们做了手脚的 add-money
方法。
Pedro: 解释一下呗。
Eve: 假如这里提供了一个隐藏的秘密方法 secret-add-money
也可以改动资金,那么我这种观察者一样可以很好地处理它。
Pedro: 这个有点酷炫啊!
第十一集:解释器
Bertie Prayc 从我们的的服务器上偷走了重要的数据,而且还做了 BT 种子上传到了网上。搞一个叫 Bertie 的假帐户整一下他。
Pedro: BT 系统建立在 .torrent
文件之上。我们需要进行 Bencode 编码。
Eve: 是的,不过我们首先要了解它的编码格式
Bencode 编码规范:
-
支持以下两种数据类型:
- 整形
N
被编码为i
。 (42 = i42e)e - 字符串
S
被编码为<长度>:<内容>
(hello = 5:hello)
- 整形
-
支持以下两种容器类型:
- 列表类型被编码为
l<内容>e
([1, "Bye"] = li1e3:Byee) - 键值类型被编码为
d<内容>e
({"R" 2, "D" 2} = d1:Ri2e1:Di2ee)- 键必须是字符串,值可以是任何允许的 bencode 元素节点
- 列表类型被编码为
Pedro: 看上去不难。
Eve: 但愿吧,考虑到值是可以进行嵌套的,列表套列表之类的。
Pedro: 好的。我认为我们可以使用*解释器 *模式来对付 bencode 编码问题。
Eve: 试试看。
Pedro: 我们先把所有 bencode 元素抽象为一个接口
interface BencodeElement {
String interpret();
}
Pedro: 然后我们再依次搞出数据类型和容器类型的实现
class IntegerElement implements BencodeElement {
private int value;
public IntegerElement(int value) {
this.value = value;
}
@Override
public String interpret() {
return "i" + value + "e";
}
}
class StringElement implements BencodeElement {
private String value;
StringElement(String value) {
this.value = value;
}
@Override
public String interpret() {
return value.length() + ":" + value;
}
}
class ListElement implements BencodeElement {
private List extends BencodeElement> list;
ListElement(List extends BencodeElement> list) {
this.list = list;
}
@Override
public String interpret() {
String content = "";
for (BencodeElement e : list) {
content += e.interpret();
}
return "l" + content + "e";
}
}
class DictionaryElement implements BencodeElement {
private Map map;
DictionaryElement(Map map) {
this.map = map;
}
@Override
public String interpret() {
String content = "";
for (Map.Entry kv : map.entrySet()) {
content += kv.getKey().interpret() + kv.getValue().interpret();
}
return "d" + content + "e";
}
}
Pedro: 最终,我们就可以使用平时用的数据结构编写程序来生成编码后的字符串了。
// discredit user
Map mainStructure = new HashMap();
// our victim
mainStructure.put(new StringElement("user"), new StringElement("Bertie"));
// just downloads files
mainStructure.put(new StringElement("number_of_downloaded_torrents"), new IntegerElement(623));
// and nothing uploads
mainStructure.put(new StringElement("number_of_uploaded_torrents"), new IntegerElement(0));
// and nothing donates
mainStructure.put(new StringElement("donation_in_dollars"), new IntegerElement(0));
// prefer dirty categories
mainStructure.put(new StringElement("preffered_categories"),
new ListElement(Arrays.asList(
new StringElement("porn"),
new StringElement("murder"),
new StringElement("scala"),
new StringElement("pokemons")
)));
BencodeElement top = new DictionaryElement(mainStructure);
// let's totally discredit him
String bencodedString = top.interpret();
BitTorrent.send(bencodedString);
Eve: 很不错哦,但是你这代码量快要有一卡车了吧!
Pedro: 为了增强可读性嘛。
Eve: 我觉得你应该听说过代码即数据,这在 Clojure 中特别容易实现
;; multimethod to handle bencode structure
(defmulti interpret class)
;; implementation of bencode handler for each type
(defmethod interpret java.lang.Long [n]
(str "i" n "e"))
(defmethod interpret java.lang.String [s]
(str (count s) ":" s))
(defmethod interpret clojure.lang.PersistentVector [v]
(str "l"
(apply str (map interpret v))
"e"))
(defmethod interpret clojure.lang.PersistentArrayMap [m]
(str "d"
(apply str (map (fn [[k v]]
(str (interpret k)
(interpret v))) m))
"e"))
;; usage
(interpret {"user" "Bertie"
"number_of_downloaded_torrents" 623
"number_of_uploaded_torrent" 0
"donation_in_dollars" 0
"preffered_categories" ["porn"
"murder"
"scala"
"pokemons"]})
Eve: 你瞅瞅使用 Clojure 定义一个特殊数据是多么的方便。
Pedro: 真的是啊,不同的 bencode 解释器不过是一些函数而已,而不是一些类。
Eve: 回答正确,解释器不过是一套用来处理树形结构的函数。
第十二集:羽量 (Flyweight)
某律师公司的管理员 Cristopher, Matton & Pharts 发现,报表系统消耗了大量的内存资源,导致垃圾处理程序不断运行,造成系统卡顿。修复这个问题。
Pedro: 我以前也遇见过这个问题。
Eve: 问题出在哪里呢?
Pedro: 这是个实时图表系统,里面有非常多的点。真的是占用了巨大的内存空间。结果垃圾处理程序把系统拖垮了。
Eve: 嗯……那我们咋办?
Pedro: 我也不造啊,缓存也派不上用场,因为节点实在是太多了……
Eve: 等等!
Pedro: 咋了?
Eve: 这里的点会被重复使用多次,为什么我们不预先加载最经常使用的点呢?比如 [0, 100] 范围内的。
Pedro: 你的意思是用*共享 *模式?
(译注:共享 原文 Flyweight。直译为 羽量。这个模式的思想是共享相同的元素,故又译为 享元。)
Eve: 我的意思是复用对象。
class Point {
int x;
int y;
/* some other properties*/
// precompute 10000 point values at class loading time
private static Point[][] CACHED;
static {
CACHED = new Point[100][];
for (int i = 0; i < 100; i++) {
CACHED[i] = new Point[100];
for (int j = 0; j < 100; j++) {
CACHED[i][j] = new Point(i, j);
}
}
}
Point(int x, int y) {
this.x = x;
this.y = y;
}
static Point makePoint(int x, int y) {
if (x >= 0 && x < 100 &&
y >= 0 && y < 100) {
return CACHED[x][y];
} else {
return new Point(x, y);
}
}
}
Pedro: 这个模式的要点有两个:一是在启动的时候对最常用的点进行预加载,二是使用静态工厂方法取代构造方法,以便返回缓存的对象。
Eve: 这东西你测试过了?
Pedro: 肯定啊,系统像钟表一样精确运行。
Eve: 你真厉害啊,来看看我写的版本
(defn make-point [x y]
[x y {:some "Important Properties"}])
(def CACHE
(let [cache-keys (for [i (range 100) j (range 100)] [i j])]
(zipmap cache-keys (map #(apply make-point %) cache-keys))))
(defn make-point-cached [x y]
(let [result (get CACHE [x y])]
(if result
result
(make-point x y))))
Eve: 我搞了一个关于 [x, y]
的扁平映射 (flat map) ,以取代二维数组。
Pedro: 没啥区别啊。
Eve: 并不是,我这样更灵活一些,你的二维数组并不能及时适应三维的点或者非整型的点值。
Pedro: 哦,好吧。
Eve: 其实还能更简单,在 Clojure 里面你可以很方便的使用 memoize
函数来给 make-point
函数增加缓存功能,这样就可以替代手工的缓存工厂了。
(def make-point-memoize (memoize make-point))
Eve: 每次调用的时候(除了第一次),只要函数参数与之前的某次调用相同,就会返回上次缓存的值。
Pedro: 这个太牛了!
Eve: 那可不,不过需要注意的是,如果你的函数具有副作用,用缓存就不合适了。
第十三集:建造者 (Builder)
Tuck Brass 抱怨他的自动咖啡贩卖机系统运行起来实在是太慢了。顾客们根本没有耐心等下去就走了。
Pedro: 首先要弄明白问题的真正原因。
Eve: 我已经调查完毕,这是个上古系统,竟然是用 COBOL 语言写的,而且是建立在问-答 机制专家系统架构上。这个机制在上古时期很流行的。
Pedro: “问-答” 机制是个啥?
Eve: 就好比有一个操作员坐在电脑终端面前。系统问:“要加点水么?”,操作员回答:“对 ”。系统又问:“要加点咖啡么?”,操作员回答:“对 ” 然后巴拉巴拉继续下去……
Pedro: 简直是要急死人了,我就是想要一杯咖啡加点牛奶嘛。为啥他们不做一些预选项,像是:咖啡加牛奶,咖啡加糖等等等。
Eve: 因为这种系统的卖点就是:顾客可以*自行搭配 *各种咖啡配料。
Pedro: 好吧,我们用建造者模式进行改进吧。
public class Coffee {
private String coffeeName; // required
private double amountOfCoffee; // required
private double water; // required
private double milk; // optional
private double sugar; // optional
private double cinnamon; // optional
private Coffee() { }
public static class Builder {
private String builderCoffeeName;
private double builderAmountOfCoffee; // required
private double builderWater; // required
private double builderMilk; // optional
private double builderSugar; // optional
private double builderCinnamon; // optional
public Builder() { }
public Builder setCoffeeName(String name) {
this.builderCoffeeName = name;
return this;
}
public Builder setCoffee(double coffee) {
this.builderAmountOfCoffee = coffee;
return this;
}
public Builder setWater(double water) {
this.builderWater = water;
return this;
}
public Builder setMilk(double milk) {
this.builderMilk = milk;
return this;
}
public Builder setSugar(double sugar) {
this.builderSugar = sugar;
return this;
}
public Builder setCinnamon(double cinnamon) {
this.builderCinnamon = cinnamon;
return this;
}
public Coffee make() {
Coffee c = new Coffee();
c.coffeeName = builderCoffeeName;
c.amountOfCoffee = builderAmountOfCoffee;
c.water = builderWater;
c.milk = builderMilk;
c.sugar = builderSugar;
c.cinnamon = builderCinnamon;
// check required parameters and invariants
if (c.coffeeName == null || c.coffeeName.equals("") ||
c.amountOfCoffee <= 0 || c.water <= 0) {
throw new IllegalArgumentException("Provide required parameters");
}
return c;
}
}
}
Pedro: 你看这样你就不能简单地直接实例化 Coffee
类了,必须先通过内部类 Builder
设置参数
Coffee c = new Coffee.Builder()
.setCoffeeName("Royale Coffee")
.setCoffee(15)
.setWater(100)
.setMilk(10)
.setCinnamon(3)
.make();
Pedro: 调用 make
方法检查所有必要的参数,如果发现问题就扔出一个异常,没问题就返回实例。
Eve: 很不错的功能,就是有点啰嗦。
Pedro: 你行你上。
Eve: 小菜一碟,Clojure 支持可选参数列表,轻松实现建造者模式。
(defn make-coffee [name amount water
& {:keys [milk sugar cinnamon]
:or {milk 0 sugar 0 cinnamon 0}}]
;; definition goes here
)
(make-coffee "Royale Coffee" 15 100
:milk 10
:cinnamon 3)
Pedro: 啊哈,你这有三个必选参数和三个可选参数,但是必选参数依然没有命名。
Eve: 啥子意思?
Pedro: 比如拿你这个例子来说,我并不能直接看出 15
这个数字代表什么含义。
Eve: 好像是这样。那就把所有参数都取个名吧,然后再做一下预处理,这样就和你的建造者一样了。
(defn make-coffee
[& {:keys [name amount water milk sugar cinnamon]
:or {name "" amount 0 water 0 milk 0 sugar 0 cinnamon 0}}]
{:pre [(not (empty? name))
(> amount 0)
(> water 0)]}
;; definition goes here
)
(make-coffee :name "Royale Coffee"
:amount 15
:water 100
:milk 10
:cinnamon 3)
Eve: 你看,这样所有的参数都有名字了,而且我使用了 :pre
约束对参数进行了预处理,如果约束不成立,就会扔出 AssertionError
异常。
Pedro: 有意思,:pre
是语言本身提供的么?
Eve: 是的,它就是一个简单的断言。除此之外还有 :post
断言,功能差不多。
(译注::post
断言用来设置函数执行完毕后返回值的约束条件)
Pedro: 额,好吧。不过你知道的,建造者
模式通常用在易变数据结构上,比如 StringBuilder
。
Eve: 可变数据类型不符合 Clojure 哲学,不过如果你*真的 *需要,也没问题。用 deftype
创建一个新的类就可以了,别忘了在会发生变化的属性上加上 volatile-mutable
。
Pedro: 代码呢?
Eve: 这有一个在 Clojure 里自定义的可变类型的 StringBuilder
的实现的例子。虽然可变类型有一大堆的缺点和限制,但是没办法你非要用。
;; interface
(defprotocol IStringBuilder
(append [this s])
(to-string [this]))
;; implementation
(deftype ClojureStringBuilder [charray ^:volatile-mutable last-pos]
IStringBuilder
(append [this s]
(let [cs (char-array s)]
(doseq [i (range (count cs))]
(aset charray (+ last-pos i) (aget cs i))))
(set! last-pos (+ last-pos (count s))))
(to-string [this] (apply str (take last-pos charray))))
;; clojure binding
(defn new-string-builder []
(ClojureStringBuilder. (char-array 100) 0))
;; usage
(def sb (new-string-builder))
(append sb "Toby Wong")
(to-string sb) => "Toby Wong"
(append sb " ")
(append sb "Toby Chung") => "Toby Wang Toby Chung"
Pedro: 并不是和我想象中的一样麻烦。
第十四集:外观 (Facade)
我们的新员工 Eugenio Reinn Jr. 给 servlet 程序提交了 134 行的代码改动。其实这些代码改动只是为了发起一个 request 请求。除此之外的代码都是注入导入之类的。必须把类似的功能简化到一行。
Pedro: 管他几行代码改动啊。
Eve: 某人在乎啊。
Pedro: 我看一下问题出在哪
class OldServlet {
@Autowired
RequestExtractorService requestExtractorService;
@Autowired
RequestValidatorService requestValidatorService;
@Autowired
TransformerService transformerService;
@Autowired
ResponseBuilderService responseBuilderService;
public Response service(Request request) {
RequestRaw rawRequest = requestExtractorService.extract(request);
RequestRaw validated = requestValidatorService.validate(rawRequest);
RequestRaw transformed = transformerService.transform(validated);
Response response = responseBuilderService.buildResponse(transformed);
return response;
}
}
Eve: 我擦……
Pedro: 这就是我们的内部开发者 API,每次处理 request 请求都需要注入 4 个服务,导入所有依赖,然后就写出了这样的代码。
Eve: 我们来重构一下,就用……
Pedro: ……用外观模式。我们把所有的依赖分解为**单一访问点 (single point of access) **来简化 API 的使用。
public class FacadeService {
@Autowired
RequestExtractorService requestExtractorService;
@Autowired
RequestValidatorService requestValidatorService;
@Autowired
TransformerService transformerService;
@Autowired
ResponseBuilderService responseBuilderService;
RequestRaw extractRequest(Request req) {
return requestExtractorService.extract(req);
}
RequestRaw validateRequest(RequestRaw raw) {
return requestValidatorService.validate(raw);
}
RequestRaw transformRequest(RequestRaw raw) {
return transformerService.transform(raw);
}
Response buildResponse(RequestRaw raw) {
return responseBuilderService.buildResponse(raw);
}
}
Pedro: 这样如果你需要在代码里引入任何服务,只需注入 facade 到你的代码中。
class NewServlet {
@Autowired
FacadeService facadeService;
Response service(Request request) {
RequestRaw rawRequest = facadeService.extractRequest(request);
RequestRaw validated = facadeService.validateRequest(rawRequest);
RequestRaw transformed = facadeService.transformRequest(validated);
Response response = facadeService.buildResponse(transformed);
return response;
}
}
Eve: 打住!你这就是把所有的依赖都放在一个东西里面,每次用的时候都用这个大的,就这样?
Pedro: 对,现在不管你需要哪种功能,无脑用 FacadeService
。这里面啥依赖都有。
Eve: 那这东西和中介者模式一样啊。
Pedro: 中介者模式是关于行为的模式。我们把所有的依赖都交给中介者,然后向其添加*新的行为 。
Eve: 那外观模式呢?
Pedro: 外观模式是关于组织结构的模式,我们并没有增加新的功能,我们只是用外观模式暴露出已经存在的功能 *。
Eve: 明白了。不过貌似这个东西看起来很强大实际上改进不大啊。
Pedro: 也许吧。
Eve: 这是 Clojure 版本,使用命名空间 (namespaces) 来组织结构 (structure)。
(ns application.old-servlet
(:require [application.request-extractor :as re])
(:require [application.request-validator :as rv])
(:require [application.transformer :as t])
(:require [application.response-builder :as rb]))
(defn service [request]
(-> request
(re/extract)
(rv/validate)
(t/transform)
(rb/build)))
Eve: 通过 facade 暴露出所有的服务。
(ns application.facade
(:require [application.request-extractor :as re])
(:require [application.request-validator :as rv])
(:require [application.transformer :as t])
(:require [application.response-builder :as rb]))
(defn request-extract [request]
(re/extract request))
(defn request-validate [request]
(rv/validate request))
(defn request-transform [request]
(t/transform request))
(defn response-build [request]
(rb/build request))
Eve: 然后就可以用了。
(ns application.old-servlet
(:use [application.facade]))
(defn service [request]
(-> request
(request-extract)
(request-validate)
(request-transform)
(request-build)))
Pedro: :use
和 :require
有啥区别?
Eve: 它俩基本一样,区别是 :require
暴露出的功能必须通过命名空间全限定名 (namespace/function)
来访问,用 :use
的时候就可以直接使用 (function)
。
Pedro: 也就是说,:use
更好咯。
Eve: 也不是,要小心使用 :use
,因为它可能会引起当前命名空间冲突。
Pedro: 哦,我明白你的意思了。一旦你在某个命名空间里使用 (:use [application.facade])
,就可以使用 facade 里面所有的函数功能了?
Eve: 是的。
Pedro: 嗯,是差不多。
-
一个不存在的公司。 ↩