diff --git a/README.md b/README.md index 1b258c87774e7efbe6636d7e2e15ea8b201e4e6d..e0441f6c953df65123a2165d3842cc41d543b07c 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,4 @@ - [`4. 重学 Java 设计模式:实战装饰器模式(SSO单点登录功能扩展,增加拦截用户访问方法范围场景)`](https://bugstack.cn/itstack-demo-design/2020/06/09/%E9%87%8D%E5%AD%A6-Java-%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F-%E5%AE%9E%E6%88%98%E8%A3%85%E9%A5%B0%E5%99%A8%E6%A8%A1%E5%BC%8F.html) - [`5. 重学 Java 设计模式:实战外观模式「基于SpringBoot开发门面模式中间件,统一控制接口白名单场景」`](https://bugstack.cn/itstack-demo-design/2020/06/11/%E9%87%8D%E5%AD%A6-Java-%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F-%E5%AE%9E%E6%88%98%E5%A4%96%E8%A7%82%E6%A8%A1%E5%BC%8F.html) - [`6. 重学 Java 设计模式:实战享元模式「基于Redis秒杀,提供活动与库存信息查询场景」`](https://bugstack.cn/itstack-demo-design/2020/06/14/%E9%87%8D%E5%AD%A6-Java-%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F-%E5%AE%9E%E6%88%98%E4%BA%AB%E5%85%83%E6%A8%A1%E5%BC%8F.html) +- [`7.重学 Java 设计模式:实战代理模式「模拟mybatis-spring中定义DAO接口,使用代理类方式操作数据库原理实现场景」`](https://bugstack.cn/itstack-demo-design/2020/06/16/%E9%87%8D%E5%AD%A6-Java-%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F-%E5%AE%9E%E6%88%98%E4%BB%A3%E7%90%86%E6%A8%A1%E5%BC%8F.html) \ No newline at end of file diff --git "a/doc/11.\344\272\253\345\205\203\346\250\241\345\274\217.md" "b/doc/11.\344\272\253\345\205\203\346\250\241\345\274\217.md" new file mode 100644 index 0000000000000000000000000000000000000000..4726138e4996d0b097e3a4282d18d4a41361b86f --- /dev/null +++ "b/doc/11.\344\272\253\345\205\203\346\250\241\345\274\217.md" @@ -0,0 +1,311 @@ +1. https://www.cnblogs.com/V1haoge/p/6542449.html + +# 重学 Java 设计模式:实战享元模式「基于Redis秒杀,活动与库存信息查询场景」 + +## 一、前言 + +`程序员👨‍💻‍的上线文是什么?` + +很多时候一大部分编程开发的人员都只是关注于功能的实现,只要自己把这部分需求写完就可以了,有点像被动的交作业。这样的问题一方面是由于很多新人还不了解程序员的职业发展,还有一部分是对于编程开发只是工作并非兴趣。但在程序员的发展来看,如果不能很好的处理上文(`产品`),下文(`测试`),在这样不能很好的了解业务和产品发展,也不能编写出很有体系结构的代码,日久天长,1到3年、3到5年,就很难跨越一个个技术成长的分水岭。 + +`拥有接受和学习新知识的能力` + +你是否有感受过小时候在什么都还不会的时候接受知识的能力很强,但随着我们开始长大后,慢慢学习能力、处事方式、性格品行,往往会固定。一方面是形成了各自的性格特征,一方面是圈子已经固定。但也正因为这样的故步,而很少愿意听取别人的意见,就像即使看到了一整片内容,在视觉盲区下也会过掉到80%,就在眼前也看不见,也因此导致了能力不再有较大的提升。 + +`编程能力怎样会成长的最快` + +工作内容往往有些像在工厂🏭拧螺丝,大部分内容是重复的,也可以想象过去的一年你有过多少创新和学习了新的技能。那么这时候一般为了多学些内容会买一些技术书籍,但!技术类书籍和其他书籍不同,只要不去用看了也就只是轻描淡写,很难接纳和理解。就像设计模式,虽然可能看了几遍,但是在实际编码中仍然很少会用,大部分原因还是没有认认真真的跟着实操。事必躬亲才是学习编程的最好是方式。 + +## 二、开发环境 + +1. JDK 1.8 +2. Idea + Maven +3. 涉及工程三个,可以通过关注**公众号**:[`bugstack虫洞栈`](https://bugstack.cn/assets/images/qrcode.png),回复`源码下载`获取(打开获取的链接,找到序号18) + +| 工程 | 描述 | +| ------------------------ | --------------------------------- | +| itstack-demo-design-11-01 | 使用一坨代码实现业务需求 | +| itstack-demo-design-11-02 | 通过设计模式优化代码结构,减少内存使用和查询耗时 | + +## 三、享元模式介绍 + +![享元模式,图片来自 refactoringguru.cn](https://bugstack.cn/assets/images/2020/itstack-demo-design-11-01.png) + +享元模式,主要在于共享通用对象,减少内存的使用,提升系统的访问效率。而这部分共享对象通常比较耗费内存或者需要查询大量接口或者使用数据库资源,因此统一抽离作为共享对象使用。 + +另外享元模式可以分为在服务端和客户端,一般互联网H5和Web场景下大部分数据都需要服务端进行处理,比如数据库连接池的使用、多线程线程池的使用,除了这些功能外,还有些需要服务端进行包装后的处理下发给客户端,因为服务端需要做享元处理。但在一些游戏场景下,很多都是客户端需要进行渲染地图效果,比如;树木、花草、鱼虫,通过设置不同元素描述使用享元公用对象,减少内存的占用,让客户端的游戏更加流畅。 + +在享元模型的实现中需要使用到享元工厂来进行管理这部分独立的对象和共享的对象,避免出现线程安全的问题。 + +## 四、案例场景模拟 + +![场景模拟;秒杀场景下商品查询](https://bugstack.cn/assets/images/2020/itstack-demo-design-11-02.png) + +**在这个案例中我们模拟在商品秒杀场景下使用享元模式查询优化** + +你是否经历过一个商品下单的项目从最初的日均十几单到一个月后每个时段秒杀量破十万的项目。一般在最初如果没有经验的情况下可能会使用数据库行级锁的方式下保证商品库存的扣减操作,但是随着业务的快速发展秒杀的用户越来越多,这个时候数据库已经扛不住了,一般都会使用redis的分布式锁来控制商品库存。 + +同时在查询的时候也不需要每一次对不同的活动查询都从库中获取,因为这里除了库存以外其他的活动商品信息都是固定不变的,以此这里一般大家会缓存到内存中。 + +这里我们模拟使用享元模式工厂结构,提供活动商品的查询。活动商品相当于不变的信息,而库存部分属于变化的信息。 + +## 五、用一坨坨代码实现 + +`逻辑很简单,就怕你写乱。一片片的固定内容和变化内容的查询组合,CV的哪里都是!` + +其实这部分逻辑的查询在一般情况很多程序员都是先查询固定信息,在使用过滤的或者添加if判断的方式补充变化的信息,也就是库存。这样写最开始并不会看出来有什么问题,但随着方法逻辑的增加,后面就越来越多重复的代码。 + +### 1. 工程结构 + +```java +itstack-demo-design-11-01 +└── src + └── main + └── java + └── org.itstack.demo.design + └── ActivityController.java +``` + +- 以上工程结构比较简单,之后一个控制类用于查询活动信息。 + +### 2. 代码实现 + +```java +/** + * 博客:https://bugstack.cn - 沉淀、分享、成长,让自己和他人都能有所收获! + * 公众号:bugstack虫洞栈 + * Create by 小傅哥(fustack) @2020 + */ +public class ActivityController { + + public Activity queryActivityInfo(Long id) { + // 模拟从实际业务应用从接口中获取活动信息 + Activity activity = new Activity(); + activity.setId(10001L); + activity.setName("图书嗨乐"); + activity.setDesc("图书优惠券分享激励分享活动第二期"); + activity.setStartTime(new Date()); + activity.setStopTime(new Date()); + activity.setStock(new Stock(1000,1)); + return activity; + } + +} +``` + +- 这里模拟的是从接口中查询活动信息,基本也就是从数据库中获取所有的商品信息和库存。有点像最开始写的商品销售系统,数据库就可以抗住购物量。 +- 当后续因为业务的发展需要扩展代码将库存部分交给redis处理,那么久需要从redis中获取活动的库存,而不是从库中,否则将造成数据不统一的问题。 + +## 六、享元模式重构代码 + +`接下来使用享元模式来进行代码优化,也算是一次很小的重构。` + +享元模式一般情况下使用此结构在平时的开发中并不太多,除了一些线程池、数据库连接池外,再就是游戏场景下的场景渲染。另外这个设计的模式思想是减少内存的使用提升效率,与我们之前使用的**原型模式**通过克隆对象的方式生成复杂对象,减少rpc的调用,都是此类思想。 + +### 1. 工程结构 + +```java +itstack-demo-design-11-02 +└── src + ├── main + │ └── java + │ └── org.itstack.demo.design + │ ├── util + │ │ └── RedisUtils.java + │ ├── Activity.java + │ ├── ActivityController.java + │ ├── ActivityFactory.java + │ └── Stock.java + └── test + └── java + └── org.itstack.demo.test + └── ApiTest.java +``` + +**享元模式模型结构** + +![享元模式模型结构](https://bugstack.cn/assets/images/2020/itstack-demo-design-11-03.png) + +- 以上是我们模拟查询活动场景的类图结构,左侧构建的是享元工厂,提供固定活动数据的查询,右侧是Redis存放的库存数据。 +- 最终交给活动控制类来处理查询操作,并提供活动的所有信息和库存。因为库存是变化的,所以我们模拟的`RedisUtils`中设置了定时任务使用库存。 + +### 2. 代码实现 + +#### 2.1 活动信息 + +```java +/** + * 博客:https://bugstack.cn - 沉淀、分享、成长,让自己和他人都能有所收获! + * 公众号:bugstack虫洞栈 + * Create by 小傅哥(fustack) @2020 + */ +public class Activity { + + private Long id; // 活动ID + private String name; // 活动名称 + private String desc; // 活动描述 + private Date startTime; // 开始时间 + private Date stopTime; // 结束时间 + private Stock stock; // 活动库存 + + // ...get/set +} +``` + +- 这里的对象类比较简单,只是一个活动的基础信息;id、名称、描述、时间和库存。 + +#### 2.2 库存信息 + +```java +public class Stock { + + private int total; // 库存总量 + private int used; // 库存已用 + + // ...get/set +} +``` + +- 这里是库存数据我们单独提供了一个类进行保存数据。 + +#### 2.3 享元工厂 + +```java +/** + * 博客:https://bugstack.cn - 沉淀、分享、成长,让自己和他人都能有所收获! + * 公众号:bugstack虫洞栈 + * Create by 小傅哥(fustack) @2020 + */ +public class ActivityFactory { + + static Map activityMap = new HashMap(); + + public static Activity getActivity(Long id) { + Activity activity = activityMap.get(id); + if (null == activity) { + // 模拟从实际业务应用从接口中获取活动信息 + activity = new Activity(); + activity.setId(10001L); + activity.setName("图书嗨乐"); + activity.setDesc("图书优惠券分享激励分享活动第二期"); + activity.setStartTime(new Date()); + activity.setStopTime(new Date()); + activityMap.put(id, activity); + } + return activity; + } + +} +``` + +- 这里提供的是一个享元工厂🏭,通过`map`结构存放已经从库表或者接口中查询到的数据,存放到内存中,用于下次可以直接获取。 +- 这样的结构一般在我们的编程开发中还是比较常见的,当然也有些时候为了分布式的获取,会把数据存放到redis中,可以按需选择。 + +#### 2.4 模拟Redis类 + +```java +/** + * 博客:https://bugstack.cn - 沉淀、分享、成长,让自己和他人都能有所收获! + * 公众号:bugstack虫洞栈 + * Create by 小傅哥(fustack) @2020 + */ +public class RedisUtils { + + private ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1); + + private AtomicInteger stock = new AtomicInteger(0); + + public RedisUtils() { + scheduledExecutorService.scheduleAtFixedRate(() -> { + // 模拟库存消耗 + stock.addAndGet(1); + }, 0, 100000, TimeUnit.MICROSECONDS); + + } + + public int getStockUsed() { + return stock.get(); + } + +} +``` + +- 这里处理模拟`redis`的操作工具类外,还提供了一个定时任务用于模拟库存的使用,这样方面我们在测试的时候可以观察到库存的变化。 + +#### 2.4 活动控制类 + +```java +/** + * 博客:https://bugstack.cn - 沉淀、分享、成长,让自己和他人都能有所收获! + * 公众号:bugstack虫洞栈 + * Create by 小傅哥(fustack) @2020 + */ +public class ActivityController { + + private RedisUtils redisUtils = new RedisUtils(); + + public Activity queryActivityInfo(Long id) { + Activity activity = ActivityFactory.getActivity(id); + // 模拟从Redis中获取库存变化信息 + Stock stock = new Stock(1000, redisUtils.getStockUsed()); + activity.setStock(stock); + return activity; + } + +} +``` + +- 在活动控制类中使用了享元工厂获取活动信息,查询后将库存信息在补充上。因为库存信息是变化的,而活动信息是固定不变的。 +- 最终通过统一的控制类就可以把完整包装后的活动信息返回给调用方。 + +### 3. 测试验证 + +#### 3.1 编写测试类 + +```java +public class ApiTest { + + private Logger logger = LoggerFactory.getLogger(ApiTest.class); + + private ActivityController activityController = new ActivityController(); + + @Test + public void test_queryActivityInfo() throws InterruptedException { + for (int idx = 0; idx < 10; idx++) { + Long req = 10001L; + Activity activity = activityController.queryActivityInfo(req); + logger.info("测试结果:{} {}", req, JSON.toJSONString(activity)); + Thread.sleep(1200); + } + } + +} +``` + +- 这里我们通过活动查询控制类,在`for`循环的操作下查询了十次活动信息,同时为了保证库存定时任务的变化,加了睡眠操作,实际的开发中不会有这样的睡眠。 + +#### 3.2 测试结果 + +```java +22:35:20.285 [main] INFO org.i..t.ApiTest - 测试结果:10001 {"desc":"图书优惠券分享激励分享活动第二期","id":10001,"name":"图书嗨乐","startTime":1592130919931,"stock":{"total":1000,"used":1},"stopTime":1592130919931} +22:35:21.634 [main] INFO org.i..t.ApiTest - 测试结果:10001 {"desc":"图书优惠券分享激励分享活动第二期","id":10001,"name":"图书嗨乐","startTime":1592130919931,"stock":{"total":1000,"used":18},"stopTime":1592130919931} +22:35:22.838 [main] INFO org.i..t.ApiTest - 测试结果:10001 {"desc":"图书优惠券分享激励分享活动第二期","id":10001,"name":"图书嗨乐","startTime":1592130919931,"stock":{"total":1000,"used":30},"stopTime":1592130919931} +22:35:24.042 [main] INFO org.i..t.ApiTest - 测试结果:10001 {"desc":"图书优惠券分享激励分享活动第二期","id":10001,"name":"图书嗨乐","startTime":1592130919931,"stock":{"total":1000,"used":42},"stopTime":1592130919931} +22:35:25.246 [main] INFO org.i..t.ApiTest - 测试结果:10001 {"desc":"图书优惠券分享激励分享活动第二期","id":10001,"name":"图书嗨乐","startTime":1592130919931,"stock":{"total":1000,"used":54},"stopTime":1592130919931} +22:35:26.452 [main] INFO org.i..t.ApiTest - 测试结果:10001 {"desc":"图书优惠券分享激励分享活动第二期","id":10001,"name":"图书嗨乐","startTime":1592130919931,"stock":{"total":1000,"used":66},"stopTime":1592130919931} +22:35:27.655 [main] INFO org.i..t.ApiTest - 测试结果:10001 {"desc":"图书优惠券分享激励分享活动第二期","id":10001,"name":"图书嗨乐","startTime":1592130919931,"stock":{"total":1000,"used":78},"stopTime":1592130919931} +22:35:28.859 [main] INFO org.i..t.ApiTest - 测试结果:10001 {"desc":"图书优惠券分享激励分享活动第二期","id":10001,"name":"图书嗨乐","startTime":1592130919931,"stock":{"total":1000,"used":90},"stopTime":1592130919931} +22:35:30.063 [main] INFO org.i..t.ApiTest - 测试结果:10001 {"desc":"图书优惠券分享激励分享活动第二期","id":10001,"name":"图书嗨乐","startTime":1592130919931,"stock":{"total":1000,"used":102},"stopTime":1592130919931} +22:35:31.268 [main] INFO org.i..t.ApiTest - 测试结果:10001 {"desc":"图书优惠券分享激励分享活动第二期","id":10001,"name":"图书嗨乐","startTime":1592130919931,"stock":{"total":1000,"used":114},"stopTime":1592130919931} + +Process finished with exit code 0 +``` + +- 可以仔细看下`stock`部分的库存是一直在变化的,其他部分是活动信息,是固定的,所以我们使用享元模式来将这样的结构进行拆分。 + +## 七、总结 + +- 关于享元模式的设计可以着重学习享元工厂的设计,在一些有大量重复对象可复用的场景下,使用此场景在服务端减少接口的调用,在客户端减少内存的占用。是这个设计模式的主要应用方式。 +- 另外通过`map`结构的使用方式也可以看到,使用一个固定id来存放和获取对象,是非常关键的点。而且不只是在享元模式中使用,一些其他工厂模式、适配器模式、组合模式中都可以通过map结构存放服务供外部获取,减少ifelse的判断使用。 +- 当然除了这种设计的减少内存的使用优点外,也有它带来的缺点,在一些复杂的业务处理场景,很不容易区分出内部和外部状态,就像我们活动信息部分与库存变化部分。如果不能很好的拆分,就会把享元工厂设计的非常混乱,难以维护。 + diff --git "a/doc/2020-06-11-\351\207\215\345\255\246 Java \350\256\276\350\256\241\346\250\241\345\274\217\343\200\212\345\256\236\346\210\230\345\244\226\350\247\202\346\250\241\345\274\217\343\200\213.md" "b/doc/2020-06-11-\351\207\215\345\255\246 Java \350\256\276\350\256\241\346\250\241\345\274\217\343\200\212\345\256\236\346\210\230\345\244\226\350\247\202\346\250\241\345\274\217\343\200\213.md" new file mode 100755 index 0000000000000000000000000000000000000000..6ea634949c53dbef918c208dd0074719c5bc6b7c --- /dev/null +++ "b/doc/2020-06-11-\351\207\215\345\255\246 Java \350\256\276\350\256\241\346\250\241\345\274\217\343\200\212\345\256\236\346\210\230\345\244\226\350\247\202\346\250\241\345\274\217\343\200\213.md" @@ -0,0 +1,483 @@ +--- +layout: post +category: itstack-demo-design +title: 重学 Java 设计模式:实战外观模式「基于SpringBoot开发门面模式中间件,统一控制接口白名单场景」 +tagline: by 小傅哥 +tag: [itstack-demo-design] +excerpt: 刀枪棍棒、斧钺钩叉、包子油条、盒子麻花,是语言。五郎八卦棍、十二路弹腿、洪家铁线拳,是设计。就像叶问打败金山找,不是南北拳的输赢,而是个人的武术修为之差。 +lock: need +--- + +作者:小傅哥 +
博客:[https://bugstack.cn](https://bugstack.cn) + +>沉淀、分享、成长,让自己和他人都能有所收获!😄 + +## 一、前言 + +`你感受到的容易,一定有人为你承担不容易` + +这句话更像是描述生活的,许许多多的磕磕绊绊总有人为你提供躲雨的屋檐和避风的港湾。其实编程开发的团队中也一样有人只负责CRUD中的简单调用,去使用团队中高级程序员开发出来的核心服务和接口。这样的编程开发对于初期刚进入程序员行业的小伙伴来说锻炼锻炼还是不错的,但随着开发的日子越来越久一直做这样的事情就很难得到成长,也想努力的去做一些更有难度的承担,以此来增强个人的技术能力。 + +`没有最好的编程语言,语言只是工具` + +刀枪棍棒、斧钺钩叉、包子油条、盒子麻花,是**语言**。五郎八卦棍、十二路弹腿、洪家铁线拳,是**设计**。记得叶问里有一句台词是:`金山找:今天我北方拳术,输给你南方拳术了。叶问:你错了,不是南北拳的问题,是你的问题。`所以当你编程开发写的久了,就不会再特别在意用的语言,而是为目标服务,用最好的设计能力也就是编程的智慧做出做最完美的服务。这也就是编程人员的价值所在! + +`设计与反设计以及过渡设计` + +设计模式是解决程序中不合理、不易于扩展、不易于维护的问题,也是干掉大部分`ifelse`的利器,在我们常用的框架中基本都会用到大量的设计模式来构建组件,这样也能方便框架的升级和功能的扩展。但!如果不能合理的设计以及乱用设计模式,会导致整个编程变得更加复杂难维护,也就是我们常说的;`反设计`、`过渡设计`。而这部分设计能力也是从实践的项目中获取的经验,不断的改造优化摸索出的最合理的方式,应对当前的服务体量。 + +## 二、开发环境 + +1. JDK 1.8 +2. Idea + Maven +3. SpringBoot 2.1.2.RELEASE +4. 涉及工程三个,可以通过关注**公众号**:[`bugstack虫洞栈`](https://bugstack.cn/assets/images/qrcode.png),回复`源码下载`获取(打开获取的链接,找到序号18) + +| 工程 | 描述 | +| ------------------------ | --------------------------------- | +| itstack-demo-design-10-00 | 场景模拟工程;模拟一个提供接口服务的SpringBoot工程 | +| itstack-demo-design-10-01 | 使用一坨代码实现业务需求 | +| itstack-demo-design-10-02 | 通过设计模式开发为中间件,包装通用型核心逻辑 | + +## 三、外观模式介绍 + +![外观模式,图片来自 refactoringguru.cn](https://bugstack.cn/assets/images/2020/itstack-demo-design-10-01.png) + +外观模式也叫门面模式,主要解决的是降低调用方的使用接口的复杂逻辑组合。这样调用方与实际的接口提供方提供方提供了一个中间层,用于包装逻辑提供API接口。有些时候外观模式也被用在中间件层,对服务中的通用性复杂逻辑进行中间件层包装,让使用方可以只关心业务开发。 + +**那么这样的模式在我们的所见产品功能中也经常遇到**,就像几年前我们注册一个网站时候往往要添加很多信息,包括;姓名、昵称、手机号、QQ、邮箱、住址、单身等等,但现在注册成为一个网站的用户只需要一步即可,无论是手机号还是微信也都提供了这样的登录服务。而对于服务端应用开发来说以前是提供了一个整套的接口,现在注册的时候并没有这些信息,那么服务端就需要进行接口包装,在前端调用注册的时候服务端获取相应的用户信息(从各个渠道),如果获取不到会让用户后续进行补全(营销补全信息给奖励),以此来拉动用户的注册量和活跃度。 + +## 四、案例场景模拟 + +![场景模拟;所有服务添加白名单校验](https://bugstack.cn/assets/images/2020/itstack-demo-design-10-02.png) + +**在本案例中我们模拟一个将所有服务接口添加白名单的场景** + +在项目不断壮大发展的路上,每一次发版上线都需要进行测试,而这部分测试验证一般会进行白名单开量或者切量的方式进行验证。那么如果在每一个接口中都添加这样的逻辑,就会非常麻烦且不易维护。另外这是一类具备通用逻辑的共性需求,非常适合开发成组件,以此来治理服务,让研发人员更多的关心业务功能开发。 + +一般情况下对于外观模式的使用通常是用在复杂或多个接口进行包装统一对外提供服务上,此种使用方式也相对简单在我们平常的业务开发中也是最常用的。*你可能经常听到把这两个接口包装一下*,但在本例子中我们把这种设计思路放到中间件层,让服务变得可以统一控制。 + +### 1. 场景模拟工程 + +```java +itstack-demo-design-10-00 +└── src + ├── main + │ ├── java + │ │ └── org.itstack.demo.design + │ │ ├── domain + │ │ │ └── UserInfo.java + │ │ ├── web + │ │ │ └── HelloWorldController.java + │ │ └── HelloWorldApplication.java + │ └── resources + │ └── application.yml + └── test + └── java + └── org.itstack.demo.test + └── ApiTest.java +``` + +- 这是一个`SpringBoot`的`HelloWorld`工程,在工程中提供了查询用户信息的接口`HelloWorldController.queryUserInfo`,为后续扩展此接口的白名单过滤做准备。 + +### 2. 场景简述 + +#### 2.1 定义基础查询接口 + +```java +@RestController +public class HelloWorldController { + + @Value("${server.port}") + private int port; + + /** + * key:需要从入参取值的属性字段,如果是对象则从对象中取值,如果是单个值则直接使用 + * returnJson:预设拦截时返回值,是返回对象的Json + * + * http://localhost:8080/api/queryUserInfo?userId=1001 + * http://localhost:8080/api/queryUserInfo?userId=小团团 + */ + @RequestMapping(path = "/api/queryUserInfo", method = RequestMethod.GET) + public UserInfo queryUserInfo(@RequestParam String userId) { + return new UserInfo("虫虫:" + userId, 19, "天津市南开区旮旯胡同100号"); + } + +} +``` + +- 这里提供了一个基本的查询服务,通过入参`userId`,查询用户信息。后续就需要在这里扩展白名单,只有指定用户才可以查询,其他用户不能查询。 + +#### 2.2 设置Application启动类 + +```java +@SpringBootApplication +@Configuration +public class HelloWorldApplication { + + public static void main(String[] args) { + SpringApplication.run(HelloWorldApplication.class, args); + } + +} +``` + +- 这里是通用的`SpringBoot`启动类。需要添加的是一个配置注解`@Configuration`,为了后续可以读取白名单配置。 + +## 五、用一坨坨代码实现 + +`一般对于此种场景最简单的做法就是直接修改代码` + +累加`if`块几乎是实现需求最快也是最慢的方式,**快**是修改当前内容很快,**慢**是如果同类的内容几百个也都需要如此修改扩展和维护会越来越慢。 + +### 1. 工程结构 + +```java +itstack-demo-design-10-01 +└── src + └── main + └── java + └── org.itstack.demo.design + └── HelloWorldController.java +``` + +- 以上的实现是模拟一个Api接口类,在里面添加白名单功能,但类似此类的接口会有很多都需要修改,所以这也是不推荐使用此种方式的重要原因。 + +### 2. 代码实现 + +```java +public class HelloWorldController { + + public UserInfo queryUserInfo(@RequestParam String userId) { + + // 做白名单拦截 + List userList = new ArrayList(); + userList.add("1001"); + userList.add("aaaa"); + userList.add("ccc"); + if (!userList.contains(userId)) { + return new UserInfo("1111", "非白名单可访问用户拦截!"); + } + + return new UserInfo("虫虫:" + userId, 19, "天津市南开区旮旯胡同100号"); + } + +} +``` + +- 在这里白名单的代码占据了一大块,但它又不是业务中的逻辑,而是因为我们上线过程中需要做的开量前测试验证。 +- 如果你日常对待此类需求经常是这样开发,那么可以按照此设计模式进行优化你的处理方式,让后续的扩展和摘除更加容易。 + +## 六、外观模式重构代码 + +`接下来使用外观器模式来进行代码优化,也算是一次很小的重构。` + +这次重构的核心是使用外观模式也可以说门面模式,结合`SpringBoot`中的自定义`starter`中间件开发的方式,统一处理所有需要白名单的地方。 + +后续接下来的实现中,会涉及的知识; +1. SpringBoot的starter中间件开发方式。 +2. 面向切面编程和自定义注解的使用。 +3. 外部自定义配置信息的透传,SpringBoot与Spring不同,对于此类方式获取白名单配置存在差异。 + +### 1. 工程结构 + +```java +itstack-demo-design-10-02 +└── src + ├── main + │ ├── java + │ │ └── org.itstack.demo.design.door + │ │ ├── annotation + │ │ │ └── DoDoor.java + │ │ ├── config + │ │ │ ├── StarterAutoConfigure.java + │ │ │ ├── StarterService.java + │ │ │ └── StarterServiceProperties.java + │ │ └── DoJoinPoint.java + │ └── resources + │ └── META_INF + │ └── spring.factories + └── test + └── java + └── org.itstack.demo.test + └── ApiTest.java +``` + +**门面模式模型结构** + +![门面模式模型结构](https://bugstack.cn/assets/images/2020/itstack-demo-design-10-03.png) + +- 以上是外观模式的中间件实现思路,右侧是为了获取配置文件,左侧是对于切面的处理。 +- 门面模式可以是对接口的包装提供出接口服务,也可以是对逻辑的包装通过自定义注解对接口提供服务能力。 + +### 2. 代码实现 + +#### 2.1 配置服务类 + +```java +public class StarterService { + + private String userStr; + + public StarterService(String userStr) { + this.userStr = userStr; + } + + public String[] split(String separatorChar) { + return StringUtils.split(this.userStr, separatorChar); + } + +} +``` + +- 以上类的内容较简单只是为了获取配置信息。 + +#### 2.2 配置类注解定义 + +```java +@ConfigurationProperties("itstack.door") +public class StarterServiceProperties { + + private String userStr; + + public String getUserStr() { + return userStr; + } + + public void setUserStr(String userStr) { + this.userStr = userStr; + } + +} +``` + +- 用于定义好后续在 `application.yml` 中添加 `itstack.door` 的配置信息。 + +#### 2.3 自定义配置类信息获取 + +```java +@Configuration +@ConditionalOnClass(StarterService.class) +@EnableConfigurationProperties(StarterServiceProperties.class) +public class StarterAutoConfigure { + + @Autowired + private StarterServiceProperties properties; + + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty(prefix = "itstack.door", value = "enabled", havingValue = "true") + StarterService starterService() { + return new StarterService(properties.getUserStr()); + } + +} +``` + +- 以上代码是对配置的获取操作,主要是对注解的定义;`@Configuration`、`@ConditionalOnClass`、`@EnableConfigurationProperties`,这一部分主要是与SpringBoot的结合使用。 + +#### 2.4 切面注解定义 + +```java +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface DoDoor { + + String key() default ""; + + String returnJson() default ""; + +} +``` + +- 定义了外观模式门面注解,后续就是此注解添加到需要扩展白名单的方法上。 +- 这里提供了两个入参,**key**:获取某个字段例如用户ID、**returnJson**:确定白名单拦截后返回的具体内容。 + +#### 2.5 白名单切面逻辑 + +```java +@Aspect +@Component +public class DoJoinPoint { + + private Logger logger = LoggerFactory.getLogger(DoJoinPoint.class); + + @Autowired + private StarterService starterService; + + @Pointcut("@annotation(org.itstack.demo.design.door.annotation.DoDoor)") + public void aopPoint() { + } + + @Around("aopPoint()") + public Object doRouter(ProceedingJoinPoint jp) throws Throwable { + //获取内容 + Method method = getMethod(jp); + DoDoor door = method.getAnnotation(DoDoor.class); + //获取字段值 + String keyValue = getFiledValue(door.key(), jp.getArgs()); + logger.info("itstack door handler method:{} value:{}", method.getName(), keyValue); + if (null == keyValue || "".equals(keyValue)) return jp.proceed(); + //配置内容 + String[] split = starterService.split(","); + //白名单过滤 + for (String str : split) { + if (keyValue.equals(str)) { + return jp.proceed(); + } + } + //拦截 + return returnObject(door, method); + } + + private Method getMethod(JoinPoint jp) throws NoSuchMethodException { + Signature sig = jp.getSignature(); + MethodSignature methodSignature = (MethodSignature) sig; + return getClass(jp).getMethod(methodSignature.getName(), methodSignature.getParameterTypes()); + } + + private Class getClass(JoinPoint jp) throws NoSuchMethodException { + return jp.getTarget().getClass(); + } + + //返回对象 + private Object returnObject(DoDoor doGate, Method method) throws IllegalAccessException, InstantiationException { + Class returnType = method.getReturnType(); + String returnJson = doGate.returnJson(); + if ("".equals(returnJson)) { + return returnType.newInstance(); + } + return JSON.parseObject(returnJson, returnType); + } + + //获取属性值 + private String getFiledValue(String filed, Object[] args) { + String filedValue = null; + for (Object arg : args) { + try { + if (null == filedValue || "".equals(filedValue)) { + filedValue = BeanUtils.getProperty(arg, filed); + } else { + break; + } + } catch (Exception e) { + if (args.length == 1) { + return args[0].toString(); + } + } + } + return filedValue; + } + +} +``` + +- 这里包括的内容较多,核心逻辑主要是;`Object doRouter(ProceedingJoinPoint jp)`,接下来我们分别介绍。 + +**@Pointcut("@annotation(org.itstack.demo.design.door.annotation.DoDoor)")** + +定义切面,这里采用的是注解路径,也就是所有的加入这个注解的方法都会被切面进行管理。 + +**getFiledValue** + +获取指定key也就是获取入参中的某个属性,这里主要是获取用户ID,通过ID进行拦截校验。 + +**returnObject** + +返回拦截后的转换对象,也就是说当非白名单用户访问时则返回一些提示信息。 + +**doRouter** + +切面核心逻辑,这一部分主要是判断当前访问的用户ID是否白名单用户,如果是则放行`jp.proceed();`,否则返回自定义的拦截提示信息。 + +### 3. 测试验证 + +这里的测试我们会在工程:`itstack-demo-design-10-00`中进行操作,通过引入jar包,配置注解的方式进行验证。 + +#### 3.1 引入中间件POM配置 + +```java + + org.springframework.boot + itstack-demo-design-10-02 + +``` + +- 打包中间件工程,给外部提供jar包服务 + +#### 3.2 配置application.yml + +```java +# 自定义中间件配置 +itstack: + door: + enabled: true + userStr: 1001,aaaa,ccc #白名单用户ID,多个逗号隔开 +``` + +- 这里主要是加入了白名单的开关和白名单的用户ID,逗号隔开。 + +#### 3.3 在Controller中添加自定义注解 + +```java +/** + * http://localhost:8080/api/queryUserInfo?userId=1001 + * http://localhost:8080/api/queryUserInfo?userId=小团团 + */ +@DoDoor(key = "userId", returnJson = "{\"code\":\"1111\",\"info\":\"非白名单可访问用户拦截!\"}") +@RequestMapping(path = "/api/queryUserInfo", method = RequestMethod.GET) +public UserInfo queryUserInfo(@RequestParam String userId) { + return new UserInfo("虫虫:" + userId, 19, "天津市南开区旮旯胡同100号"); +} +``` + +- 这里核心的内容主要是自定义的注解的添加`@DoDoor`,也就是我们的外观模式中间件化实现。 +- key:需要从入参取值的属性字段,如果是对象则从对象中取值,如果是单个值则直接使用。 +- returnJson:预设拦截时返回值,是返回对象的Json。 + +#### 3.4 启动SpringBoot + +```java + . ____ _ __ _ _ + /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \ +( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ + \\/ ___)| |_)| | | | | || (_| | ) ) ) ) + ' |____| .__|_| |_|_| |_\__, | / / / / + =========|_|==============|___/=/_/_/_/ + :: Spring Boot :: (v2.1.2.RELEASE) + +2020-06-11 23:56:55.451 WARN 65228 --- [ main] ion$DefaultTemplateResolverConfiguration : Cannot find template location: classpath:/templates/ (please add some templates or check your Thymeleaf configuration) +2020-06-11 23:56:55.531 INFO 65228 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path '' +2020-06-11 23:56:55.533 INFO 65228 --- [ main] o.i.demo.design.HelloWorldApplication : Started HelloWorldApplication in 1.688 seconds (JVM running for 2.934) +``` + +- 启动正常,SpringBoot已经启动可以对外提供服务。 + +#### 3.5 访问接口接口测试 + +**白名单用户访问** + +[http://localhost:8080/api/queryUserInfo?userId=1001](http://localhost:8080/api/queryUserInfo?userId=1001) + +```java +{"code":"0000","info":"success","name":"虫虫:1001","age":19,"address":"天津市南开区旮旯胡同100号"} +``` + +- 此时的测试结果正常,可以拿到接口数据。 + +**非白名单用户访问** + +[http://localhost:8080/api/queryUserInfo?userId=小团团](http://localhost:8080/api/queryUserInfo?userId=小团团) + +```java +{"code":"1111","info":"非白名单可访问用户拦截!","name":null,"age":null,"address":null} +``` + +- 这次我们把`userId`换成`小团团`,此时返回的信息已经是被拦截的信息。而这个拦截信息正式我们自定义注解中的信息:`@DoDoor(key = "userId", returnJson = "{\"code\":\"1111\",\"info\":\"非白名单可访问用户拦截!\"}")` + +## 七、总结 + +- 以上我们通过中间件的方式实现外观模式,这样的设计可以很好的增强代码的隔离性,以及复用性,不仅使用上非常灵活也降低了每一个系统都开发这样的服务带来的风险。 +- 可能目前你看这只是非常简单的白名单控制,是否需要这样的处理。但往往一个小小的开始会影响着后续无限的扩展,实际的业务开发往往也要复杂的很多,不可能如此简单。因而使用设计模式来让代码结构更加干净整洁。 +- 很多时候不是设计模式没有用,而是自己编程开发经验不足导致即使学了设计模式也很难驾驭。毕竟这些知识都是经过一些实际操作提炼出来的精华,但如果你可以按照本系列文章中的案例方式进行学习实操,还是可以增强这部分设计能力的。 \ No newline at end of file diff --git a/doc/itstack-demo-design-11-01.png b/doc/itstack-demo-design-11-01.png new file mode 100644 index 0000000000000000000000000000000000000000..a7e5942da4037537336dea12d2a292bf22c662f9 Binary files /dev/null and b/doc/itstack-demo-design-11-01.png differ diff --git a/doc/itstack-demo-design-11-02.png b/doc/itstack-demo-design-11-02.png new file mode 100644 index 0000000000000000000000000000000000000000..cf81e1f39af6240959907d8e91c57671d6343b1c Binary files /dev/null and b/doc/itstack-demo-design-11-02.png differ diff --git a/doc/itstack-demo-design-11-03.png b/doc/itstack-demo-design-11-03.png new file mode 100644 index 0000000000000000000000000000000000000000..89fccaf314730b11aebd10a0416cce4305316bf2 Binary files /dev/null and b/doc/itstack-demo-design-11-03.png differ diff --git a/itstack-demo-design-12-00/pom.xml b/itstack-demo-design-12-00/pom.xml new file mode 100755 index 0000000000000000000000000000000000000000..2f052116af679b595f34b4d5fe209dc9a22ed879 --- /dev/null +++ b/itstack-demo-design-12-00/pom.xml @@ -0,0 +1,295 @@ + + + + itstack-demo-design + org.itstack + 1.0-SNAPSHOT + + 4.0.0 + + itstack-demo-design-12-00 + + + + 1.8 + UTF-8 + + 3.8.1 + 1.2.60 + 4.12 + 1.0.9 + 5.1.20 + 2.2.1.GA + 1.7.7 + 4.3.24.RELEASE + 3.1.1.RELEASE + 2.5 + 3.4.6 + 1.3.2 + + + + + org.quartz-scheduler + quartz + 2.3.2 + + + org.aspectj + aspectjweaver + 1.6.12 + + + org.aspectj + aspectjrt + 1.6.12 + + + + org.apache.commons + commons-dbcp2 + 2.5.0 + + + org.apache.commons + commons-pool2 + 2.5.0 + + + + org.mybatis + mybatis-spring + ${mybatis-spring.version} + + + + org.mybatis + mybatis + ${mybatis.version} + + + + org.apache.commons + commons-lang3 + ${commons-lang3.version} + + + + com.alibaba + fastjson + ${fastjson.version} + + + + junit + junit + ${junit.version} + test + + + + ch.qos.logback + logback-classic + ${logback.version} + + + slf4j-api + org.slf4j + + + + + + mysql + mysql-connector-java + ${mysql.version} + + + + org.jboss.resteasy + resteasy-jaxrs + ${resteasy.version} + + + org.jboss.resteasy + jaxrs-api + ${resteasy.version} + + + org.jboss.resteasy + resteasy-jaxb-provider + ${resteasy.version} + + + org.jboss.resteasy + resteasy-spring + ${resteasy.version} + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + org.springframework + spring-core + ${spring.version} + + + org.springframework + spring-test + ${spring.version} + test + + + org.springframework + spring-context + ${spring.version} + + + org.springframework + spring-context-support + ${spring.version} + + + org.springframework + spring-beans + ${spring.version} + + + org.springframework + spring-jdbc + ${spring.version} + + + org.springframework + spring-aop + ${spring.version} + + + org.springframework + spring-orm + ${spring-orm.version} + + + org.springframework + spring-web + ${spring.version} + + + org.springframework + spring-aspects + ${spring.version} + + + org.springframework + spring-tx + ${spring.version} + + + javax.servlet + servlet-api + ${servlet-api.version} + provided + + + + + + + src/main/resources + true + + + src/assembly/resources + false + + + + + src/test/resources + true + + + src/main/resources + true + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 2.3.2 + + ${jdk.version} + ${jdk.version} + ${sourceEncoding} + true + + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.6 + + true + + + **/*Test.java + + + + + + org.apache.maven.plugins + maven-war-plugin + 2.1.1 + + + + org.apache.maven.plugins + maven-jar-plugin + 2.3.1 + + + org.apache.maven.plugins + maven-eclipse-plugin + 2.8 + + + + org.apache.maven.plugins + maven-resources-plugin + 2.4.3 + + ${sourceEncoding} + + + + + maven-source-plugin + 2.0.4 + + + attach-sources + + jar + + + + + + org.zeroturnaround + jrebel-maven-plugin + 1.1.3 + + + + + \ No newline at end of file diff --git a/itstack-demo-design-12-00/src/main/java/org/itstack/demo/design/IUserDao.java b/itstack-demo-design-12-00/src/main/java/org/itstack/demo/design/IUserDao.java new file mode 100755 index 0000000000000000000000000000000000000000..8e6c73932c771860a69b3aa39442b058976975f5 --- /dev/null +++ b/itstack-demo-design-12-00/src/main/java/org/itstack/demo/design/IUserDao.java @@ -0,0 +1,10 @@ +package org.itstack.demo.design; + +import org.itstack.demo.design.agent.Select; + +public interface IUserDao { + + @Select("select userName from user where id = #{uId}") + String queryUserInfo(String uId); + +} diff --git a/itstack-demo-design-12-00/src/main/java/org/itstack/demo/design/agent/MapperFactoryBean.java b/itstack-demo-design-12-00/src/main/java/org/itstack/demo/design/agent/MapperFactoryBean.java new file mode 100755 index 0000000000000000000000000000000000000000..8bc84980ad06d4b8222eec3bdcf206229c5e105c --- /dev/null +++ b/itstack-demo-design-12-00/src/main/java/org/itstack/demo/design/agent/MapperFactoryBean.java @@ -0,0 +1,40 @@ +package org.itstack.demo.design.agent; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.FactoryBean; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; + +public class MapperFactoryBean implements FactoryBean { + + private Logger logger = LoggerFactory.getLogger(MapperFactoryBean.class); + + private Class mapperInterface; + + public MapperFactoryBean(Class mapperInterface) { + this.mapperInterface = mapperInterface; + } + + @Override + public T getObject() throws Exception { + InvocationHandler handler = (proxy, method, args) -> { + Select select = method.getAnnotation(Select.class); + logger.info("SQL:{}", select.value().replace("#{uId}", args[0].toString())); + return args[0] + ",小傅哥,bugstack.cn - 沉淀、分享、成长,让自己和他人都能有所收获!"; + }; + return (T) Proxy.newProxyInstance(this.getClass().getClassLoader(), new Class[]{mapperInterface}, handler); + } + + @Override + public Class getObjectType() { + return mapperInterface; + } + + @Override + public boolean isSingleton() { + return true; + } + +} diff --git a/itstack-demo-design-12-00/src/main/java/org/itstack/demo/design/agent/RegisterBeanFactory.java b/itstack-demo-design-12-00/src/main/java/org/itstack/demo/design/agent/RegisterBeanFactory.java new file mode 100755 index 0000000000000000000000000000000000000000..d7fa59775b902b0e5d100fe18da075de0a7561c0 --- /dev/null +++ b/itstack-demo-design-12-00/src/main/java/org/itstack/demo/design/agent/RegisterBeanFactory.java @@ -0,0 +1,31 @@ +package org.itstack.demo.design.agent; + +import org.itstack.demo.design.IUserDao; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanDefinitionHolder; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.BeanDefinitionReaderUtils; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; +import org.springframework.beans.factory.support.GenericBeanDefinition; + +public class RegisterBeanFactory implements BeanDefinitionRegistryPostProcessor { + + @Override + public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException { + + GenericBeanDefinition beanDefinition = new GenericBeanDefinition(); + beanDefinition.setBeanClass(MapperFactoryBean.class); + beanDefinition.setScope("singleton"); + beanDefinition.getConstructorArgumentValues().addGenericArgumentValue(IUserDao.class); + + BeanDefinitionHolder definitionHolder = new BeanDefinitionHolder(beanDefinition, "userDao"); + BeanDefinitionReaderUtils.registerBeanDefinition(definitionHolder, registry); + } + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory configurableListableBeanFactory) throws BeansException { + // left intentionally blank + } + +} diff --git a/itstack-demo-design-12-00/src/main/java/org/itstack/demo/design/agent/Select.java b/itstack-demo-design-12-00/src/main/java/org/itstack/demo/design/agent/Select.java new file mode 100755 index 0000000000000000000000000000000000000000..f0016fc903b0463c87f5085d7adda585d0e4778a --- /dev/null +++ b/itstack-demo-design-12-00/src/main/java/org/itstack/demo/design/agent/Select.java @@ -0,0 +1,12 @@ +package org.itstack.demo.design.agent; + +import java.lang.annotation.*; + +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD}) +public @interface Select { + + String value() default ""; + +} diff --git a/itstack-demo-design-12-00/src/main/resources/spring-config.xml b/itstack-demo-design-12-00/src/main/resources/spring-config.xml new file mode 100755 index 0000000000000000000000000000000000000000..fb289497cb3ce3199293994b694e74e25206ef58 --- /dev/null +++ b/itstack-demo-design-12-00/src/main/resources/spring-config.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/itstack-demo-design-12-00/src/test/java/org/itstack/demo/design/test/ApiTest.java b/itstack-demo-design-12-00/src/test/java/org/itstack/demo/design/test/ApiTest.java new file mode 100755 index 0000000000000000000000000000000000000000..4665e86500788836b0ab53a95de0d607dcd1eb19 --- /dev/null +++ b/itstack-demo-design-12-00/src/test/java/org/itstack/demo/design/test/ApiTest.java @@ -0,0 +1,22 @@ +package org.itstack.demo.design.test; + +import org.itstack.demo.design.IUserDao; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.context.support.ClassPathXmlApplicationContext; + +public class ApiTest { + + private Logger logger = LoggerFactory.getLogger(ApiTest.class); + + @Test + public void test_IUserDao() { + BeanFactory beanFactory = new ClassPathXmlApplicationContext("spring-config.xml"); + IUserDao userDao = beanFactory.getBean("userDao", IUserDao.class); + String res = userDao.queryUserInfo("100001"); + logger.info("测试结果:{}", res); + } + +} diff --git a/pom.xml b/pom.xml index dac7fcf1e490ce81e27560fbffab3e818f5809a2..23a63519ef4e3117d5fa58a7181aaf88e9763b36 100755 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,7 @@ itstack-demo-design-10-02 itstack-demo-design-11-02 itstack-demo-design-11-01 + itstack-demo-design-12-00