前言
时隔半年,今天我们继续来讲讲设计模式,这次要讲的是结构型模式。同样的,我们继续以java代码为例,来帮助小唐——一个类minecraft游戏开发者来熟悉这些模式吧
结构型模式
1.适配器模式
大模型这年头很火,小唐前些日子把文心一言嵌入在了游戏的聊天栏中,用作游戏内小助手。
文心一言的API需要通过api_key与api_secret进行实时请求获取access_token,然后才能访问,代码是这样的:
1 2 3 4 5 6 7 8 9 10 11
| class YiYan{ private String getAccessToken(String api_key ,String api_secret){ return "[通过key:"+ api_key +"与secret:" + api_secret + "得到的token]"; } public String chat(String api_key ,String api_secret, String user_input) { String access_token = getAccessToken(api_key,api_secret); String yiYan_url = "https://[文心一言的api地址]"; System.out.println("请求地址:" + yiYan_url + ",token:" + access_token + ",用户输入:" + user_input); return "[YiYan的回答]"; } }
|
这几天,小唐他又想给游戏中的生物接入大模型,当玩家与其互动时可以进行有趣的对话。他写了一个LLM通用的接口进行测试,如下,他还写好了Chat-GPT与X-AI的API使用代码进行测试
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| interface LLM{ String chat(String access_token,String user_input); } class Chat_GPT implements LLM{ @Override public String chat(String access_token, String user_input) { String gpt_url = "https://[Chat_GPT的api地址]"; System.out.println("请求地址:" + gpt_url + ",token:" + access_token + ",用户输入:" + user_input); return "[Chat_GPT的回答]"; } } class X_AI implements LLM{ @Override public String chat(String access_token, String user_input) { String x_url = "https://[X_AI的api地址]"; System.out.println("请求地址:" + x_url + ",token:" + access_token + ",用户输入:" + user_input); return "[X_AI的回答]"; } }
|
很快他就发现了问题,这两个大模型除外,其它很多大模型的接口调用方式都不统一,特别是之前使用的文心一言,并不像它们一样直接使用access_token调用。
可是,文心一言的调用类已经多处在聊天栏小助手的场景中使用了,现在去修改,违反了开闭原则,工作量巨大。
在这种情况下,适配器模式就要闪亮登场了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| class YiYanLLMAdapter implements LLM { private YiYan yiYan; public YiYanLLMAdapter(YiYan yiYan) { this.yiYan = yiYan; } @Override public String chat(String access_token, String user_input) { String[] credentials = extractApiKeySecret(access_token); return yiYan.chat(credentials[0], credentials[1], user_input); } private String[] extractApiKeySecret(String access_token) { if (access_token == null || !access_token.contains(":")) { return null; } return access_token.split(":", 2); } }
|
我们可以写一个适配器类YiYanLLMAdapter
完成YiYan
对LLM
的实现,传入your_api_key:your_api_secret
的形式作为access_token
,也就是chat
方法的第一个参数,这样就完成了对该方法的风格统一,实现了覆写。
适配器类用于在不对原类修改的情况下,使得接口能兼容它,在生活中这种思想也很常见,如USB转TYPEC,HDMI转VGA
这是一种很简单的逻辑,深刻体现了没有什么是加一层解决不了的这个思想,如下图所示:
最后给出测试程序
1 2 3 4 5 6 7 8
| public class Test { public static void main(String[] args){ LLM llm1 = new Chat_GPT(); LLM llm2 = new YiYanLLMAdapter(new YiYan()); System.out.println(llm1.chat("gpt_token","你好")); System.out.println(llm2.chat("yiyan_key:yiyan:secret","你好")); } }
|
运行结果
1 2 3 4
| 请求地址:https://[Chat_GPT的api地址],token:gpt_token,用户输入:你好 [Chat_GPT的回答] 请求地址:https://[文心一言的api地址],token:[通过key:yiyan_key与secret:yiyan:secret得到的token],用户输入:你好 [YiYan的回答]
|
2.装饰器模式
附魔是游戏中重要元素,对物品进行附魔可以改变它的伤害或某些使用效果。小唐正在写附魔相关代码,他的逻辑如下
在物品中维护一个enchantmentList
,存储当前的附魔,类型为Enchantment
,同时保留物品最基础的信息属性
通过addEnchant(Enchantment em)
为enchantmentList
添加元素
如果需要移除效果,则调用removeEnchant(Enchantment em)
交互前遍历enchantList
,根据列表中的具体的附魔Sharpness
,FireAspect
…,分别调用Enchantment
接口中的方法enchant
,根据物品最基础的信息属性得到附魔后的信息属性。
想法很丰满,但是物品接口Item
及其实现类似乎已经在众多地方使用了,除了附魔,后期可能还要加入物品纹饰、充能等词条,难道每次为物品新增词条都要修改列表甚至新增一个列表属性吗。
现实是骨感的,这严重违反了开闭原则,太复杂的逻辑可能会导致bug不说,每次交互遍历列表显然也不是一个明智之举。
此时小唐面前还有一条不修改Item
代码就能实现附魔的路,那就是装饰器模式
我对装饰器模式的理解是:
装饰类A与被装饰类B都实现了接口I,而A含有一个I类型的属性,所以他能接收B并调用B的方法,同时还能调用自身的方法,由此实现装饰B的效果
如果还无法理解的话,看了以下代码后就能清晰的明白
先把item
相关的模拟代码放出
1 2 3 4 5 6 7 8 9
| interface Item{ void use(); } class IronSword implements Item{ @Override public void use() { System.out.println("使用铁剑进行攻击"); } }
|
现在我们可以着手写装饰器了,首先完成一个抽象的装饰类,实现Item
接口,以方便装饰类间传递,达到层层装饰的效果
1 2 3 4 5 6 7
| abstract class ItemDecorator implements Item { protected Item itemDecorator; public ItemDecorator(Item itemDecorator){ this.itemDecorator = itemDecorator; } public abstract void use(); }
|
具体的装饰类,继承自抽象装饰类类,保持了统一的规范,并增加了可维护性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| class LootingItemDecorator extends ItemDecorator{ public LootingItemDecorator(Item itemDecorator) { super(itemDecorator); } @Override public void use(){ System.out.println(" -赋予抢夺效果"); itemDecorator.use(); } } class FlameItemDecorator extends ItemDecorator{ public FlameItemDecorator(Item itemDecorator) { super(itemDecorator); } @Override public void use(){ System.out.println(" -火焰附加"); itemDecorator.use(); } }
|
测试代码与结果
1 2 3 4 5 6 7 8
| public class Test { public static void main(String[] args){ IronSword ironSword = new IronSword(); FlameItemDecorator flameIS = new FlameItemDecorator(ironSword); LootingItemDecorator lootingFIS = new LootingItemDecorator(flameIS); lootingFIS.use(); } }
|
1 2 3
| -赋予抢夺效果 -火焰附加 使用铁剑进行攻击
|
3.桥接模式
在写这篇文章时快要过年了,小唐想趁此机会在游戏中添加烟花,游戏中的烟花有两个重要的组成部分:图案与颜色。
现在就以得到"颜色,图案面积"
的字符串来模拟游戏中烟花的生成
这是他的逻辑:
- 有一个图案接口
Pattern
,其实现类用于在空中按固定的样式和动画绘制图案;以及一个颜色接口Color
,其实现类用于具体渲染烟花在不同时刻的颜色
- 定义一个烟花类
Firework
,构造函数接收Pattern
与Color
类的参数,以此来生成烟花
这恐怕也是大多数人的逻辑了,其实这正是“桥接模式”的一种体现,可以解耦烟花的图案与颜色,哪怕出现更多图案与颜色也不用修改烟花的代码,遵守了开闭原则。
以更标准的“桥接模式”方法来写:
Color
接口仍然保留并写好实现类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| interface Color { String fill(); } class Red implements Color { @Override public String fill() { return "Red"; } } class Blue implements Color { @Override public String fill() { return "Blue"; } }
|
定义一个抽象类Firework
,其子类表示不同图案的烟花(相当于小唐逻辑中烟花与图案归并为一类)。该抽象类仅接收一个Color
用于填充颜色,其子类才接收具体的半径信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| abstract class Firework { protected Color color; protected Firework(Color color){ this.color = color; } public abstract void setOff(); } class CircleFirework extends Firework{ private int r; public CircleFirework(int r, Color color){ super(color); this.r = r; } @Override public void setOff() { String c = "Color:" + color.fill(); double area = 3.14159 * r * r; System.out.println(c + ",Circle-area:" + area); } } class RingFirework extends Firework{ private int r1; private int r2; public RingFirework(int r1, int r2, Color color){ super(color); this.r1 = r1; this.r2 = r2; } @Override public void setOff() { String c = "Color:" + color.fill(); double area = 3.14159 * (r1*r1 -r2*r2); System.out.println(c + ",Ring-area:" + area); } }
|
测试代码及其结果
1 2 3 4 5 6 7 8
| public class Test { public static void main(String[] args){ CircleFirework circleFirework = new CircleFirework(2,new Blue()); RingFirework ringFirework = new RingFirework(3,2,new Red()); circleFirework.setOff(); ringFirework.setOff(); } }
|
1 2
| Color:Blue,Circle-area:12.56636 Color:Red,Ring-area:15.70795
|
桥梁模式能很好地体现组合这一思想。图案与颜色是分立的两个概念,都应该可以自由地拓展而不影响另一边。只需要在其中一边预留好一个接口能调用另一边(如Firework(Color color)
中的color
),就可以实现两者组合才能实现的效果,这个接口就是所谓的“桥梁”
4.外观模式
和大多数游戏一样,小唐的游戏也有作弊指令,小唐想在游戏内控制台中暴露几个指令方便玩家使用,以给予玩家物品为例,现有的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| interface Item{ void give(); } class IronSword implements Item{ @Override public void give() { System.out.println("给予玩家铁剑"); } } class GoldApple implements Item{ @Override public void give() { System.out.println("给予玩家金苹果"); } } class TNT implements Item{ @Override public void give() { System.out.println("给予玩家TNT"); } }
|
玩家获取三样东西时要经历这样的过程
1 2 3 4 5 6 7 8 9 10
| public class Test { public static void main(String[] args){ TNT tnt = new TNT(); tnt.give(); IronSword ironSword = new IronSword(); ironSword.give(); GoldApple goldApple = new GoldApple(); goldApple.give(); } }
|
不仅玩家调用麻烦,而且把太多的东西暴露给了玩家
于是小唐对所有想要暴露的东西做了一个封装
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| class Command{ private TNT tnt; private GoldApple goldApple; private IronSword ironSword; public Command() { this.tnt = new TNT(); this.goldApple = new GoldApple(); this.ironSword = new IronSword(); } public void giveTNT(){ this.tnt.give(); } public void giveGoldApple(){ this.goldApple.give(); } public void giveIronSword(){ this.ironSword.give(); } }
|
现在我们就只暴露了Command类给玩家,玩家也可以简单的键入giveXXX就能获取相应的物品
1 2 3 4 5 6 7 8
| public class Test { public static void main(String[] args){ Command command = new Command(); command.giveTNT(); command.giveGoldApple(); command.giveIronSword(); } }
|
在不经意间小唐竟然自己写出了外观模式的设计
这种模式有以下优点
- 客户端与子系统之间的依赖减少。
- 子系统的内部变化不会影响客户端,提高灵活性。
- 隐藏了子系统的内部实现,只暴露必要的操作,增加了安全性。
5.组合模式
游戏时物品栏不够用是个令人头疼的事,这就是小唐为什么要在游戏中添加背包的原因,背包中还可以放置背包,通过“异次元空间”来拥有近乎无限的物品栏,这是小唐游戏对玩家的善意,但是这种嵌套对象的代码怎么实现令他犯了怵。
他画了一下物品栏存储对象的结构图
巧的是,刚好有一种设计模式对应树形对象的结构,那就是组合模式
实现原理是当前对象的属性中有一个List<当前对象类型>
,用于存储当前对象的下属对象,由此实现树形结构。
由于十分的简单,这里就不赘述了,直接上代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| class Storage{ private String name; private int count; private List<Storage> subStorages; public Storage(String name, int count) { this.name = name; this.count = count; this.subStorages = new ArrayList<Storage>(); } String getName() { return this.name; } int getCount() { return this.count; } void add(Storage storage) { this.subStorages.add(storage); } List<Storage> getSubStorages() { return subStorages; } public String toString(){ return ("item :[ Name : "+ this.name +", count : "+ this.count +" ]"); } }
public class Test { public static void printStorageHierarchy(Storage storage, String indent) { System.out.println(indent + storage); for (Storage subStorage : storage.getSubStorages()) { printStorageHierarchy(subStorage, indent + "\t"); } } public static void main(String[] args){ Storage container_0 = new Storage("玩家物品栏",1); Storage storage0_0 = new Storage("羊毛",15); Storage storage0_container1 = new Storage("背包",1); Storage storage0_1 = new Storage("苹果",2); Storage storage0_2 = new Storage("橡木原木",64); Storage storage1_0 = new Storage("羊毛",1); Storage storage1_container2 = new Storage("背包",1); Storage storage2_0 = new Storage("苹果",12);
container_0.add(storage0_0); container_0.add(storage0_1); container_0.add(storage0_2); container_0.add(storage0_container1);
storage0_container1.add(storage1_0); storage0_container1.add(storage1_container2);
storage1_container2.add(storage2_0);
printStorageHierarchy(container_0, ""); } }
|
运行结果
1 2 3 4 5 6 7 8
| item :[ Name : 玩家物品栏, count : 1 ] item :[ Name : 羊毛, count : 15 ] item :[ Name : 苹果, count : 2 ] item :[ Name : 橡木原木, count : 64 ] item :[ Name : 背包, count : 1 ] item :[ Name : 羊毛, count : 1 ] item :[ Name : 背包, count : 1 ] item :[ Name : 苹果, count : 12 ]
|
6.享元模式
写了这么多是时候将方块真正渲染出来了,但仍有一个问题亟待解决,不同方块除了自己的位置属性外,还有不同的材质贴图属性。大量的方块对象加上各自的贴图数据,会导致游戏的内存占用过高,性能低下。那么如何压缩所需存储空间呢?
我们来分析一下,这个场景具有两个主要特征:
- 创建了大量相似的对象(方块)。
- 对象的部分状态(如材质贴图)是共享的,而另一部分状态(如坐标位置)是独立的。
这种情况下享元模式就是一种简单的解决方案
享元模式的核心思想在这里可以描述为:将方块的一部分共享属性剥离出来变为共享对象,再把这个共享对象作为它的属性,以此实现可以被多个方块对象复用的效果,减小内存消耗。
下面是代码实现:
方块类BlockInstance
,独立的属性直接写在该类中,而共享的属性(这里是材质类型)则封装在BlockRender
中,在此时,BlockRender
是作为一个共享对象类存在的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| class BlockInstance { private BlockRender blockRender; private int x, y; public BlockInstance(BlockRender blockRender, int x, int y) { this.blockRender = blockRender; this.x = x; this.y = y; } public void show() { System.out.println("position:("+this.x+","+this.y+"),render:"+this.blockRender.hashCode()); } } class BlockRender { private String type; public BlockRender(String type) { this.type = type; } public String render() { return type; } }
|
现在还需要一个工厂,用于生产BlockRender
。当我们需要一个材质时,先从池中判断是否已经有该对象,有的话直接使用,没有的话就创建一个新的BlockRender
,并将其放入池中。
1 2 3 4 5 6 7 8 9 10 11 12
| class BlockFactory { private static final HashMap<String, BlockRender> blockMap = new HashMap<>(); public static BlockRender getBlock(String type) { BlockRender blockRender = blockMap.get(type); if (blockRender == null) { blockRender = new BlockRender(type); blockMap.put(type, blockRender); System.out.println("Creating new render:" + type +",hashcode:"+blockRender.hashCode()); } return blockRender; } }
|
测试代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| public class Test { private static final String[] blockTypes = {"泥土块", "石头", "钻石"}; public static void main(String[] args) { Random rand = new Random(); for (int i = 0; i < 10; ++i) { String particleType = blockTypes[rand.nextInt(blockTypes.length)]; BlockRender blockRender = BlockFactory.getBlock(particleType); int x = rand.nextInt(100); int y = rand.nextInt(100); BlockInstance blockInstance = new BlockInstance(blockRender, x, y); blockInstance.show(); } } }
|
通过不同方块对象render属性的hashcode与池中的hashcode一致可以发现,确实节约了内存
1 2 3 4 5 6 7 8 9 10 11 12 13
| Creating new render:泥土块,hashcode:1329552164 position:(83,78),render:1329552164 Creating new render:钻石,hashcode:500977346 position:(15,22),render:500977346 Creating new render:石头,hashcode:20132171 position:(61,94),render:20132171 position:(32,27),render:500977346 position:(27,63),render:1329552164 position:(14,23),render:20132171 position:(26,41),render:20132171 position:(51,88),render:20132171 position:(29,18),render:1329552164 position:(63,34),render:1329552164
|
7.代理模式
静态代理
在外观模式中我们提到了小唐游戏中的作弊指令,但是和其它游戏一样,并不是每个指令都能随意使用的,具体指令使用应当有权限控制,而且使用过的指令应当留下日志。这些操作要无痛地塞到已经写好的指令类中恐怕有些困难,我们首先想到了装饰器模式,但仔细想想这真的合理吗?
为什么不使用装饰器模式
装饰器模式常用于对对象的修饰,比如给 HTML 元素添加样式、给物品附魔增益等行为。它们的描述是让这个p元素对象变得粗些,颜色变为蓝色
或让这把钻石剑变得含有火焰附加效果
这些能被归纳为让这个类所表示的实体变得怎么样
。
而现有的权限控制等功能则无法被这样描述,况且,随着这些需要被附加的装饰越来越多,传递链也会很长,代码会变得十分冗杂。
所以使用代理模式
代理模式更适用于现在的场景,它一般用于需要在访问一个对象时进行一些控制或额外处理时的情况。
代理模式有代理类与真实类,代理类与与真实类实现一个接口,方法定义都是一样的,不过代理类能在真实类的基础上多实现一些功能。
这点其实与装饰器模式也很像,不过代理类是一对一的,他的职责就是让客户在附加一些功能的基础上直接控制真实类,而不是装饰器那样可以多个组合为对象修饰。
代码与装饰器模式非常的像啊,所以关键是要搞清楚上面的区别
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| interface ICommand{ void talk(String id); void give(String item); } class Command implements ICommand{ private String permissions; public Command(String permissions){ this.permissions = permissions; } public void talk(String id){ System.out.println("向玩家-"+id + "说话"); } public void give(String item){ System.out.println("给予玩家-" + item); } public String getPermissions() { return permissions; } } class CommandProxy implements ICommand{ private Command command; public CommandProxy(Command command){ this.command = command; } @Override public void talk(String id) { System.out.println("正在构建玩家间通信"); this.command.talk(id); System.out.println("关闭玩家间通信"); System.out.println(); } @Override public void give(String item) { this.command.give(item); if (this.command.getPermissions().equals("op")) System.out.println("给予成功"); else System.out.println("权限不够"); System.out.println(); } } public class Test { public static void main(String[] args){ Command command = new Command("op"); Command command2 = new Command("noob");
CommandProxy commandProxy = new CommandProxy(command); CommandProxy commandProxy2 = new CommandProxy(command2);
commandProxy.give("金苹果"); commandProxy2.give("金苹果"); commandProxy2.talk("唐宋"); } }
|
测试结果
1 2 3 4 5 6 7 8 9
| 给予玩家-金苹果 给予成功
给予玩家-金苹果 权限不够
正在构建玩家间通信 向玩家-唐宋说话 关闭玩家间通信
|
动态代理
我们可以通过使用JAVA的Proxy库来实现动态生成的代理逻辑,此时的代理就可以变成一块功能拼图,随时织进想要的接口中,就类似于我在Spring技术的博客中说到的AOP技术
先放代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
|
class GeneralInvocationHandler implements InvocationHandler { private final Object target; public GeneralInvocationHandler(Object target) { this.target = target; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (method.getName().equals("talk")) { System.out.println("正在构建玩家间通信"); Object result = method.invoke(target, args); System.out.println("关闭玩家间通信"); System.out.println(); return result; } else if (method.getName().equals("give")) { Object result = method.invoke(target, args); if (((Command)target).getPermissions().equals("op")) { System.out.println("给予成功"); } else { System.out.println("权限不够"); } System.out.println(); return result; } return method.invoke(target, args); } } public class Test { public static void main(String[] args) { Command command = new Command("op"); Command command2 = new Command("noob");
ICommand proxy1 = (ICommand) Proxy.newProxyInstance( Command.class.getClassLoader(), new Class[]{ICommand.class}, new GeneralInvocationHandler(command) );
ICommand proxy2 = (ICommand) Proxy.newProxyInstance( Command.class.getClassLoader(), new Class[]{ICommand.class}, new GeneralInvocationHandler(command2) );
proxy1.give("金苹果"); proxy2.give("金苹果"); proxy2.talk("唐宋"); } }
|
参考代码对于动态代理的实现原理给出几个解释
InvocationHandler
接口是Java动态代理机制的核心,它允许你在运行时拦截并修改方法调用
代码中我们对其有一个实现类GeneralInvocationHandler
,接收被代理类对象为参数target
Proxy.newProxyInstance
用于动态得到一个代理类对象,它需要三个参数
- 参数1:被代理类的类加载器,如代码中的
Command.class.getClassLoader()
- 参数2:代理类对象需要实现的接口(是一个
Class<?>[]
),如代码中的new Class[]{ICommand.class}
- 参数3:一个
InvocationHandler
接口的实现类对象,如代码中的new GeneralInvocationHandler(command)
假设proxy
是通过Proxy.newProxyInstance
得到的代理类对象,当我们调用其方法时,会自动触发参数3所代表的类中写好的invoke
方法,这类似于回调函数。
在invoke
方法中也会获得3个参数,分别是触发它的代理对象,触发它的方法,以及触发它的方法所携带的参数
于是我们在invoke
中不仅能通过method.getName()
判断是哪个方法,并执行相关代理功能,也能通过method.invoke(target, args)
来调用被代理类中的该方法(要记得GeneralInvocationHandler
接受了被代理类对象为参数target
)
动态代理正是这样巧妙地实现的
还有种第三方库CGLib的代理方式,它基于继承而非动态代理一样需要接口,有兴趣的也可以去学习一下