闹钟响到第三遍,人才从被子里出来。
天冷了。被子外面凉,脚一伸出去就缩回来,脑子里那点“早起跑一圈”的念头,跟着一起缩回去了。
这事搁浅一个月了。上回还是绕着小区跑了几圈,跑完站在楼下喘,那会儿天还没这么冷。
出了地铁口,路两边的银杏黄透了,风一过,一片叶子落在肩上。
心情松了一下。
到公司第一件事,跟产品对需求。这系统转手过好几拨人,产品也换过,有几块业务他自己都说不清,只能把代码打开,一行行翻出来讲给他听。
翻到第三层,一个方法两百多行,变量名从 a1 排到 a9,注释里写的日期比我来公司还早。
需求对到一半,得把刚翻到的那段代码投出来看。笔记本只有一个 Type-C 口,墙上那台投影仪只认 HDMI。抽屉翻了两遍,摸出来一个转接头。
插上,屏幕亮了。
插上转接头,老投影仪认了新笔记本
今天聊的适配器模式(Adapter):两边接口对不上,别去改哪一边,中间加一层翻译。系列第十九篇。
01 谁用谁自己转
新代码像那台只有 Type-C 的笔记本,老会员中心像只认 HDMI 的投影仪:一边要的是带前缀的编号、按元算的余额、脱敏手机号,另一边给的是 int 编号、按分存的余额、明文手机号。
最直接的办法,是谁要用,谁就地转一下。
LegacyUserCenter center = new LegacyUserCenter(1024, 12850, "13812342048");
int uid = center.getUid();
int balanceFen = center.getBalanceFen();
String phone = center.getPhone();
BigDecimal balanceYuan = BigDecimal.valueOf(balanceFen, 2); // 分换成元
System.out.println("用户:U" + uid);
System.out.println("余额:" + balanceYuan + " 元");
System.out.println("手机:" + phone.substring(0, 3) + "****" + phone.substring(7));
就地转:分换元、编号加前缀、手机号打码
这段能跑,数也对。
问题是这套转换不止一处要用。下单前要问余额,退款要查手机号,月底对账还得再算一遍。三处写三遍,等于把同一条约定抄了三份。
哪天老系统把金额单位从分改成元,或者编号前面要加前缀,改的人得把每一处都翻出来。漏一处,金额就错一百倍——这种错不抛异常,只会在财务那边对不上账。
转接头的活,本来就不该让每个用电的人自己干。
02 造一个转接头
先把新代码需要的样子写成一个接口:
interface MemberService {
String userId();
BigDecimal balanceYuan();
String maskedPhone();
}
新代码认的形状:编号、余额、手机号
再写一个类实现它,把每个方法翻译过去:
class LegacyMemberAdapter implements MemberService {
private final LegacyUserCenter legacy;
LegacyMemberAdapter(LegacyUserCenter legacy) {
this.legacy = legacy;
}
public String userId() {
return "U" + legacy.getUid();
}
public BigDecimal balanceYuan() {
return BigDecimal.valueOf(legacy.getBalanceFen(), 2);
}
public String maskedPhone() {
return legacy.getPhone().replaceAll(
"(\d{3})\d{4}(\d{4})", "$1****$2");
}
}
一个类,三行翻译,老对象原样拿在手里
这里用的是组合:手里攥着老对象,不是继承它。老系统那个类,一行都不用动,也动不了。
这三行翻译,值得多看一眼。
getUid 到 userId,只是改个名、加个前缀。这是换形状。
getBalanceFen 到 balanceYuan,是把分换成元。钱还是那笔钱,单位变了。12850 和 128.50,两串数字都“看着挺对”,这种错最难在 review 里看出来。
getPhone 到 maskedPhone,是明文变脱敏。
现实里的转接头,其实也分这两类。Type-C 转 USB-A 只改形状,插得进就能用;笔记本那块砖头似的电源适配器,是把 220V 交流变成 20V 直流,改的是电。
形状好改,电难改。
调用方这边,就干净了:
MemberService member = new LegacyMemberAdapter(
new LegacyUserCenter(1024, 12850, "13812342048"));
System.out.println("用户:" + member.userId());
System.out.println("余额:" + member.balanceYuan() + " 元");
System.out.println("手机:" + member.maskedPhone());
后面全按新接口写,不用再记老接口的样子
跑一遍:
用户:U1024
余额:128.50 元
手机:138****2048
代码实际运行输出
调用方从头到尾没见过 getBalanceFen 这个名字,也不必知道余额原来是按分存的。老系统一行没改,调用方没为它让路,中间多出来一个类,把两套话对上了。
03 老的新的,一起跑
接手老系统,最难的不是写新代码,是老代码下不掉。
适配器刚好能撑住这段过渡。新的会员中心直接实现同一个接口:
class NewMemberService implements MemberService {
public String userId() {
return "M-88";
}
public BigDecimal balanceYuan() {
return new BigDecimal("300.00");
}
public String maskedPhone() {
return "139****7788";
}
}
新的不用转接头,它本来就长这样
调用方只认接口,两套能一起用:
static void placeOrder(MemberService member, String amountYuan) {
BigDecimal amount = new BigDecimal(amountYuan);
System.out.println("用户:" + member.userId());
System.out.println("余额:" + member.balanceYuan() + " 元");
System.out.println("手机:" + member.maskedPhone());
System.out.println("下单 " + amount + " 元:"
+ (member.balanceYuan().compareTo(amount) >= 0 ? "可以" : "余额不够"));
}
// 老的包一层,新的直接用
placeOrder(new LegacyMemberAdapter(
new LegacyUserCenter(1024, 12850, "13812342048")), "99.00");
placeOrder(new NewMemberService(), "99.00");
同一个方法,两种来源
输出:
用户:U1024
余额:128.50 元
手机:138****2048
下单 99.00 元:可以
用户:M-88
余额:300.00 元
手机:139****7788
下单 99.00 元:可以
代码实际运行输出
placeOrder 里没有一行判断“这是老的还是新的”,两次调用的输出格式一模一样。
等哪天老会员中心能下线,把这个适配器摘掉,别处不用动。这话说起来轻,做过迁移的人知道它值多少钱。
04 翻译的是形状,不是业务
先把失真点挑明。
第一处失真:适配器不改变两头。老系统的毛病原样还在:它返回的余额要是本来就错了,适配器会老老实实换算成元,一个不多一个不少。适配器保证格式对,不保证语义对。
这也是它最容易被误用的地方。产品问“这个人为什么不能下单”,适配器答不上来。它只知道 12850 该读成 128.50,不知道这 12850 是怎么算出来的、有没有扣过优惠券。
要搞懂,还是得翻代码。
模式能把接口抹平,抹不平业务。
第二处失真:现实里插上转接头,信号可能会掉一点;代码里多一层调用,开销反倒不是最要紧的,要紧的是老接口有些能力没往外露。
老会员中心有个一次能查一百个人的批量接口,适配器只翻译了单个查询,只走这个适配器的新代码就用不上那个批量。适配器是一扇门,门开多大,是写适配器的人定的。
第三处:适配器是所有不一致的汇聚点。老接口一改,它必须跟着改——好处也在这儿,只要改这一处。
顺带把容易混的四个说清。
装饰器(Day⑤)不换接口,只在外面加功能;代理(Day⑦)也不换接口,管的是谁能碰到真对象;外观(Day⑨)是把一堆接口收成一个入口,接口变少了;适配器是把 A 的形状翻成 B 的形状,接口换了。
装饰器加东西,代理挡人,外观减接口,适配器换接口。
JDK 和常用框架里也有不少“转接头”。它们不解决投屏,干的却是同一件事:把一种接口翻成调用方认的另一种。
new InputStreamReader(new FileInputStream(file)) 把字节流翻成字符流,编码不对就乱码,这是一次带语义的翻译。
Collections.list(...) 把老式的 Enumeration 翻成 List。JDBC 的 java.sql.Driver 更典型:同一句 SQL,底下 MySQL 和 Oracle 的协议完全两样,驱动就是那个转接头。
Spring MVC 里也有一个:DispatcherServlet 不管你是注解方法还是实现 Controller 接口,一律交给对应的 HandlerAdapter 去调。
05 什么时候别用
只有一处调用,就地转一行就完了,别为一次调用造一个类。临时插一下的转接头,用完就拔,不必焊在墙上。
老东西本来就打算这周下线,直接换掉比包一层快。转接头是给还得用几年的东西准备的。
还有一种,不是形状不同,是模型不同。老系统按订单行算钱,新系统按账期算钱,这不是接口能翻译的。硬套一个适配器,等于把两套账揉在一起,越揉越乱。这种该做的是单独重写,或者在中间加一层防腐层,把老模型挡在外面。
最要命的一种:别让适配器里长出业务逻辑。
适配器只翻译。一旦开始在里面算折扣、判权限、补默认值,它就成了新的屎山——还是夹在中间、谁都不敢动的那种,因为拆它得同时动两头。
06 会开完了
会开完,转接头拔下来揣回兜里。投影仪还在墙上挂着,笔记本还是原来那台。
系统也还是那套系统。业务有几块没人说得清,下次对需求还得翻代码——这件事,模式帮不上忙。
但至少新写的这一部分,不用照着老接口的形状长。老接口哪天真要动,也只需要动那个转接头。
老系统留在原位,新代码不用照着它长
跟前面十八篇对一句:单例管造几个,工厂管怎么造,模板方法管事情怎么走,策略管这一步用什么做法,装饰器管外面加几层,命令管把请求做成对象,代理管谁能碰到真东西,观察者管变化后通知谁,外观管一堆零件只留一个入口,状态管当前状态下怎么做,责任链管请求由哪一环接住,原型管拿哪个现成对象复制,桥接管抽象和实现怎么独立扩展,享元管把能共享的部分只留一份,备忘录管把某个时刻的状态存下来、再原样恢复,生成器管把复杂对象的装配拆成一步一步、最后一次性成型,访问者管对一批稳定的元素做什么操作由递进来的单子说了算,组合管单个和一组长一个样。本篇适配器,管两套对不上的接口中间怎么搭一座桥。
带走一句话:适配器 = 两头都不动,中间加一层翻译;翻译对了,老东西还能再用几年。
接口对不上,先想着加个转接头。
别急着把整面墙拆了。
银杏还得落一阵子,明早那个闹钟,大概还是得响几遍。
参考资料
- 《设计模式:可复用面向对象软件的基础》(GoF 原著)——适配器模式的原始定义:把一个类的接口变换成客户端所期待的另一种接口,使原本因接口不匹配而无法一起工作的两个类能够一起工作
- Refactoring Guru · Adapter——对象适配器与类适配器的区别:组合被适配者,还是继承被适配者
- JDK · java.io.InputStreamReader——从字节流到字符流的桥:读进来的是字节,用的时候是字符
- Spring Framework · HandlerAdapter——DispatcherServlet 通过它调用各种写法的处理器