开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

让你的Python(Web应用)飞起来,(异步/协程)全家桶

发表于 2019-05-26

一、前言

Python语言近年来人气爆棚。它广泛应用于网络开发运营,数据科学,网络开发,以及网络安全问题中。

然而,Python在速度上完全没有优势可言。

在速度上,Java如何同C,C++,C#或者Python相比较?答案几乎完全取决于要运行的应用。在这个问题上,没有完美的评判标准,然而The Computer Language Benchmarks Game 是一个不错的方法。

我想要回答这样一个问题:当运行同一个程序时,为什么Python会 比其他语言慢2到10倍?为什么我们无法将它变得更快?

以下是最主要的原因:

  • “它是GIL(Global Interpreter Lock全局解释器锁)”
  • “它是解释型语言而非编译语言”
  • “它是动态类型语言”

今天,我们不纠结以上哪种原因对性能影响最大,而是来谈谈当下Python3.7中,我们有怎样的方法让他变得比想象中的快,而且快很多

二、开始探索

Python在3.5版本中引入了关于协程的语法糖async和await,这意味着我们可以使用协程的方式来编写Python应用程序,这会让我们的Python代码像Node一样,让他变成异步方式运行

当然,这并不是我们今天的目标

三、进入正题

在众多的Web框架中,Django、Tornado、Flask已经相对成熟,Tornado等框架也可以进行异步方式的选择,但今天我们都不讲

我们要讲的是当下比较强悍的 Sanic

为什么要讲Sanic,我想最主要的的原因是他足够快,而且可以进行快速构建,他写起来类似Flask,口说无凭,看图

这是官方提供的 基准测试结果

Sanic的开发者说他们的灵感来自于这篇文章 uvloop: Blazing fast Python networking

这是在我使用两年来的感受

  • 快(爽)到起飞
  • 构建速度快
  • 原生支持 async/await 语法
  • 社区慢慢活跃,相比之前的不成熟慢慢出现一些优秀的插件
  • 自动分配&管理进程

只是简单说一下感受,今天的目的不是为了教大家如何使用Sanic,而是希望更多的人加入Sanic,体验到Sanic以及更多优秀的框架的出现带给我们的便捷

附上Sanic 官方文档

三、进阶

说了这么多,还有一个问题,我的Web使用了协程,那么意味着我在操作数据库时,使用常规的pymysql以及psycopg2会阻塞我们的线程,我们的Web服务再快,卡在数据库也是徒劳,那么接下来为大家带来一些我常用的协程支持库

  • Postgresql: asyncpg
  • Redis: aioredis
  • I/O Framework:uvloop

这是我平时用的比较多的一些库,个人喜好pg,至于mysql等其他数据库,大家可以自行搜索

Sanic问世以来,我已经利用它构建过大大小小的Web应用程序,包括他日益完善的社区以及各方面的支持,例如sanic_session等库的出现,让他不再显得那么吃力,让他不再面对大型生产环境而无能为力,到目前为止我一直在热衷于探索他的小插件,如果你们有更好的Sanic支持,请留言

四、擦屁股

我会抽时间将Sanic以及全家桶的教程更新在下方,如果懒,懒,懒的看官方文档,就等我(大哥)更新吧!

  • Sanic: Python让你的Web应用程飞起来全家桶之Sanic
  • asyncpg
  • aioredis
  • uvloop
  • sanic_session
  • sso
  • crypto/decrypt
  • golang mixin
  • middleware
  • pub/sub/listeners

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

面试官 “谈谈Spring中都用到了那些设计模式?”。

发表于 2019-05-23

我自己总结的Java学习的系统知识点以及面试问题,已经开源,目前已经 41k+ Star。会一直完善下去,欢迎建议和指导,同时也欢迎Star: github.com/Snailclimb/…

JDK 中用到了那些设计模式?Spring 中用到了那些设计模式?这两个问题,在面试中比较常见。我在网上搜索了一下关于 Spring 中设计模式的讲解几乎都是千篇一律,而且大部分都年代久远。所以,花了几天时间自己总结了一下,由于我的个人能力有限,文中如有任何错误各位都可以指出。另外,文章篇幅有限,对于设计模式以及一些源码的解读我只是一笔带过,这篇文章的主要目的是回顾一下 Spring 中的设计模式。

Design Patterns(设计模式) 表示面向对象软件开发中最好的计算机编程实践。 Spring 框架中广泛使用了不同类型的设计模式,下面我们来看看到底有哪些设计模式?

控制反转(IoC)和依赖注入(DI)

IoC(Inversion of Control,控制翻转) 是Spring 中一个非常非常重要的概念,它不是什么技术,而是一种解耦的设计思想。它的主要目的是借助于“第三方”(Spring 中的 IOC 容器) 实现具有依赖关系的对象之间的解耦(IOC容易管理对象,你只管使用即可),从而降低代码之间的耦合度。IOC 是一个原则,而不是一个模式,以下模式(但不限于)实现了IoC原则。

ioc-patterns

Spring IOC 容器就像是一个工厂一样,当我们需要创建一个对象的时候,只需要配置好配置文件/注解即可,完全不用考虑对象是如何被创建出来的。 IOC 容器负责创建对象,将对象连接在一起,配置这些对象,并从创建中处理这些对象的整个生命周期,直到它们被完全销毁。

在实际项目中一个 Service 类如果有几百甚至上千个类作为它的底层,我们需要实例化这个 Service,你可能要每次都要搞清这个 Service 所有底层类的构造函数,这可能会把人逼疯。如果利用 IOC 的话,你只需要配置好,然后在需要的地方引用就行了,这大大增加了项目的可维护性且降低了开发难度。关于Spring IOC 的理解,推荐看这一下知乎的一个回答:www.zhihu.com/question/23… ,非常不错。

控制翻转怎么理解呢? 举个例子:”对象a 依赖了对象 b,当对象 a 需要使用 对象 b的时候必须自己去创建。但是当系统引入了 IOC 容器后, 对象a 和对象 b 之前就失去了直接的联系。这个时候,当对象 a 需要使用 对象 b的时候, 我们可以指定 IOC 容器去创建一个对象b注入到对象 a 中”。 对象 a 获得依赖对象 b 的过程,由主动行为变为了被动行为,控制权翻转,这就是控制反转名字的由来。

DI(Dependecy Inject,依赖注入)是实现控制反转的一种设计模式,依赖注入就是将实例变量传入到一个对象中去。

工厂设计模式

Spring使用工厂模式可以通过 BeanFactory 或 ApplicationContext 创建 bean 对象。

两者对比:

  • BeanFactory :延迟注入(使用到某个 bean 的时候才会注入),相比于BeanFactory 来说会占用更少的内存,程序启动速度更快。
  • ApplicationContext :容器启动的时候,不管你用没用到,一次性创建所有 bean 。BeanFactory 仅提供了最基本的依赖注入支持,ApplicationContext 扩展了 BeanFactory ,除了有BeanFactory的功能还有额外更多功能,所以一般开发人员使用ApplicationContext会更多。

ApplicationContext的三个实现类:

  1. ClassPathXmlApplication:把上下文文件当成类路径资源。
  2. FileSystemXmlApplication:从文件系统中的 XML 文件载入上下文定义信息。
  3. XmlWebApplicationContext:从Web系统中的XML文件载入上下文定义信息。

Example:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码import org.springframework.context.ApplicationContext;
import org.springframework.context.support.FileSystemXmlApplicationContext;

public class App {
public static void main(String[] args) {
ApplicationContext context = new FileSystemXmlApplicationContext(
"C:/work/IOC Containers/springframework.applicationcontext/src/main/resources/bean-factory-config.xml");

HelloApplicationContext obj = (HelloApplicationContext) context.getBean("helloApplicationContext");
obj.getMsg();
}
}

单例设计模式

在我们的系统中,有一些对象其实我们只需要一个,比如说:线程池、缓存、对话框、注册表、日志对象、充当打印机、显卡等设备驱动程序的对象。事实上,这一类对象只能有一个实例,如果制造出多个实例就可能会导致一些问题的产生,比如:程序的行为异常、资源使用过量、或者不一致性的结果。

使用单例模式的好处:

  • 对于频繁使用的对象,可以省略创建对象所花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销;
  • 由于 new 操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻 GC 压力,缩短 GC 停顿时间。

Spring 中 bean 的默认作用域就是 singleton(单例)的。 除了 singleton 作用域,Spring 中 bean 还有下面几种作用域:

  • prototype : 每次请求都会创建一个新的 bean 实例。
  • request : 每一次HTTP请求都会产生一个新的bean,该bean仅在当前HTTP request内有效。
  • session : 每一次HTTP请求都会产生一个新的 bean,该bean仅在当前 HTTP session 内有效。
  • global-session: 全局session作用域,仅仅在基于portlet的web应用中才有意义,Spring5已经没有了。Portlet是能够生成语义代码(例如:HTML)片段的小型Java Web插件。它们基于portlet容器,可以像servlet一样处理HTTP请求。但是,与 servlet 不同,每个 portlet 都有不同的会话

Spring 实现单例的方式:

  • xml : <bean id="userService" class="top.snailclimb.UserService" scope="singleton"/>
  • 注解:@Scope(value = "singleton")

Spring 通过 ConcurrentHashMap 实现单例注册表的特殊方式实现单例模式。Spring 实现单例的核心代码如下

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
复制代码// 通过 ConcurrentHashMap(线程安全) 实现单例注册表
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<String, Object>(64);

public Object getSingleton(String beanName, ObjectFactory<?> singletonFactory) {
Assert.notNull(beanName, "'beanName' must not be null");
synchronized (this.singletonObjects) {
// 检查缓存中是否存在实例
Object singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null) {
//...省略了很多代码
try {
singletonObject = singletonFactory.getObject();
}
//...省略了很多代码
// 如果实例对象在不存在,我们注册到单例注册表中。
addSingleton(beanName, singletonObject);
}
return (singletonObject != NULL_OBJECT ? singletonObject : null);
}
}
//将对象添加到单例注册表
protected void addSingleton(String beanName, Object singletonObject) {
synchronized (this.singletonObjects) {
this.singletonObjects.put(beanName, (singletonObject != null ? singletonObject : NULL_OBJECT));

}
}
}

代理设计模式

代理模式在 AOP 中的应用

AOP(Aspect-Oriented Programming:面向切面编程)能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可拓展性和可维护性。

Spring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么Spring AOP会使用JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候Spring AOP会使用Cglib ,这时候Spring AOP会使用 Cglib 生成一个被代理对象的子类来作为代理,如下图所示:

SpringAOPProcess

当然你也可以使用 AspectJ ,Spring AOP 已经集成了AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。

使用 AOP 之后我们可以把一些通用功能抽象出来,在需要用到的地方直接使用即可,这样大大简化了代码量。我们需要增加新功能时也方便,这样也提高了系统扩展性。日志功能、事务管理等等场景都用到了 AOP 。

Spring AOP 和 AspectJ AOP 有什么区别?

Spring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。

Spring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。AspectJ 相比于 Spring AOP 功能更加强大,但是 Spring AOP 相对来说更简单,

如果我们的切面比较少,那么两者性能差异不大。但是,当切面太多的话,最好选择 AspectJ ,它比Spring AOP 快很多。

模板方法

模板方法模式是一种行为设计模式,它定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。 模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤的实现方式。

模板方法UML图

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
复制代码public abstract class Template {
//这是我们的模板方法
public final void TemplateMethod(){
PrimitiveOperation1();
PrimitiveOperation2();
PrimitiveOperation3();
}

protected void PrimitiveOperation1(){
//当前类实现
}

//被子类实现的方法
protected abstract void PrimitiveOperation2();
protected abstract void PrimitiveOperation3();

}
public class TemplateImpl extends Template {

@Override
public void PrimitiveOperation2() {
//当前类实现
}

@Override
public void PrimitiveOperation3() {
//当前类实现
}
}

Spring 中 jdbcTemplate、hibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。一般情况下,我们都是使用继承的方式来实现模板模式,但是 Spring 并没有使用这种方式,而是使用Callback 模式与模板方法模式配合,既达到了代码复用的效果,同时增加了灵活性。

观察者模式

观察者模式是一种对象行为型模式。它表示的是一种对象与对象之间具有依赖关系,当一个对象发生改变的时候,这个对象所依赖的对象也会做出反应。Spring 事件驱动模型就是观察者模式很经典的一个应用。Spring 事件驱动模型非常有用,在很多场景都可以解耦我们的代码。比如我们每次添加商品的时候都需要重新更新商品索引,这个时候就可以利用观察者模式来解决这个问题。

Spring 事件驱动模型中的三种角色

事件角色

ApplicationEvent (org.springframework.context包下)充当事件的角色,这是一个抽象类,它继承了java.util.EventObject并实现了 java.io.Serializable接口。

Spring 中默认存在以下事件,他们都是对 ApplicationContextEvent 的实现(继承自ApplicationContextEvent):

  • ContextStartedEvent:ApplicationContext 启动后触发的事件;
  • ContextStoppedEvent:ApplicationContext 停止后触发的事件;
  • ContextRefreshedEvent:ApplicationContext 初始化或刷新完成后触发的事件;
  • ContextClosedEvent:ApplicationContext 关闭后触发的事件。

ApplicationEvent-Subclass

事件监听者角色

ApplicationListener 充当了事件监听者角色,它是一个接口,里面只定义了一个 onApplicationEvent()方法来处理ApplicationEvent。ApplicationListener接口类源码如下,可以看出接口定义看出接口中的事件只要实现了 ApplicationEvent就可以了。所以,在 Spring中我们只要实现 ApplicationListener 接口实现 onApplicationEvent() 方法即可完成监听事件

1
2
3
4
5
6
复制代码package org.springframework.context;
import java.util.EventListener;
@FunctionalInterface
public interface ApplicationListener<E extends ApplicationEvent> extends EventListener {
void onApplicationEvent(E var1);
}

事件发布者角色

ApplicationEventPublisher 充当了事件的发布者,它也是一个接口。

1
2
3
4
5
6
7
8
复制代码@FunctionalInterface
public interface ApplicationEventPublisher {
default void publishEvent(ApplicationEvent event) {
this.publishEvent((Object)event);
}

void publishEvent(Object var1);
}

ApplicationEventPublisher 接口的publishEvent()这个方法在AbstractApplicationContext类中被实现,阅读这个方法的实现,你会发现实际上事件真正是通过ApplicationEventMulticaster来广播出去的。具体内容过多,就不在这里分析了,后面可能会单独写一篇文章提到。

Spring 的事件流程总结

  1. 定义一个事件: 实现一个继承自 ApplicationEvent,并且写相应的构造函数;
  2. 定义一个事件监听者:实现 ApplicationListener 接口,重写 onApplicationEvent() 方法;
  3. 使用事件发布者发布消息: 可以通过 ApplicationEventPublisher 的 publishEvent() 方法发布消息。

Example:

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
复制代码// 定义一个事件,继承自ApplicationEvent并且写相应的构造函数
public class DemoEvent extends ApplicationEvent{
private static final long serialVersionUID = 1L;

private String message;

public DemoEvent(Object source,String message){
super(source);
this.message = message;
}

public String getMessage() {
return message;
}


// 定义一个事件监听者,实现ApplicationListener接口,重写 onApplicationEvent() 方法;
@Component
public class DemoListener implements ApplicationListener<DemoEvent>{

//使用onApplicationEvent接收消息
@Override
public void onApplicationEvent(DemoEvent event) {
String msg = event.getMessage();
System.out.println("接收到的信息是:"+msg);
}

}
// 发布事件,可以通过ApplicationEventPublisher 的 publishEvent() 方法发布消息。
@Component
public class DemoPublisher {

@Autowired
ApplicationContext applicationContext;

public void publish(String message){
//发布事件
applicationContext.publishEvent(new DemoEvent(this, message));
}
}

当调用 DemoPublisher 的 publish() 方法的时候,比如 demoPublisher.publish("你好") ,控制台就会打印出:接收到的信息是:你好 。

适配器模式

适配器模式(Adapter Pattern) 将一个接口转换成客户希望的另一个接口,适配器模式使接口不兼容的那些类可以一起工作,其别名为包装器(Wrapper)。

spring AOP中的适配器模式

我们知道 Spring AOP 的实现是基于代理模式,但是 Spring AOP 的增强或通知(Advice)使用到了适配器模式,与之相关的接口是AdvisorAdapter 。Advice 常用的类型有:BeforeAdvice(目标方法调用前,前置通知)、AfterAdvice(目标方法调用后,后置通知)、AfterReturningAdvice(目标方法执行结束后,return之前)等等。每个类型Advice(通知)都有对应的拦截器:MethodBeforeAdviceInterceptor、AfterReturningAdviceAdapter、AfterReturningAdviceInterceptor。Spring预定义的通知要通过对应的适配器,适配成 MethodInterceptor接口(方法拦截器)类型的对象(如:MethodBeforeAdviceInterceptor 负责适配 MethodBeforeAdvice)。

spring MVC中的适配器模式

在Spring MVC中,DispatcherServlet 根据请求信息调用 HandlerMapping,解析请求对应的 Handler。解析到对应的 Handler(也就是我们平常说的 Controller 控制器)后,开始由HandlerAdapter 适配器处理。HandlerAdapter 作为期望接口,具体的适配器实现类用于对目标类进行适配,Controller 作为需要适配的类。

为什么要在 Spring MVC 中使用适配器模式? Spring MVC 中的 Controller 种类众多,不同类型的 Controller 通过不同的方法来对请求进行处理。如果不利用适配器模式的话,DispatcherServlet 直接获取对应类型的 Controller,需要的自行来判断,像下面这段代码一样:

1
2
3
4
5
6
7
复制代码if(mappedHandler.getHandler() instanceof MultiActionController){  
((MultiActionController)mappedHandler.getHandler()).xxx
}else if(mappedHandler.getHandler() instanceof XXX){
...
}else if(...){
...
}

假如我们再增加一个 Controller类型就要在上面代码中再加入一行 判断语句,这种形式就使得程序难以维护,也违反了设计模式中的开闭原则 – 对扩展开放,对修改关闭。

装饰者模式

装饰者模式可以动态地给对象添加一些额外的属性或行为。相比于使用继承,装饰者模式更加灵活。简单点儿说就是当我们需要修改原有的功能,但我们又不愿直接去修改原有的代码时,设计一个Decorator套在原有代码外面。其实在 JDK 中就有很多地方用到了装饰者模式,比如 InputStream家族,InputStream 类下有 FileInputStream (读取文件)、BufferedInputStream (增加缓存,使读取文件速度大大提升)等子类都在不修改InputStream 代码的情况下扩展了它的功能。

装饰者模式示意图

Spring 中配置 DataSource 的时候,DataSource 可能是不同的数据库和数据源。我们能否根据客户的需求在少修改原有类的代码下动态切换不同的数据源?这个时候就要用到装饰者模式(这一点我自己还没太理解具体原理)。Spring 中用到的包装器模式在类名上含有 Wrapper或者 Decorator。这些类基本上都是动态地给一个对象添加一些额外的职责

总结

Spring 框架中用到了哪些设计模式?

  • 工厂设计模式 : Spring使用工厂模式通过 BeanFactory、ApplicationContext 创建 bean 对象。
  • 代理设计模式 : Spring AOP 功能的实现。
  • 单例设计模式 : Spring 中的 Bean 默认都是单例的。
  • 模板方法模式 : Spring 中 jdbcTemplate、hibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。
  • 包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。
  • 观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。
  • 适配器模式 :Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller。
  • ……

参考

  • 《Spring技术内幕》
  • blog.eduonix.com/java-progra…
  • blog.yeamin.top/2018/03/27/…
  • www.tutorialsteacher.com/ioc/inversi…
  • design-patterns.readthedocs.io/zh_CN/lates…
  • juejin.cn/post/684490…
  • juejin.cn/post/684490…

公众号

如果大家想要实时关注我更新的文章以及分享的干货的话,可以关注我的公众号。

《Java面试突击》: 由本文档衍生的专为面试而生的《Java面试突击》V2.0 PDF 版本公众号后台回复 “Java面试突击” 即可免费领取!

Java工程师必备学习资源: 一些Java工程师常用学习资源公众号后台回复关键字 “1” 即可免费无套路获取。

我的公众号

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

关于redis的几件小事(五)redis保证高并发以及高可用

发表于 2019-05-23

如果你用redis缓存技术的话,肯定要考虑如何用redis来加多台机器,保证redis是高并发的,还有就是如何让Redis保证自己不是挂掉以后就直接死掉了,redis高可用

redis高并发:主从架构,一主多从,一般来说,很多项目其实就足够了,单主用来写入数据,单机几万QPS,多从用来查询数据,多个从实例可以提供每秒10万的QPS。

redis高并发的同时,还需要容纳大量的数据:一主多从,每个实例都容纳了完整的数据,比如redis主就10G的内存量,其实你就最对只能容纳10g的数据量。如果你的缓存要容纳的数据量很大,达到了几十g,甚至几百g,或者是几t,那你就需要redis集群,而且用redis集群之后,可以提供可能每秒几十万的读写并发。

redis高可用:如果你做主从架构部署,其实就是加上哨兵就可以了,就可以实现,任何一个实例宕机,自动会进行主备切换。
下面详细介绍

一.redis如何通过读写分离来承载读请求QPS超过10万+?

1.redis高并发跟整个系统高并发的关系

redis要搞高并发,那就要把底层的缓存搞好,让更少的请求直接到数据库,因为数据库的高并发实现起来是比较麻烦的,而且有些操作还有事务的要求等等,所以很难做到非常高的并发。

redis并发做的好对于整个系统的并发来说还是不够的,但是redis作为整个大型的缓存架构,在支撑高并发的架构里面是非常重要的一环。

要实现系统的高并发,首先缓存中间件、缓存系统必须要能够支撑起高并发,然后在经过良好的整体缓存架构设计(多级缓存、热点缓存),才能真正支撑起高并发。

2.redis不能支撑高并发的瓶颈

redis不能支撑高并发的瓶颈主要是单机问题,也就是说只有一个单一的redis,就算机器性能再怎么好,也是有上限的。

3.如何支撑更高的并发

单机的redis不可能支撑太高的并发量,要想支持更高的并发可以进行 读写分离 。对于缓存来说,一般都是支撑读高并发的,写的请求是比较少的,因此可以基于主从架构进行读写分离。

配置一个master(主)机器用来写入数据,配置多个slave(从)来进行数据的读取,在master接收到数据之后将数据同步到slave上面即可,这样slave可以配置多台机器,就可以提高整体的并发量。

基于主从的读写分离简单示意图

二.redis replication以及master持久化对主从架构的安全意义

1.redis replication原理。

一个master节点下面挂若干个slave节点,写操作将数据写到master节点上面去,然后在master写完之后,通过异步操作的方式将数据同步到所有的slave节点上面去,保证所有节点的数据是一致的。

2.redis replication的核心机制

(1)redis采用异步方式复制数据到slave节点,不过redis2.8开始,slave node会周期性地确认自己每次复制的数量。

(2)一个master node 可以配置多个 salve node。

(3)slave node也可以连接其他的slave node。

(4)slave node做复制分时候,是不会阻塞master node的正常工作的。

(5)slave node在做复制的时候,也不会阻塞自己的操作,它会用旧的数据来提供服务;但是复制完成的时候,需要删除旧的数据,加载新的数据,这个时候会对外暂停提供服务。

(6)slave node主要用来进行横向扩容,做读写分离,扩容的slave node 可以提高吞吐量。

3.master持久化对主从架构的安全意义

如果采用这种主从架构,那么必须要开启master node的持久化。不建议使用slave node作为master node的热备份,因为如果这样的话,如果master一旦宕机,那么master的数据就会丢失,重启之后数据是空的,其他的slave node要是来复制数据的话,就会复制到空,这样所有节点的数据就都丢了。

要对备份文件做多种冷备份,防止整个机器坏了,备份的rdb数据也丢失的情况。

三.redis主从复制原理、断点续传、无磁盘化复制、过期key处理

1.主从复制原理

①当启动一个slave node的时候,它会发送一个PSYNC 命令给master node。

②如果这个slave node是重新连接master node,那么master node 仅仅会复制给slave部分缺失的数据;如果是第一次连接master node,那么就会触发一次 full resynchronization。

③开始 full resynchronization的时候,master会启动一个后台线程 ,开始生成一份RDB快照文件,同时还会将从客户端新接收到的所有写命令缓存在内存当中。

④master node将生成的RDB文件发送给slave node,slave现将其写入本地磁盘,然后再从磁盘加载到内存当中。然后master node会将内存中缓存的写命令发送给slave node,slave node也会同步这部分数据 。

⑤slave node如果跟master node因为网络故障断开了连接,会自动重连 。

⑥master如果发现有多个slave node来重新连接,仅仅会启动一个rdb save操作 ,用一份数据服务所有slave node。

2.主从复制的断点续传

从redis2.8开始支持断点续传。如果在主从复制的过程中,网络突然断掉了,那么可以接着上次
复制的地方,继续复制,而不是从头复制一份。

原理:
master node会在内存中创建一个backlog,master和slave都会保存一个replica offset还有一个master id,offset就保存在backlog中。如果master和slave网络连接断掉了,slave会让master从上次的replica offset开始继续复制。但是如果没有找到offset,就会执行一次 full resynchronization操作。

3.无磁盘化复制

无磁盘化复制是指,master直接再内存中创建RDB文件,然后发送给slave,不会在自己本地磁盘保存数据。
设置方式

配置 repl-diskless-sync和repl-diskless-sync-delay参数。

repl-diskless-sync:该参数保证进行无磁盘化复制。

repl-diskless-sync-delay:该参数表示等待一定时长再开始复制,这样可以等待多个slave节点从新连接上来。

4.过期key处理

slave不会过期key ,只有等待master过期key。

如果master过期了一个key,或者淘汰了一个key,那么master会模拟发送一条del命令 给slave,slave接到之后会删除该key。

四.redis replication的完整流运行程和原理的再次深入剖析

1.复制的完整流程

①slave node启动,仅仅保存了master node的信息,包括master node的host和ip,但是数据复制还没有开始。 master node的host和ip是在redis.conf文件里面的slaveOf中配置的 。

②slave node内部有一个定时任务,每秒检查是否有新的master node要连接个复制,如果发现,就跟master node建立socket网络连接。

③slave node发送ping命令给master node。

④如果master设置了requirepass,那么slave node必须发送master auth的口令过去进行口令验证。

⑤master node第一次执行全量复制,将所有数据发送给slave node。

⑥master node持续降写命令,异步复制给slave node。

2.数据同步的机制

指的是slave第一次连接master时的情况,执行的是全量复制。

①master和slave都会维护一个offset

master会在自身不断累加offset,slave也会在自身不断累加offset。slave每秒都会上报自己的offset给master,同时master也会保存每个slave的offset。

这个倒不是说特定就用在全量复制的,主要是master和slave都要知道各自的数据的offset,才能知道互相之间的数据不一致的情况

②backlog

master node有一个backlog在内存中,默认是1M大。

master node给slave node复制数据时,也会将数据在backlog中同步一份。

backlog主要是用来做全量复制中断时候的增量复制的。

③master run id

redis通过info server 可以查看到master run id。

用途:slave根据其来定位唯一的master。

为什么不用host+ip : 因为使用host+ip来定位master是不靠谱的,如果master node重启或者数据出现了变化,那么slave应该根据不同的master run id进行区分,run id不同就需要做一次全量复制。

如果需要不更改run id重启redis,可以使用redis-cli debug reload 命令。

3.全量复制流程与机制

①master执行bgsave,在本地生成一份RDB文件。

②master node将RDB快照文件发送给slave node,如果RDB文件的复制时间超过60秒(repl-timeout),那么slave node就会任务复制失败,可以适当调整这个参数。

③对于千兆网卡的机器,一般每秒传输100M,传输6G文件很可能超过60秒。

④master node在生成RDB文件时,会将所有新接到的写命令缓存在内存中,在slave node保存了RDB文件之后,再将这些写命令复制个slave node。

⑤查看client-output-buffer-limit slave 参数,比如[client-output-buffer-limit slave 256MB 64MB 60],表示在复制期间,内存缓存去持续消耗超过64M,或者一次性超过256MB,那么停止复制,复制失败。

⑥slave node接收到RDB文件之后,清空自己的数据,然后重新加载RDB文件到自己的内存中,在这个过程中,基于旧数据对外提供服务。

⑦如果slave node开启了AOF,那么会立即执行BRREWRITEAOF,重新AOF

rdb生成、rdb通过网络拷贝、slave旧数据的清理、slave aof rewrite,很耗费时间

如果复制的数据量在4G~6G之间,那么很可能全量复制时间消耗到1分半到2分钟

4.增量复制流程与机制

①如果全量复制过程中,master和slave网络连接断掉,那么slave重新连接master会触发增刊复制。

②master直接从自己的backlog中获取部分丢失是数据,发送给slave node。

③master就是根据slave发送的psync中的offset来从backlog中获取数据的。

5.心跳

master和slave互相都会发送heartbeat信息。

master默认每隔10秒发送一次,slave node默认每隔1秒发送一次。

6.异步复制

master每次接收到写命令之后,现在内部写入数据,然后异步发送给slave node

五.redis主从架构下如何才能做到99.99%的高可用性?

1.什么是99.99%高可用?

高可用性(英语:high availability,缩写为 HA),IT术语,指系统无中断地执行其功能的能力,代表系统的可用性程度。是进行系统设计时的准则之一。高可用性系统与构成该系统的各个组件相比可以更长时间运行。
高可用性通常通过提高系统的容错能力来实现。定义一个系统怎样才算具有高可用性往往需要根据每一个案例的具体情况来具体分析。
其度量方式,是根据系统损害、无法使用的时间,以及由无法运作恢复到可运作状况的时间,与系统总运作时间的比较。计算公式为:

image.png

A(可用性),MTBF(平均故障间隔),MDT(平均修复时间)
在线系统和执行关键任务的系统通常要求其可用性要达到5个9标准(99.999%)。

可用性 年故障时间
99.9999% 32秒
99.999% 5分15秒
99.99% 52分34秒
99.9% 8小时46分
99% 3天15小时36分

2.redis不可用

redis不可以包含了单实例的不可用,主从架构的不可用。

不可用的情况:

①主从架构的master节点挂了,如果master节点挂了那么缓存数据无法再写入,而且slave里面的数据也无法过期,这样就导致了不可用。

②如果是单实例,那么可能因为其他原因导致redis进程死了。或者部署redis的机器坏了。

不可用的后果 :首先缓存不可用了,那么请求就会直接走数据库,如果涌入大量请求超过了数据库的承载能力,那么数据库就挂掉了,这时候如果不能及时处理好缓存问题,那么由于请求过多,数据库重启之后很快就又会挂掉,直接导致整个系统不可用。

3.如何实现高可用

①保证每个redis都有备份。

②保证在当前redis出故障之后,可以很快切换到备份redis上面去。

为了解决这个问题,引入下面的哨兵机制。

六.redis哨兵架构的相关基础知识的讲解

1.什么是哨兵?

哨兵(Sentinal)是redis集群架构当中非常重要的一个组件,它主要有一下功能:

①集群监控 ,负责监控redis master和slave进程是否正常工作。

②消息通知 ,如果某个redis实例有故障,那么哨兵负责发送消息作为报警通知给管理员。

③故障转移 ,如果master挂掉了,会自动转移到slave上。

④配置中心 ,如果故障发生了,通知client客户端连接到新的master上面去。

2.哨兵的核心知识

①哨兵本身是分布式的,需要作为一个集群去运行,个哨兵协同工作。

②故障转移时,判断一个master宕机了,需要大部分哨兵同意才行。

③即使部分哨兵挂掉了,哨兵集群还是能正常工作的。

④哨兵至少需要3个实例,来保证自己的健壮性。

⑤哨兵+redis主从结构,是无法保证数据零丢失的,只会保证redis集群的高可用。

⑥对应哨兵+redis主从这种架构,再使用之前,要做重复的测试和演练。

3.为什么哨兵集群部署2个节点无法正常工作?

哨兵集群必须部署2个以上的节点。如果集群仅仅部署了2个哨兵实例,那么quorum=1(执行故障转移需要同意的哨兵个数)。

2节点示意图

如图,如果这时候master1宕机了,哨兵1和哨兵2中只要有一个认为master1宕机了就可以进行故障转移,同时哨兵1和哨兵2会选举出一个哨兵来执行故障转移。

同时这个时候需要majority(也就是所有集群中超过一半哨兵的数量),2个哨兵那么majority就是2,也就说需要至少2个哨兵还运行着,才可以进行故障转移。

但是如果整个master和哨兵1同时宕机了,那么就只剩一个哨兵了,这个时候就没有majority来运行执行故障转移了,虽然两外一台机器还有一个哨兵,但是1无法大于1,也就是无法保证半数以上,因此故障转移不会执行。

4.经典的3节点哨兵集群

三节点示意图

Configuration: quorum = 2,majority=2

如果M1所在机器宕机了,那么三个哨兵还剩下2个,S2和S3可以一致认为master宕机,然后选举出一个来执行故障转移

同时3个哨兵的majority是2,所以还剩下的2个哨兵运行着,就可以允许执行故障转移

七.redis哨兵主备切换的数据丢失问题:异步复制、集群脑裂

1.两种数据丢失的场景

①异步复制导致的数据丢失

因为从master到slave的数据复制过程是异步的,可能有部分数据还没来得及复制到slave上面去,这时候master就宕机了,那么这部分数据就丢失了。

②集群脑裂导致的数据丢失

什么是脑裂:脑裂,也就是说,某个master所在机器突然脱离了正常的网络,跟其他slave机器不能连接,但是实际上master还运行着。

此时哨兵可能就会认为master宕机了,然后开启选举,将其他slave切换成了master。

这个时候,集群里就会有两个master,也就是所谓的脑裂。

此时虽然某个slave被切换成了master,但是可能client还没来得及切换到新的master,还继续写向旧master的数据可能也丢失了。

因此旧master再次恢复的时候,会被作为一个slave挂到新的master上去,自己的数据会清空,重新从新的master复制数据

2.解决异步复制的脑裂导致的数据丢失

要解决这个问题,就需要配置两个参数:

min-slaves-to-write 1 和 min-slaves-max-lag :

表示 要求至少有一个slave 在进行数据的复制和同步的延迟不能超过10秒。

如果一旦所有的slave数据同步和复制的延迟都超过了10秒,那么这个时候,master就会在接受任何请求了。

①减少异步复制的数据丢失

有了min-slaves-max-lag这个配置,就可以确保说,一旦slave复制数据和ack延时太长,就认为可能master宕机后损失的数据太多了,那么就拒绝写请求,这样可以把master宕机时由于部分数据未同步到slave导致的数据丢失降低的可控范围内。

(2)减少脑裂的数据丢失
如果一个master出现了脑裂,跟其他slave丢了连接,那么上面两个配置可以确保说,如果不能继续给指定数量的slave发送数据,而且slave超过10秒没有给自己ack消息,那么就直接拒绝客户端的写请求。
这样脑裂后的旧master就不会接受client的新数据,也就避免了数据丢失。

上面的配置就确保了,如果跟任何一个slave丢了连接,在10秒后发现没有slave给自己ack,那么就拒绝新的写请求。
因此在脑裂场景下,最多就丢失10秒的数据

八.redis哨兵的多个核心底层原理的深入解析(包含slave选举算法)

1.sdown和odown两种状态

sdown是主观宕机,就一个哨兵如果自己觉得一个master宕机了,那么就是主观宕机

odown是客观宕机,如果quorum数量的哨兵都觉得一个master宕机了,那么就是客观宕机

sdown达成的条件很简单,如果一个哨兵ping一个master,超过了is-master-down-after-milliseconds指定的毫秒数之后,就主观认为master宕机

sdown到odown转换的条件很简单,如果一个哨兵在指定时间内,收到了quorum指定数量的其他哨兵也认为那个master是sdown了,那么就认为是odown了,客观认为master宕机。

2.哨兵集群的字段发现机制

①哨兵相互之间的发现,是通过redis的pub/sub系统实现的,每个哨兵都会往 __sentinel__:hello 这个channel里面发送一个消息,这时候其他的哨兵都可以消费这个消息,并感知其他哨兵的存在。

②每个两秒钟,每个哨兵都会往自己监控的某个master+slave对应的 __sentinel__:hello channel里面发送一个消息,内容是自己的host、ip和run id还有对这个master的监控配置。

③每个哨兵也会去监听自己监控的每个master+slave对应的 __sentinel__:hello channel,r然后去感知到同样在监听这个master+slave的其他哨兵的存在。

④每个哨兵还会根据其他哨兵交换对master的监控配置,互相进行监控配置的同步。

3.slave配置的自我纠正

哨兵会负责自动纠正slave的一些配置,比如slave如果要成为潜在的master候选人,哨兵会确保slave在复制现有master数据;如果slave连接到了一个错误的master上,比如故障转移之后,那么哨兵会确保他们连接到正确的master上来。

4.选举算法

如果一个master被认为odown了,而且majority数量的哨兵都允许了主备切换,那么某个哨兵就会执行主备切换,此时首先要选举一个slave出来。选举会考虑到一下情况:

①slave跟master断开连接的时长

②slave的优先级

③slave复制数据的offset

④slave的run id

首先,如果一个slave跟master断开连接已经超过了 down-after-millisecondes 的10倍,外加master宕机的时长,那么slave就被认为不适合选举为master了。

即:断开连接时间 > (down-after-milliseconds * 10 + milliseconds_since_master_is_in_SDOWN_state).

对应剩下的slave按照如下规定排序:

①首先,按照slave的优先级进行排序,slave priority越低,优先级就越高。

②如果优先级相同,那么就看replica offset,那个slave复制了越多的数据,offset越靠后,优先级就越高。

③如果上面都想同,那就选择run id最小的那个slave。

5.quorum和majority

每次一个哨兵做主备切换,首先需要quorum数量的哨兵认为odown,然后选举出一个哨兵来做主备切换,这个哨兵还要得到majority数量哨兵的授权,才能正式执行切换。

如果quorum < majority ,比如5个哨兵,majority就是3(超过半数),quorum设置为2,那么就需要3个哨兵授权就可以执行切换。

如果 quorum >= majority,那么必须quorum数量的哨兵都授权才可以进行切换,比如5个哨兵,quorum是5,那么必须5个哨兵都同意授权,才可以进行切换。

6.configuration epoch

哨兵会对一套redis master+slave进行监控,有相应的监控的配置。

执行切换的那个哨兵,会从要切换到的新master(salve->master)那里得到一个configuration epoch,这就是一个version号,每次切换的version号都必须是唯一的。

如果第一个选举出的哨兵切换失败了,那么其他哨兵,会等待failover-timeout时间,然后接替继续执行切换,此时会重新获取一个新的configuration epoch,作为新的version号。

7、configuraiton传播

哨兵完成切换之后,会在自己本地更新生成最新的master配置,然后同步给其他的哨兵,就是通过之前说的pub/sub消息机制。

这里之前的version号就很重要了,因为各种消息都是通过一个channel去发布和监听的,所以一个哨兵完成一次新的切换之后,新的master配置是跟着新的version号的。

其他的哨兵都是根据版本号的大小来更新自己的master配置的。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

这样讲 SpringBoot 自动配置原理,你应该能明白了吧

发表于 2019-05-23

前言

小伙伴们是否想起曾经被 SSM 整合支配的恐惧?相信很多小伙伴都是有过这样的经历的,一大堆配置问题,各种排除扫描,导入一个新的依赖又得添加新的配置。自从有了 SpringBoot 之后,咋们就起飞了!各种零配置开箱即用,而我们之所以开发起来能够这么爽,自动配置的功劳少不了,今天我们就一起来讨论一下 SpringBoot 自动配置原理。

本文主要分为三大部分:

  1. SpringBoot 源码常用注解拾遗
  2. SpringBoot 启动过程
  3. SpringBoot 自动配置原理
  1. SpringBoot 源码常用注解拾遗

这部分主要讲一下 SpringBoot 源码中经常使用到的注解,以扫清后面阅读源码时候的障碍。

组合注解

当可能大量同时使用到几个注解到同一个类上,就可以考虑将这几个注解到别的注解上。被注解的注解我们就称之为组合注解。

  • 元注解:可以注解到别的注解上的注解。
  • 组合注解:被注解的注解我们就称之为组合注解。

@Value 【Spring 提供】

@Value 就相当于传统 xml 配置文件中的 value 字段。

假设存在代码:

1
2
3
4
5
6
7
复制代码@Component 
public class Person {

@Value("i am name")
private String name;

}

上面代码等价于的配置文件:

1
2
3
复制代码<bean class="Person"> 
<property name ="name" value="i am name"></property>
</bean>

我们知道配置文件中的 value 的取值可以是:

  • 字面量
  • 通过 ${key} 方式从环境变量中获取值
  • 通过 ${key} 方式全局配置文件中获取值
  • #{SpEL}

所以,我们就可以通过 @Value(${key}) 的方式获取全局配置文件中的指定配置项。

@ConfigurationProperties 【SpringBoot 提供】

如果我们需要取 N 个配置项,通过 @Value 的方式去配置项需要一个一个去取,这就显得有点 low 了。我们可以使用 @ConfigurationProperties 。

标有 @ConfigurationProperties 的类的所有属性和配置文件中相关的配置项进行绑定。(默认从全局配置文件中获取配置值),绑定之后我们就可以通过这个类去访问全局配置文件中的属性值了。

下面看一个实例:

  1. 在主配置文件中添加如下配置
1
2
3
复制代码person.name=kundy 
person.age=13
person.sex=male
  1. 创建配置类,由于篇幅问题这里省略了 setter、getter 方法,但是实际开发中这个是必须的,否则无法成功注入。另外,@Component 这个注解也还是需要添加的。
1
2
3
4
5
6
7
8
9
复制代码@Component 
@ConfigurationProperties(prefix = "person")
public class Person {

private String name;
private Integer age;
private String sex;

}

这里 @ConfigurationProperties 有一个 prefix 参数,主要是用来指定该配置项在配置文件中的前缀。

  1. 测试,在 SpringBoot 环境中,编写个测试方法,注入 Person 类,即可通过 Person 对象取到配置文件的值。

@Import 【Spring 提供】

@Import 注解支持导入普通 java 类,并将其声明成一个bean。主要用于将多个分散的 java config 配置类融合成一个更大的 config 类。

  • @Import 注解在 4.2 之前只支持导入配置类。
  • 在4.2之后 @Import 注解支持导入普通的 java 类,并将其声明成一个 bean。

**@Import 三种使用方式 **

  • 直接导入普通的 Java 类。
  • 配合自定义的 ImportSelector 使用。
  • 配合 ImportBeanDefinitionRegistrar 使用。

1. 直接导入普通的 Java 类

  1. 创建一个普通的 Java 类。
1
2
3
4
5
6
7
复制代码public class Circle { 

public void sayHi() {
System.out.println("Circle sayHi()");
}

}
  1. 创建一个配置类,里面没有显式声明任何的 Bean,然后将刚才创建的 Circle 导入。
1
2
3
4
5
复制代码@Import({Circle.class}) 
@Configuration
public class MainConfig {

}
  1. 创建测试类。
1
2
3
4
5
6
7
复制代码public static void main(String[] args) { 

ApplicationContext context = new AnnotationConfigApplicationContext(MainConfig.class);
Circle circle = context.getBean(Circle.class);
circle.sayHi();

}
  1. 运行结果:

Circle sayHi()

可以看到我们顺利的从 IOC 容器中获取到了 Circle 对象,证明我们在配置类中导入的 Circle 类,确实被声明为了一个 Bean。

2. 配合自定义的 ImportSelector 使用

ImportSelector 是一个接口,该接口中只有一个 selectImports 方法,用于返回全类名数组。所以利用该特性我们可以给容器动态导入 N 个 Bean。

  1. 创建普通 Java 类 Triangle。
1
2
3
4
5
6
7
复制代码public class Triangle { 

public void sayHi(){
System.out.println("Triangle sayHi()");
}

}
  1. 创建 ImportSelector 实现类,selectImports 返回 Triangle 的全类名。
1
2
3
4
5
6
7
8
复制代码public class MyImportSelector implements ImportSelector { 

@Override
public String[] selectImports(AnnotationMetadata annotationMetadata) {
return new String[]{"annotation.importannotation.waytwo.Triangle"};
}

}
  1. 创建配置类,在原来的基础上还导入了 MyImportSelector。
1
2
3
4
5
复制代码@Import({Circle.class,MyImportSelector.class}) 
@Configuration
public class MainConfigTwo {

}
  1. 创建测试类
1
2
3
4
5
6
7
8
9
复制代码public static void main(String[] args) { 

ApplicationContext context = new AnnotationConfigApplicationContext(MainConfigTwo.class);
Circle circle = context.getBean(Circle.class);
Triangle triangle = context.getBean(Triangle.class);
circle.sayHi();
triangle.sayHi();

}
  1. 运行结果:

Circle sayHi()

Triangle sayHi()

可以看到 Triangle 对象也被 IOC 容器成功的实例化出来了。

3. 配合 ImportBeanDefinitionRegistrar 使用

ImportBeanDefinitionRegistrar 也是一个接口,它可以手动注册bean到容器中,从而我们可以对类进行个性化的定制。(需要搭配 @Import 与 @Configuration 一起使用。)

  1. 创建普通 Java 类 Rectangle。
1
2
3
4
5
6
7
复制代码public class Rectangle { 

public void sayHi() {
System.out.println("Rectangle sayHi()");
}

}
  1. 创建 ImportBeanDefinitionRegistrar 实现类,实现方法直接手动注册一个名叫 rectangle 的 Bean 到 IOC 容器中。
1
2
3
4
5
6
7
8
9
10
11
复制代码public class MyImportBeanDefinitionRegistrar implements ImportBeanDefinitionRegistrar { 

@Override
public void registerBeanDefinitions(AnnotationMetadata annotationMetadata, BeanDefinitionRegistry beanDefinitionRegistry) {

RootBeanDefinition rootBeanDefinition = new RootBeanDefinition(Rectangle.class);
// 注册一个名字叫做 rectangle 的 bean
beanDefinitionRegistry.registerBeanDefinition("rectangle", rootBeanDefinition);
}

}
  1. 创建配置类,导入 MyImportBeanDefinitionRegistrar 类。
1
2
3
4
5
复制代码@Import({Circle.class, MyImportSelector.class, MyImportBeanDefinitionRegistrar.class}) 
@Configuration
public class MainConfigThree {

}
  1. 创建测试类。
1
2
3
4
5
6
7
8
9
10
11
复制代码public static void main(String[] args) { 

ApplicationContext context = new AnnotationConfigApplicationContext(MainConfigThree.class);
Circle circle = context.getBean(Circle.class);
Triangle triangle = context.getBean(Triangle.class);
Rectangle rectangle = context.getBean(Rectangle.class);
circle.sayHi();
triangle.sayHi();
rectangle.sayHi();

}
  1. 运行结果

Circle sayHi()

Triangle sayHi()

Rectangle sayHi()

嗯对,Rectangle 对象也被注册进来了。

@Conditional 【Spring提供】

@Conditional 注释可以实现只有在特定条件满足时才启用一些配置。

下面看一个简单的例子:

  1. 创建普通 Java 类 ConditionBean,该类主要用来验证 Bean 是否成功加载。
1
2
3
4
5
6
7
复制代码public class ConditionBean { 

public void sayHi() {
System.out.println("ConditionBean sayHi()");
}

}
  1. 创建 Condition 实现类,@Conditional 注解只有一个 Condition 类型的参数,Condition 是一个接口,该接口只有一个返回布尔值的 matches() 方法,该方法返回 true 则条件成立,配置类生效。反之,则不生效。在该例子中我们直接返回 true。
1
2
3
4
5
6
7
8
复制代码public class MyCondition implements Condition { 

@Override
public boolean matches(ConditionContext conditionContext, AnnotatedTypeMetadata annotatedTypeMetadata) {
return true;
}

}
  1. 创建配置类,可以看到该配置的 @Conditional 传了我们刚才创建的 Condition 实现类进去,用作条件判断。
1
2
3
4
5
6
7
8
9
10
复制代码@Configuration 
@Conditional(MyCondition.class)
public class ConditionConfig {

@Bean
public ConditionBean conditionBean(){
return new ConditionBean();
}

}
  1. 编写测试方法。
1
2
3
4
5
6
7
复制代码public static void main(String[] args) { 

ApplicationContext context = new AnnotationConfigApplicationContext(ConditionConfig.class);
ConditionBean conditionBean = context.getBean(ConditionBean.class);
conditionBean.sayHi();

}
  1. 结果分析

因为 Condition 的 matches 方法直接返回了 true,配置类会生效,我们可以把 matches 改成返回 false,则配置类就不会生效了。

除了自定义 Condition,Spring 还为我们扩展了一些常用的 Condition。

扩展注解 作用
ConditionalOnBean 容器中存在指定 Bean,则生效。
ConditionalOnMissingBean 容器中不存在指定 Bean,则生效。
ConditionalOnClass 系统中有指定的类,则生效。
ConditionalOnMissingClass 系统中没有指定的类,则生效。
ConditionalOnProperty 系统中指定的属性是否有指定的值。
ConditionalOnWebApplication 当前是web环境,则生效。
  1. SpringBoot 启动过程

在看源码的过程中,我们会看到以下四个类的方法经常会被调用,我们需要对一下几个类有点印象:

  • ApplicationContextInitializer
  • ApplicationRunner
  • CommandLineRunner
  • SpringApplicationRunListener

下面开始源码分析,先从 SpringBoot 的启动类的 run() 方法开始看,以下是调用链:SpringApplication.run() -> run(new Class[]{primarySource}, args) -> new SpringApplication(primarySources)).run(args)。

一直在run,终于到重点了,我们直接看 new SpringApplication(primarySources)).run(args) 这个方法。

1.png

上面的方法主要包括两大步骤:

  • 创建 SpringApplication 对象。
  • 运行 run() 方法。

创建 SpringApplication 对象

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
复制代码public SpringApplication(ResourceLoader resourceLoader, Class... primarySources) { 

this.sources = new LinkedHashSet();
this.bannerMode = Mode.CONSOLE;
this.logStartupInfo = true;
this.addCommandLineProperties = true;
this.addConversionService = true;
this.headless = true;
this.registerShutdownHook = true;
this.additionalProfiles = new HashSet();
this.isCustomEnvironment = false;
this.resourceLoader = resourceLoader;
Assert.notNull(primarySources, "PrimarySources must not be null");
// 保存主配置类(这里是一个数组,说明可以有多个主配置类)
this.primarySources = new LinkedHashSet(Arrays.asList(primarySources));
// 判断当前是否是一个 Web 应用
this.webApplicationType = WebApplicationType.deduceFromClasspath();
// 从类路径下找到 META/INF/Spring.factories 配置的所有 ApplicationContextInitializer,然后保存起来
this.setInitializers(this.getSpringFactoriesInstances(ApplicationContextInitializer.class));
// 从类路径下找到 META/INF/Spring.factories 配置的所有 ApplicationListener,然后保存起来
this.setListeners(this.getSpringFactoriesInstances(ApplicationListener.class));
// 从多个配置类中找到有 main 方法的主配置类(只有一个)
this.mainApplicationClass = this.deduceMainApplicationClass();

}

运行 run() 方法

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
复制代码public ConfigurableApplicationContext run(String... args) { 

// 创建计时器
StopWatch stopWatch = new StopWatch();
stopWatch.start();
// 声明 IOC 容器
ConfigurableApplicationContext context = null;
Collection<SpringBootExceptionReporter> exceptionReporters = new ArrayList();
this.configureHeadlessProperty();
// 从类路径下找到 META/INF/Spring.factories 获取 SpringApplicationRunListeners
SpringApplicationRunListeners listeners = this.getRunListeners(args);
// 回调所有 SpringApplicationRunListeners 的 starting() 方法
listeners.starting();
Collection exceptionReporters;
try {
// 封装命令行参数
ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
// 准备环境,包括创建环境,创建环境完成后回调 SpringApplicationRunListeners#environmentPrepared()方法,表示环境准备完成
ConfigurableEnvironment environment = this.prepareEnvironment(listeners, applicationArguments);
this.configureIgnoreBeanInfo(environment);
// 打印 Banner
Banner printedBanner = this.printBanner(environment);
// 创建 IOC 容器(决定创建 web 的 IOC 容器还是普通的 IOC 容器)
context = this.createApplicationContext();
exceptionReporters = this.getSpringFactoriesInstances(SpringBootExceptionReporter.class, new Class[]{ConfigurableApplicationContext.class}, context);
/*
* 准备上下文环境,将 environment 保存到 IOC 容器中,并且调用 applyInitializers() 方法
* applyInitializers() 方法回调之前保存的所有的 ApplicationContextInitializer 的 initialize() 方法
* 然后回调所有的 SpringApplicationRunListener#contextPrepared() 方法
* 最后回调所有的 SpringApplicationRunListener#contextLoaded() 方法
*/
this.prepareContext(context, environment, listeners, applicationArguments, printedBanner);
// 刷新容器,IOC 容器初始化(如果是 Web 应用还会创建嵌入式的 Tomcat),扫描、创建、加载所有组件的地方
this.refreshContext(context);
// 从 IOC 容器中获取所有的 ApplicationRunner 和 CommandLineRunner 进行回调
this.afterRefresh(context, applicationArguments);
stopWatch.stop();
if (this.logStartupInfo) {
(new StartupInfoLogger(this.mainApplicationClass)).logStarted(this.getApplicationLog(), stopWatch);
}
// 调用 所有 SpringApplicationRunListeners#started()方法
listeners.started(context);
this.callRunners(context, applicationArguments);
} catch (Throwable var10) {
this.handleRunFailure(context, var10, exceptionReporters, listeners);
throw new IllegalStateException(var10);
}
try {
listeners.running(context);
return context;
} catch (Throwable var9) {
this.handleRunFailure(context, var9, exceptionReporters, (SpringApplicationRunListeners)null);
throw new IllegalStateException(var9);
}
}

小结:

run() 阶段主要就是回调本节开头提到过的4个监听器中的方法与加载项目中组件到 IOC 容器中,而所有需要回调的监听器都是从类路径下的 META/INF/Spring.factories 中获取,从而达到启动前后的各种定制操作。

  1. SpringBoot 自动配置原理

@SpringBootApplication 注解

SpringBoot 项目的一切都要从 @SpringBootApplication 这个注解开始说起。

@SpringBootApplication 标注在某个类上说明:

  • 这个类是 SpringBoot 的主配置类。
  • SpringBoot 就应该运行这个类的 main 方法来启动 SpringBoot 应用。

该注解的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码@SpringBootConfiguration 
@EnableAutoConfiguration
@ComponentScan(
excludeFilters = {@Filter(
type = FilterType.CUSTOM,
classes = {TypeExcludeFilter.class}
), @Filter(
type = FilterType.CUSTOM,
classes = {AutoConfigurationExcludeFilter.class}
)}
)
public @interface SpringBootApplication {

可以看到 SpringBootApplication 注解是一个组合注解(关于组合注解文章的开头有讲到),其主要组合了一下三个注解:

  • @SpringBootConfiguration:该注解表示这是一个 SpringBoot 的配置类,其实它就是一个 @Configuration 注解而已。
  • @ComponentScan:开启组件扫描。
  • @EnableAutoConfiguration:从名字就可以看出来,就是这个类开启自动配置的。嗯,自动配置的奥秘全都在这个注解里面。

@EnableAutoConfiguration 注解

先看该注解是怎么定义的:

1
2
3
复制代码@AutoConfigurationPackage 
@Import({AutoConfigurationImportSelector.class})
public @interface EnableAutoConfiguration {

@AutoConfigurationPackage

从字面意思理解就是自动配置包。点进去可以看到就是一个 @Import 注解:@Import({Registrar.class}),导入了一个 Registrar 的组件。关于 @Import 的用法文章上面也有介绍哦。

我们在 Registrar 类中的 registerBeanDefinitions 方法上打上断点,可以看到返回了一个包名,该包名其实就是主配置类所在的包。

1.png

一句话:@AutoConfigurationPackage 注解就是将主配置类(@SpringBootConfiguration标注的类)的所在包及下面所有子包里面的所有组件扫描到Spring容器中。所以说,默认情况下主配置类包及子包以外的组件,Spring 容器是扫描不到的。

@Import({AutoConfigurationImportSelector.class})

该注解给当前配置类导入另外的 N 个自动配置类。(该注解详细用法上文有提及)。

配置类导入规则

那具体的导入规则是什么呢?我们来看一下源码。在开始看源码之前,先啰嗦两句。就像小马哥说的,我们看源码不用全部都看,不用每一行代码都弄明白是什么意思,我们只要抓住关键的地方就可以了。

我们知道 AutoConfigurationImportSelector 的 selectImports 就是用来返回需要导入的组件的全类名数组的,那么如何得到这些数组呢?

在 selectImports 方法中调用了一个 getAutoConfigurationEntry() 方法。

3.png

由于篇幅问题我就不一一截图了,我直接告诉你们调用链:在 getAutoConfigurationEntry() -> getCandidateConfigurations() -> loadFactoryNames()。

在这里 loadFactoryNames() 方法传入了 EnableAutoConfiguration.class 这个参数。先记住这个参数,等下会用到。

4.png

loadFactoryNames() 中关键的三步:

  1. 从当前项目的类路径中获取所有 META-INF/spring.factories 这个文件下的信息。
  2. 将上面获取到的信息封装成一个 Map 返回。
  3. 从返回的 Map 中通过刚才传入的 EnableAutoConfiguration.class 参数,获取该 key 下的所有值。

6.png

META-INF/spring.factories 探究

听我这样说完可能会有点懵,我们来看一下 META-INF/spring.factories 这类文件是什么就不懵了。当然在很多第三方依赖中都会有这个文件,一般每导入一个第三方的依赖,除了本身的jar包以外,还会有一个 xxx-spring-boot-autoConfigure,这个就是第三方依赖自己编写的自动配置类。我们现在就以 spring-boot-autocongigure 这个依赖来说。

8.png

可以看到 EnableAutoConfiguration 下面有很多类,这些就是我们项目进行自动配置的类。

一句话:将类路径下 META-INF/spring.factories 里面配置的所有 EnableAutoConfiguration 的值加入到 Spring 容器中。

HttpEncodingAutoConfiguration

通过上面方式,所有的自动配置类就被导进主配置类中了。但是这么多的配置类,明显有很多自动配置我们平常是没有使用到的,没理由全部都生效吧。

接下来我们以 HttpEncodingAutoConfiguration为例来看一个自动配置类是怎么工作的。为啥选这个类呢?主要是这个类比较的简单典型。

先看一下该类标有的注解:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码@Configuration 
@EnableConfigurationProperties({HttpProperties.class})
@ConditionalOnWebApplication(
type = Type.SERVLET
)
@ConditionalOnClass({CharacterEncodingFilter.class})
@ConditionalOnProperty(
prefix = "spring.http.encoding",
value = {"enabled"},
matchIfMissing = true
)
public class HttpEncodingAutoConfiguration {
  • @Configuration:标记为配置类。
  • @ConditionalOnWebApplication:web应用下才生效。
  • @ConditionalOnClass:指定的类(依赖)存在才生效。
  • @ConditionalOnProperty:主配置文件中存在指定的属性才生效。
  • @EnableConfigurationProperties({HttpProperties.class}):启动指定类的ConfigurationProperties功能;将配置文件中对应的值和 HttpProperties 绑定起来;并把 HttpProperties 加入到 IOC 容器中。

因为 @EnableConfigurationProperties({HttpProperties.class}) 把配置文件中的配置项与当前 HttpProperties 类绑定上了。然后在 HttpEncodingAutoConfiguration 中又引用了 HttpProperties ,所以最后就能在 HttpEncodingAutoConfiguration 中使用配置文件中的值了。最终通过 @Bean 和一些条件判断往容器中添加组件,实现自动配置。(当然该Bean中属性值是从 HttpProperties 中获取)

HttpProperties

HttpProperties 通过 @ConfigurationProperties 注解将配置文件与自身属性绑定。

所有在配置文件中能配置的属性都是在 xxxProperties 类中封装着;配置文件能配置什么就可以参照某个功能对应的这个属性类。

1
2
3
4
复制代码@ConfigurationProperties( 
prefix = "spring.http"
)// 从配置文件中获取指定的值和bean的属性进行绑定
public class HttpProperties {

小结:

  1. SpringBoot启动会加载大量的自动配置类。
  2. 我们看需要的功能有没有SpringBoot默认写好的自动配置类。
  3. 我们再来看这个自动配置类中到底配置了那些组件(只要我们要用的组件有,我们就不需要再来配置了)。
  4. 给容器中自动配置类添加组件的时候,会从properties类中获取某些属性。我们就可以在配置文件中指定这些属性的值。
  • xxxAutoConfiguration:自动配置类给容器中添加组件。
  • xxxProperties:封装配置文件中相关属性。

不知道小伙伴们有没有发现,很多需要待加载的类都放在类路径下的META-INF/Spring.factories 文件下,而不是直接写死这代码中,这样做就可以很方便我们自己或者是第三方去扩展,我们也可以实现自己 starter,让SpringBoot 去加载。现在明白为什么 SpringBoot 可以实现零配置,开箱即用了吧!

最后

文章有点长,感谢大家的阅读!觉得不错可以点个赞哦!

参考文章:

  • blog.51cto.com/4247649/211…
  • www.cnblogs.com/duanxz/p/37…
  • www.jianshu.com/p/e22b9fef3…
  • blog.csdn.net/qq_26525215…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

记一次PHP并发性能调优实战 -- 性能提升104%

发表于 2019-05-23

文章编写计划

待完成: 详细介绍用到的各个工具

作者: 祝星

适合阅读人群

文中的调优思路无论是php, java, 还是其他任何语言都是用. 如果你有php使用经验, 那肯定就更好了

业务背景

框架及相应环境

  1. laravel5.7, mysql5.7, redis5, nginx1.15
  2. centos 7.5 bbr
  3. docker, docker-compose
  4. 阿里云 4C和8G

问题背景

php已经开启opcache, laravel也运行了optimize命令进行优化, composer也进行过dump-autoload命令.

首先需要声明的是, 系统的环境中是一定有小问题的(没有问题也不可能能够提升如此大的性能), 但是这些问题, 如果不通过使用合适的工具, 可能一辈子也发现不出来.

本文关注的就是如何发现这些问题, 以及发现问题的思路.

我们首先找到系统中一个合适的API或函数, 用来放大问题.

这个api设计之初是给nginx负载均衡做健康检查的.
使用ab -n 100000 -c 1000 进行压测, 发现qps只能到140个每秒.

我们知道Laravel的性能是出了名的不好, 但是也不至于到这个程度, 从api的编写来看不应该这么低. 所以决定一探究竟.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
复制代码 public function getActivateStatus()
{
try {
$result = \DB::select('select 1');
$key = 1;
if ($result[0]->$key !== 1) {
throw new \Exception("mysql 检查失败");
}
} catch (\Exception $exception) {
\Log::critical("数据库连接失败: {$exception->getMessage()}", $exception->getTrace());
return \response(null, 500);
}
try {
Cache::getRedis()->connection()->exists("1");
} catch (\Exception $exception) {
\Log::critical("缓存连接失败: {$exception->getMessage()}", $exception->getTrace());
return \response(null, 500);
}
return \response(null, 204);
}

问题表现以及排查思路

top

top命令发现系统CPU占用100%
其中用户态占80%, 内核态占20%, 看起来没什么大问题. 有一个地方看起来很奇怪,
top命令的运行结果

top命令运行结果

就是有一部分php-fpm进程处在Sleep状态, 但CPU占用还是达到了近30%.
当一个进程处于Sleep状态的时候, 任然占用了不少CPU, 先不要怀疑是不是进程的问题, 我们看一下Ttop命令的man page.

1
2
3
复制代码%CPU -- CPU usage

The task's share of the elapsed CPU time since the last screen update, expressed as a percentage of total CPU time.

大致意思是这个占用是最后一次屏幕刷新的时候, 进程CPU的占用. 由于top命令收集信息的时候, 可能linux把这个进程强制调度了 ( 比如用于top收集进程信息 ), 所以在这一瞬间(屏幕刷新的这一瞬间)某些php-fpm进程处于sleep状态, 可以理解, 所以应该不是php-fpm的问题.

pidstat

首先选出一个php-fpm进程, 然后使用pidstat查看进程详细的运行情况

过程中也没发现什么异样, 并且和top命令的运行结果也基本一致.

vmstat

保持压测压力, 运行vmstate查看, 除了context switch (上下文切换)有点高之外, 并没有看到太多异常.
由于我们使用的docker, redis, mysql都运行在同一台机器上, 7000左右的CS还是一个合理的范围, 但是这个IN(中断)就有点太高了, 达到了1.4万左右. 一定有什么东西触发了中断.

我们知道中断有硬中断和软中断, 硬中断是由网卡, 鼠标等硬件发出中断信号, cpu马上停下在做的事情, 处理中断信号. 软中断是由操作系统发出的, 常用于进程的强制调度.
不管是vmstat还是pidstat都只是新能探测工具, 我们无法看到具体的中断是由谁发出的.
我们通过/proc/interrupts 这个只读文件中读取系统的中断信息, 获取到底是什么导致的中断升高. 通过watch -d命令, 判断变化最频繁的中断.

1
复制代码watch -d cat /proc/interrupts

我们发现其中Rescheduling interrupts变化的最快, 这个是重调度中断(RES),这个中断类型表示,唤醒空闲状态的CPU 来调度新的任务运行。这是多处理器系统(SMP)中,调度器用来分散任务到不同 CPU的机制,通常也被称为处理器间中断(Inter-Processor Interrupts,IPI)。
结合vmstat中的命令, 我们可以确定造成qps不高的原因之一是过多的进程争抢CPU导致的, 我们现在还不能确定具体是什么, 所以还需要进一步的排查.

strace

strace可以查看系统调用, 我们知道, 当使用系统调用的时候, 系统陷入内核态, 这个过程是会产生软中断的, 通过查看php-fpm的系统调用, 验证我们的猜想

果然, 发现大量的stat系统调用, 我们猜想, 是opcache在检查文件是否过期导致的. 我们通过修改opcache的配置, 让opcache更少的检查文件timestamp, 减少这种系统调用

1
2
复制代码    opcache.validate_timestamps="60"
opcache.revalidate_freq="0"

再次执行ab命令进行压测

果然qps直接涨到了205, 提升非常明显, 有接近 46% 的提升

perf

现在任然不满足这个性能, 希望在更多地方找到突破口.
通过

1
2
复制代码perf record -g
perf report -g

看到系统的分析报告

我们看到, 好像这里面有太多tcp建立相关的系统调用(具体是不是我还不清楚, 请大神指正, 但是看到send, ip, tcp啥的我就怀疑可能是tcp/ip相关的问题).
我们怀疑两种情况

  1. 与mysql, redis重复大量的建立TCP连接, 消耗资源
  2. 大量请求带来的tcp连接

先说第一个, 经过检查, 发现数据库连接使用了php-fpm的连接池, 但是redis连接没有, redis用的predis, 这个是一个纯PHP实现, 性能不高, 换成了phpredis:

打开laravel的config/database.php文件, 修改redis的driver为phpredis, 确保本机已安装php的redis扩展. 另外由于Laravel自己封装了一个Redis门面, 而恰好redis扩展带来的对象名也叫Redis. 所以需要修改Laravel的Redis门面为其他名字, 如RedisL5.

再次进行压测

达到了喜人的286qps, 虽然和其他主打高性能的框架或者原生php比, 还有很高的提升空间(比如Swoole), 但是最终达到了104%的提升, 还是很有意义的
总结
–

我们通过top, 发现系统CPU占用高, 且发现确实是php-fpm进程占用了CPU资源, 判断系统瓶颈来自于PHP.

接着我们通过pidstat, vmstat发现压测过程中, 出现了大量的系统中断, 并通过 watch -d cat /proc/interrupts 发现主要的中断来自于重调度中断(RES)

通过strace查看具体的系统调用, 发现大量的系统调用来自于stat, 猜测可能是opcache频繁的检查时间戳, 判断文件修改. 通过修改配置项, 达到了46%的性能提升

最后再通过perf, 查看函数调用栈, 分析得到, 可能是大量的与redis的TCP连接带来不必要的资源消耗. 通过安装redis扩展, 以及使用phpredis来驱动Laravel的redis缓存, 提升性能, 达到了又一次近50%的性能提升.

最终我们完成了我们的性能提升104%的目标

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

关于MQ的几件小事(六)消息积压在消息队列里怎么办

发表于 2019-05-22

1.大量消息在mq里积压了几个小时了还没解决

场景: 几千万条数据在MQ里积压了七八个小时,从下午4点多,积压到了晚上很晚,10点多,11点多。线上故障了,这个时候要不然就是修复consumer的问题,让他恢复消费速度,然后傻傻的等待几个小时消费完毕。这个肯定不行。一个消费者一秒是1000条,一秒3个消费者是3000条,一分钟是18万条,1000多万条。
所以如果你积压了几百万到上千万的数据,即使消费者恢复了,也需要大概1小时的时间才能恢复过来。

解决方案:

这种时候只能操作临时扩容,以更快的速度去消费数据了。具体操作步骤和思路如下:

①先修复consumer的问题,确保其恢复消费速度,然后将现有consumer都停掉。

②临时建立好原先10倍或者20倍的queue数量(新建一个topic,partition是原来的10倍)。

③然后写一个临时分发消息的consumer程序,这个程序部署上去消费积压的消息,消费之后不做耗时处理,直接均匀轮询写入临时建好分10数量的queue里面。

④紧接着征用10倍的机器来部署consumer,每一批consumer消费一个临时queue的消息。

⑤这种做法相当于临时将queue资源和consumer资源扩大10倍,以正常速度的10倍来消费消息。

⑥等快速消费完了之后,恢复原来的部署架构,重新用原来的consumer机器来消费消息。

kafka的示意图.png

2.消息设置了过期时间,过期就丢了怎么办

假设你用的是rabbitmq,rabbitmq是可以设置过期时间的,就是TTL,如果消息在queue中积压超过一定的时间就会被rabbitmq给清理掉,这个数据就没了。那这就是第二个坑了。这就不是说数据会大量积压在mq里,而是大量的数据会直接搞丢。

解决方案:

这种情况下,实际上没有什么消息挤压,而是丢了大量的消息。所以第一种增加consumer肯定不适用。
这种情况可以采取 “批量重导” 的方案来进行解决。
在流量低峰期(比如夜深人静时),写一个程序,手动去查询丢失的那部分数据,然后将消息重新发送到mq里面,把丢失的数据重新补回来。

3.积压消息长时间没有处理,mq放不下了怎么办

如果走的方式是消息积压在mq里,那么如果你很长时间都没处理掉,此时导致mq都快写满了,咋办?这个还有别的办法吗?

解决方案:

这个就没有办法了,肯定是第一方案执行太慢,这种时候只好采用 “丢弃+批量重导” 的方式来解决了。

首先,临时写个程序,连接到mq里面消费数据,收到消息之后直接将其丢弃,快速消费掉积压的消息,降低MQ的压力,然后走第二种方案,在晚上夜深人静时去手动查询重导丢失的这部分数据。

上一篇《如何保证消息按顺序执行》

下一篇《如果让你设计一个MQ,你怎么设计》

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

关于MQ的几件小事(五)如何保证消息按顺序执行

发表于 2019-05-22

1.为什么要保证顺序

消息队列中的若干消息如果是对同一个数据进行操作,这些操作具有前后的关系,必须要按前后的顺序执行,否则就会造成数据异常。举例:
比如通过mysql binlog进行两个数据库的数据同步,由于对数据库的数据操作是具有顺序性的,如果操作顺序搞反,就会造成不可估量的错误。比如数据库对一条数据依次进行了 插入->更新->删除操作,这个顺序必须是这样,如果在同步过程中,消息的顺序变成了 删除->插入->更新,那么原本应该被删除的数据,就没有被删除,造成数据的不一致问题。

2.出现顺序错乱的场景

(1)rabbitmq

①一个queue,有多个consumer去消费,这样就会造成顺序的错误,consumer从MQ里面读取数据是有序的,但是每个consumer的执行时间是不固定的,无法保证先读到消息的consumer一定先完成操作,这样就会出现消息并没有按照顺序执行,造成数据顺序错误。

rabbitmq消息顺序错乱第一种情况示意图.png

②一个queue对应一个consumer,但是consumer里面进行了多线程消费,这样也会造成消息消费顺序错误。

abbitmq消息顺序错乱第二种情况示意图.png

(2)kafka

①kafka一个topic,一个partition,一个consumer,但是consumer内部进行多线程消费,这样数据也会出现顺序错乱问题。

kafka消息顺序错乱第一种情况示意图.png

②具有顺序的数据写入到了不同的partition里面,不同的消费者去消费,但是每个consumer的执行时间是不固定的,无法保证先读到消息的consumer一定先完成操作,这样就会出现消息并没有按照顺序执行,造成数据顺序错误。

kafka消息顺序错乱第二种情况示意图..png

3.保证消息的消费顺序

(1)rabbitmq

①拆分多个queue,每个queue一个consumer,就是多一些queue而已,确实是麻烦点;这样也会造成吞吐量下降,可以在消费者内部采用多线程的方式取消费。

一个queue对应一个consumer

②或者就一个queue但是对应一个consumer,然后这个consumer内部用内存队列做排队,然后分发给底层不同的worker来处理

一个queue对应一个consumer,采用多线程.png

(2)kafka

①确保同一个消息发送到同一个partition,一个topic,一个partition,一个consumer,内部单线程消费。

单线程保证顺序.png

②写N个内存queue,然后N个线程分别消费一个内存queue即可

多线程保证顺序.png

上一篇《如何防止数据队列数据丢失》

下一篇《消息积压在消息队列里怎么办》

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Hadoop HA Hadoop HA

发表于 2019-05-22

Hadoop HA

什么是 HA

HA是High Available缩写,是双机集群系统简称,指高可用性集群,是保证业务连续性的有效解决方案,一般有两个或两个以上的节点,且分为活动节点及备用节点。通常把正在执行业务的称为活动节点,而作为活动节点的一个备份的则称为备用节点。当活动节点出现问题,导致正在运行的业务(任务)不能正常运行时,备用节点此时就会侦测到,并立即接续活动节点来执行业务。从而实现业务的不中断或短暂中断。

hadoop HA机制介绍

hadoop2.0的HA 机制有两个namenode,一个是active namenode,状态是active;另外一个是standby namenode,状态是standby。两者的状态是可以切换的,但不能同时两个都是active状态,最多只有1个是active状态。只有active namenode提供对外的服务,standby namenode是不对外服务的。active namenode和standby namenode之间通过NFS或者JN(journalnode,QJM方式)来同步数据。
active namenode会把最近的操作记录写到本地的一个edits文件中(edits file),并传输到NFS或者JN中。standby namenode定期的检查,从NFS或者JN把最近的edit文件读过来,然后把edits文件和fsimage文件合并成一个新的fsimage,合并完成之后会通知active namenode获取这个新fsimage。active namenode获得这个新的fsimage文件之后,替换原来旧的fsimage文件。
这样,保持了active namenode和standby namenode的数据的实时同步,standby namenode可以随时切换成active namenode(譬如active namenode挂了)。而且还有一个原来hadoop1.0的secondarynamenode,checkpointnode,buckcupnode的功能:合并edits文件和fsimage文件,使fsimage文件一直保持更新。所以启动了hadoop2.0的HA机制之后,secondarynamenode,checkpointnode,buckcupnode这些都不需要了。

搭建 hadoop HA 集群

环境

linux: CentOS-7.5_x64
hadoop: hadoop-3.2.0
zookeeper: zookeeper-3.4.10

机器规划

主机名 IP 安装软件 运行进程
node-1 192.168.91.11 hadoop NameNode,ResourceManager,DFSZKFailoverController
node-2 192.168.91.12 hadoop,zookeeper NameNode,ResourceManager,QuorumPeerMain,DFSZKFailoverController
node-3 192.168.91.13 hadoop,zookeeper QuorumPeerMain,DataNode,NodeManager,JournalNode
node-4 192.168.91.14 hadoop,zookeeper QuorumPeerMain,DataNode,NodeManager,JournalNode

前置准备

四台机器需要ssh免密登录,node-2,node-3,node-4需要安装zookeeper、java环境

集群搭建

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
复制代码
# 下载
$ wget http://mirrors.shu.edu.cn/apache/hadoop/common/hadoop-3.1.2/hadoop-3.2.0.tar.gz

# 解压
$ tar -zxvf hadoop-3.2.0.tar.gz

# 配置系统的环境变量
$ vim /etc/profile

export JAVA_HOME=/usr/local/jdk1.8.0_191
export PATH=$PATH:$JAVA_HOME/bin

export HADOOP_HOME=/export/servers/hadoop-3.2.0
export PATH=$PATH:$HADOOP_HOME/bin:$HADOOP_HOME/sbin


# 进入解压目录配置环境变量
$ cd $HADOOP_HOME

# 配置hadoop-env.sh 添加下面配置(不配置启动会报错)
$ vim etc/hadoop/core-site.xml

export JAVA_HOME=/usr/local/jdk1.8.0_191

export HDFS_NAMENODE_USER=root
export HDFS_DATANODE_USER=root
export HDFS_JOURNALNODE_USER=root
export HDFS_SECONDARYNAMENODE_USER=root

export YARN_RESOURCEMANAGER_USER=root
export YARN_NODEMANAGER_USER=root



# 配置core-site.xml
$ vim etc/hadoop/core-site.xml

<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="configuration.xsl"?>
<!--
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License. See accompanying LICENSE file.
-->

<!-- Put site-specific property overrides in this file. -->

<configuration>

<!-- HA 配置指定 hdfs 的 nameService 为ns -->
<property>
<name>fs.defaultFS</name>
<value>hdfs://ns</value>
</property>

<!-- HA 配置,指定zookeeper地址 -->
<property>
<name>ha.zookeeper.quorum</name>
<value>node-2:2181,node-3:2181,node-4:2181</value>
</property>

<property>
<name>hadoop.tmp.dir</name>
<value>/export/data/hadoop/temp</value>
</property>

<property>
<name>hadoop.proxyuser.root.hosts</name>
<value>*</value>
</property>

<property>
<name>hadoop.proxyuser.root.groups</name>
<value>*</value>
</property>

</configuration>



# 配置hdfs-site.xml
$ vim etc/hadoop/hdfs-site.xml

<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="configuration.xsl"?>
<!--
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License. See accompanying LICENSE file.
-->

<!-- Put site-specific property overrides in this file. -->

<configuration>

<!--指定hdfs的nameservice为ns,需要和core-site.xml中的保持一致 -->
<property>
<name>dfs.nameservices</name>
<value>ns</value>
</property>

<!-- bi下面有两个NameNode,分别是nn1,nn2 -->
<property>
<name>dfs.ha.namenodes.ns</name>
<value>nn1,nn2</value>
</property>

<!-- nn1的RPC通信地址 -->
<property>
<name>dfs.namenode.rpc-address.ns.nn1</name>
<value>node-1:9000</value>
</property>

<!-- nn1的http通信地址 -->
<property>
<name>dfs.namenode.http-address.ns.nn1</name>
<value>node-1:50070</value>
</property>

<!-- nn2的RPC通信地址 -->
<property>
<name>dfs.namenode.rpc-address.ns.nn2</name>
<value>node-2:9000</value>
</property>

<!-- nn2的http通信地址 -->
<property>
<name>dfs.namenode.http-address.ns.nn2</name>
<value>node-2:50070</value>
</property>

<!-- 指定NameNode的edits元数据在JournalNode上的存放位置 -->
<property>
<name>dfs.namenode.shared.edits.dir</name>
<value>qjournal://node-3:8485;node-4:8485/ns</value>
</property>

<!-- 指定JournalNode在本地磁盘存放数据的位置 -->
<property>
<name>dfs.journalnode.edits.dir</name>
<value>/export/data/hadoop/journaldata</value>
</property>

<!-- 开启NameNode失败自动切换 -->
<property>
<name>dfs.ha.automatic-failover.enabled</name>
<value>true</value>
</property>

<!-- 配置失败自动切换实现方式 -->
<property>
<name>dfs.client.failover.proxy.provider.bi</name>
<value>org.apache.hadoop.hdfs.server.namenode.ha.ConfiguredFailoverProxyProvider</value>
</property>

<!-- 配置隔离机制方法,多个机制用换行分割,即每个机制暂用一行-->
<property>
<name>dfs.ha.fencing.methods</name>
<value>
sshfence
shell(/bin/true)
</value>
</property>

<!-- 使用sshfence隔离机制时需要ssh免登陆 -->
<property>
<name>dfs.ha.fencing.ssh.private-key-files</name>
<value>/root/.ssh/id_rsa</value>
</property>

<!-- 配置sshfence隔离机制超时时间 -->
<property>
<name>dfs.ha.fencing.ssh.connect-timeout</name>
<value>30000</value>
</property>

<property>
<name>dfs.ha.namenodes.jn</name>
<value>node-3,node-4</value>
</property>

</configuration>

# 配置mapred-site.xml
$ vim etc/hadoop/mapred-site.xml

<?xml version="1.0"?>
<?xml-stylesheet type="text/xsl" href="configuration.xsl"?>
<!--
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License. See accompanying LICENSE file.
-->

<!-- Put site-specific property overrides in this file. -->

<configuration>

<property>
<name>mapreduce.framework.name</name>
<value>yarn</value>
</property>

<property>
<name>yarn.app.mapreduce.am.env</name>
<value>HADOOP_MAPRED_HOME=${HADOOP_HOME}</value>
</property>

<property>
<name>mapreduce.map.env</name>
<value>HADOOP_MAPRED_HOME=${HADOOP_HOME}</value>
</property>

<property>
<name>mapreduce.reduce.env</name>
<value>HADOOP_MAPRED_HOME=${HADOOP_HOME}</value>
</property>

</configuration>


# 配置yarn-site.xml
$ vim etc/hadoop/yarn-site.xml

<?xml version="1.0"?>
<!--
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License. See accompanying LICENSE file.
-->
<configuration>
<!-- Site specific YARN configuration properties -->

<!-- 开启RM高可用 -->
<property>
<name>yarn.resourcemanager.ha.enabled</name>
<value>true</value>
</property>

<!-- 指定RM的cluster id -->
<property>
<name>yarn.resourcemanager.cluster-id</name>
<value>yarn-ha</value>
</property>

<!-- 指定RM的名字 -->
<property>
<name>yarn.resourcemanager.ha.rm-ids</name>
<value>rm1,rm2</value>
</property>

<!-- 分别指定RM的地址 -->
<property>
<name>yarn.resourcemanager.hostname.rm1</name>
<value>node-1</value>
</property>

<property>
<name>yarn.resourcemanager.hostname.rm2</name>
<value>node-2</value>
</property>

<!-- 指定zk集群地址 -->
<property>
<name>yarn.resourcemanager.zk-address</name>
<value>node-2:2181,node-3:2181,node-4:2181</value>
</property>

<property>
<name>yarn.nodemanager.aux-services</name>
<value>mapreduce_shuffle</value>
</property>

</configuration>


# 配置workers节点
$ vim $HADOOP_HOME/etc/hadoop/workers

node-3
node-4

# 拷贝hadoop到其他节点(node-2,node-3,node-4)
$ scp -r hadoop-3.2.0 root@node-2:/xxx/xxx/

hdfs HA 配置

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
复制代码# 启动zookeeper集群
$ $ZOOKEEPER_HOME/bin/zkServer.sh start

# 查看zookeeper状态
$ $ZOOKEEPER_HOME/bin/zkServer.sh status

# 启动 JournalNode 集群 分别在 node-3、node-4 上执行以下命令
$ hdfs --daemon start journalnode

# 格式化 ZooKeeper 集群
$ hdfs zkfc -formatZK

# 格式化集群的 NameNode (在 node-1 上执行)
$ hdfs namenode -format

# 启动刚格式化的 NameNode (在 node-1 上执行)
$ hdfs --daemon start namenode

# 同步 NameNode1 元数据到 NameNode2 上 (在 node-2 上执行)
$ hdfs namenode -bootstrapStandby

# 启动 NameNode2 (在 node-2 上执行)
$ hdfs --daemon start namenode

# 启动集群中所有的DataNode (在 node-1 上执行)
$ sbin/start-dfs.sh

# 启动 ZKFC 进程 (在 node-1 和 node-2 的主机上分别执行如下命令)
$ hdfs --daemon start zkfc

# 验证ha(在node-1节点停掉namenode进程)
$ hafs --daemon stop namenode

resourceManager HA 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码# 在 RM1 启动 YARN (在 node-1 上执行)
$ yarn --daemon start resourcemanager

# 在 RM2 启动 YARN (在 node-2 上执行)
$ yarn --daemon start resourcemanager

# 在任意节点执行获取resourceManager状态(active)
$ yarn rmadmin -getServiceState rm1

# 在任意节点执行获取resourceManager状态(standby)
$ yarn rmadmin -getServiceState rm2

# 验证 yarn 的 ha(在node-1节点执行)standby 的 resourcemanager 则会转换为 active
$ yarn --daemon stop resourcemanager

# 在任意节点执行获取resourceManager状态(active)
$ yarn rmadmin -getServiceState rm2

总结

搭建hadoop HA 过程中遇到了很多各种各样的问题上述步骤都是经过验证的如在安装过程中遇到问题可以留言,谢谢!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

SpringMVC HandlerInterceptor诡异

发表于 2019-05-22

发现问题

最近在进行压测发现,有一些接口时好时坏,通过sentry日志平台及sky walking平台跟踪发现,用户张三获取到的用户上下文确是李四。

代码走读

用户登录下上文

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
typescript复制代码/**
* 用户登录下上文
*
* @author : jamesfu
* @date : 22/5/2019
* @time : 9:18 AM
*/
@Data
public class UserContext {
private final static ThreadLocal<UserContext> threadLocal = new ThreadLocal<>();

private Long id;

private String loginName;

public static UserContext get() {
UserContext context = threadLocal.get();
if (context == null) {
// TODO(james.h.fu):根据请求上下文获取token, 然后恢复用户登录下上文
context = new UserContext() {{
setId(1L);
setLoginName("james.h.fu1");
}};
threadLocal.set(context);
}

return context;
}

public static void clear() {
threadLocal.remove();
}

public static void set(UserContext context) {
if (context != null) {
threadLocal.set(context);
}
}
}

在拦截器中有调用UserContext.set恢复用户登录上下文,并在请求结束时调用UserContext.clear清理用户登录上下文。
拦截器在preHandle中根据请求上下文恢复登录信息

拦截器注册配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typescript复制代码/**
* 拦截器注册配置
*
* @author : jamesfu
* @date : 22/5/2019
* @time : 9:15 AM
*/
@Configuration
public class FilterConfig implements WebMvcConfigurer {
@Autowired
private JsonRpcInterceptor jsonRpcInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jsonRpcInterceptor)
.addPathPatterns("/json.rpc");
}
}

调试过程中探测springmvc已注册的拦截器有四个

遇到preHandle返回false就中断调用链条,开始执行afterCompletion

调试日志也验证了确实没有执行afterCompletion中的UserContext.clear

通过debug可以发现UserContext中的ThreadLocal的清理工作没有得到执行。导致请求进来时,有可能ThreadLocal已存在了,就不会再根据请求上下文恢复了。

springmvc 源码走读

tomcat 在收到http请求后,最终会交由spring mvc的DispatcherServlet处理。
这里可以从doDispatch按图索骥,顺藤摸瓜地往下看起走。

源码走读:DispatcherServlet

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码/**
* Process the actual dispatching to the handler.
* <p>The handler will be obtained by applying the servlet's HandlerMappings in order.
* The HandlerAdapter will be obtained by querying the servlet's installed HandlerAdapters
* to find the first that supports the handler class.
* <p>All HTTP methods are handled by this method. It's up to HandlerAdapters or handlers
* themselves to decide which methods are acceptable.
* @param request current HTTP request
* @param response current HTTP response
* @throws Exception in case of any kind of processing failure
*/
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception

请求会得到分发,然后执行各个已注册Handler的preHandle–>postHandle–>afterCompletion。

源码走读:HandlerExecutionChain

applyPreHandle
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
java复制代码/**
* Apply preHandle methods of registered interceptors.
* @return {@code true} if the execution chain should proceed with the
* next interceptor or the handler itself. Else, DispatcherServlet assumes
* that this interceptor has already dealt with the response itself.
*/
boolean applyPreHandle(HttpServletRequest request, HttpServletResponse response) throws Exception {
HandlerInterceptor[] interceptors = getInterceptors();
if (!ObjectUtils.isEmpty(interceptors)) {
for (int i = 0; i < interceptors.length; i++) {
HandlerInterceptor interceptor = interceptors[i];
if (!interceptor.preHandle(request, response, this.handler)) {
triggerAfterCompletion(request, response, null);
return false;
}
this.interceptorIndex = i;
}
}
return true;
}

当执行到preHandle返回false时,它就会从上一个返回true的handler依次往前执行afterCompletion,它自己的afterCompletion得不到执行。

triggerAfterCompletion
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
less复制代码/**
* Trigger afterCompletion callbacks on the mapped HandlerInterceptors.
* Will just invoke afterCompletion for all interceptors whose preHandle invocation
* has successfully completed and returned true.
*/
void triggerAfterCompletion(HttpServletRequest request, HttpServletResponse response, @Nullable Exception ex)
throws Exception {

HandlerInterceptor[] interceptors = getInterceptors();
if (!ObjectUtils.isEmpty(interceptors)) {
for (int i = this.interceptorIndex; i >= 0; i--) {
HandlerInterceptor interceptor = interceptors[i];
try {
interceptor.afterCompletion(request, response, this.handler, ex);
}
catch (Throwable ex2) {
logger.error("HandlerInterceptor.afterCompletion threw exception", ex2);
}
}
}
}

triggerAfterCompletion只会在(1)出现异常,(2)preHandle返回false 或(3)正常执行结束才会从索引interceptorIndex依次往前执行。

所以基于以上源码可以得知,在写拦截器时preHandle返回false时,afterCompletion是不会执行的。所以一些必要的清理工作得不到执行,会出现类似我们遇到的帐号串的问题。

参考资料

  • Spring 拦截器——HandlerInterceptor
  • SpringMVC HandlerInterceptor诡异问题排查

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java设计模式精讲 结构型模式 行为型模式 总结

发表于 2019-05-20

设计模式是对大家实际工作中写的各种代码进行高层次抽象的总结

  • 设计模式分为 23 种经典的模式,根据用途我们又可以分为三大类。分别是创建型模式、结构型模式和行为型模式
  • 列举几种设计原则,这几种设计原则将贯通全文:
    • 面向接口编程,而不是面向实现。这个尤为重要,也是优雅的、可扩展的代码的第一步,这就不需要多说了吧
    • 职责单一原则。每个类都应该只有一个单一的功能,并且该功能应该由这个类完全封装起来
    • 对修改关闭,对扩展开放。对修改关闭是说,我们辛辛苦苦加班写出来的代码,该实现的功能和该修复的 bug 都完成了,别人可不能说改就改;对扩展开放就比较好理解了,也就是说在我们写好的代码基础上,很容易实现扩展。

创建型模式

  • 创建型模式的作用就是创建对象,new 一个对象,然后 set 相关属性。但是,在很多场景下,我们需要给客户端提供更加友好的创建对象的方式,尤其是那种我们定义了类,但是需要提供给其他开发者用的时候。

简单工厂模式

  • 和名字一样简单,非常简单,直接上代码吧:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码public class FoodFactory {

public static Food makeFood(String name) {
if (name.equals("noodle")) {
Food noodle = new LanZhouNoodle();
noodle.addSpicy("more");
return noodle;
} else if (name.equals("chicken")) {
Food chicken = new HuangMenChicken();
chicken.addCondiment("potato");
return chicken;
} else {
return null;
}
}
}
  • 其中,LanZhouNoodle 和 HuangMenChicken 都继承自 Food。
  • 简单地说,简单工厂模式通常就是这样,一个工厂类 XxxFactory,里面有一个静态方法,根据我们不同的参数,返回不同的派生自同一个父类(或实现同一接口)的实例对象。

我们强调职责单一原则,一个类只提供一种功能,FoodFactory 的功能就是只要负责生产各种 Food。

工厂模式

  • 简单工厂模式很简单,如果它能满足我们的需要,我觉得就不要折腾了。之所以需要引入工厂模式,是因为我们往往需要使用两个或两个以上的工厂。
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
复制代码public interface FoodFactory {
Food makeFood(String name);
}
public class ChineseFoodFactory implements FoodFactory {

@Override
public Food makeFood(String name) {
if (name.equals("A")) {
return new ChineseFoodA();
} else if (name.equals("B")) {
return new ChineseFoodB();
} else {
return null;
}
}
}
public class AmericanFoodFactory implements FoodFactory {

@Override
public Food makeFood(String name) {
if (name.equals("A")) {
return new AmericanFoodA();
} else if (name.equals("B")) {
return new AmericanFoodB();
} else {
return null;
}
}
}
  • 其中,ChineseFoodA、ChineseFoodB、AmericanFoodA、AmericanFoodB 都派生自 Food。
  • 客户端调用:
1
2
3
4
5
6
7
8
复制代码public class APP {
public static void main(String[] args) {
// 先选择一个具体的工厂
FoodFactory factory = new ChineseFoodFactory();
// 由第一步的工厂产生具体的对象,不同的工厂造出不一样的对象
Food food = factory.makeFood("A");
}
}
  • 虽然都是调用 makeFood(“A”) 制作 A 类食物,但是,不同的工厂生产出来的完全不一样。
  • 第一步,我们需要选取合适的工厂,然后第二步基本上和简单工厂一样。
  • **核心在于,我们需要在第一步选好我们需要的工厂。**比如,我们有 LogFactory 接口,实现类有 FileLogFactory 和 KafkaLogFactory,分别对应将日志写入文件和写入 Kafka 中,显然,我们客户端第一步就需要决定到底要实例化 FileLogFactory 还是 KafkaLogFactory,这将决定之后的所有的操作。
  • 虽然简单,不过我也把所有的构件都画到一张图上,这样看着比较清晰:

抽象工厂模式

  • 当涉及到产品族的时候,就需要引入抽象工厂模式了。
  • 一个经典的例子是造一台电脑。我们先不引入抽象工厂模式,看看怎么实现。
  • 因为电脑是由许多的构件组成的,我们将 CPU 和主板进行抽象,然后 CPU 由 CPUFactory 生产,主板由 MainBoardFactory 生产,然后,我们再将 CPU 和主板搭配起来组合在一起,如下图:

  • 这个时候的客户端调用是这样的:
1
2
3
4
5
6
7
8
9
10
复制代码// 得到 Intel 的 CPU
CPUFactory cpuFactory = new IntelCPUFactory();
CPU cpu = intelCPUFactory.makeCPU();

// 得到 AMD 的主板
MainBoardFactory mainBoardFactory = new AmdMainBoardFactory();
MainBoard mainBoard = mainBoardFactory.make();

// 组装 CPU 和主板
Computer computer = new Computer(cpu, mainBoard);
  • 单独看 CPU 工厂和主板工厂,它们分别是前面我们说的工厂模式。这种方式也容易扩展,因为要给电脑加硬盘的话,只需要加一个 HardDiskFactory 和相应的实现即可,不需要修改现有的工厂。
  • 但是,这种方式有一个问题,那就是如果** Intel 家产的 CPU 和 AMD 产的主板不能兼容使用**,那么这代码就容易出错,因为客户端并不知道它们不兼容,也就会错误地出现随意组合。

-下面就是我们要说的产品族的概念,它代表了组成某个产品的一系列附件的集合:

  • 当涉及到这种产品族的问题的时候,就需要抽象工厂模式来支持了。我们不再定义 CPU 工厂、主板工厂、硬盘工厂、显示屏工厂等等,我们直接定义电脑工厂,每个电脑工厂负责生产所有的设备,这样能保证肯定不存在兼容问题。

  • 这个时候,对于客户端来说,不再需要单独挑选 CPU厂商、主板厂商、硬盘厂商等,直接选择一家品牌工厂,品牌工厂会负责生产所有的东西,而且能保证肯定是兼容可用的。
1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码public static void main(String[] args) {
// 第一步就要选定一个“大厂”
ComputerFactory cf = new AmdFactory();
// 从这个大厂造 CPU
CPU cpu = cf.makeCPU();
// 从这个大厂造主板
MainBoard board = cf.makeMainBoard();
// 从这个大厂造硬盘
HardDisk hardDisk = cf.makeHardDisk();

// 将同一个厂子出来的 CPU、主板、硬盘组装在一起
Computer result = new Computer(cpu, board, hardDisk);
}
  • 当然,抽象工厂的问题也是显而易见的,比如我们要加个显示器,就需要修改所有的工厂,给所有的工厂都加上制造显示器的方法。这有点违反了对修改关闭,对扩展开放这个设计原则。

单例模式

  • 单例模式用得最多,错得最多。
  • 饿汉模式最简单:
1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码public class Singleton {
// 首先,将 new Singleton() 堵死
private Singleton() {};
// 创建私有静态实例,意味着这个类第一次使用的时候就会进行创建
private static Singleton instance = new Singleton();

public static Singleton getInstance() {
return instance;
}
// 瞎写一个静态方法。这里想说的是,如果我们只是要调用 Singleton.getDate(...),
// 本来是不想要生成 Singleton 实例的,不过没办法,已经生成了
public static Date getDate(String mode) {return new Date();}
}

很多人都能说出饿汉模式的缺点,可是我觉得生产过程中,很少碰到这种情况:你定义了一个单例的类,不需要其实例,可是你却把一个或几个你会用到的静态方法塞到这个类中。

  • 饱汉模式最容易出错:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
复制代码public class Singleton {
// 首先,也是先堵死 new Singleton() 这条路
private Singleton() {}
// 和饿汉模式相比,这边不需要先实例化出来,注意这里的 volatile,它是必须的
private static volatile Singleton instance = null;

public static Singleton getInstance() {
if (instance == null) {
// 加锁
synchronized (Singleton.class) {
// 这一次判断也是必须的,不然会有并发问题
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

双重检查,指的是两次检查 instance 是否为 null。
volatile 在这里是需要的,希望能引起读者的关注。
很多人不知道怎么写,直接就在 getInstance() 方法签名上加上 synchronized,这就不多说了,性能太差。

  • 嵌套类最经典,以后大家就用它吧:
1
2
3
4
5
6
7
8
9
10
11
复制代码public class Singleton3 {

private Singleton3() {}
// 主要是使用了 嵌套类可以访问外部类的静态属性和静态方法 的特性
private static class Holder {
private static Singleton3 instance = new Singleton3();
}
public static Singleton3 getInstance() {
return Holder.instance;
}
}

注意,很多人都会把这个嵌套类说成是静态内部类,严格地说,内部类和嵌套类是不一样的,它们能访问的外部类权限也是不一样的。

  • 最后,一定有人跳出来说用枚举实现单例,是的没错,枚举类很特殊,它在类加载的时候会初始化里面的所有的实例,而且 JVM 保证了它们不会再被实例化,所以它天生就是单例的。不说了,读者自己看着办吧,不建议使用。

建造者模式

  • 经常碰见的 XxxBuilder 的类,通常都是建造者模式的产物。建造者模式其实有很多的变种,但是对于客户端来说,我们的使用通常都是一个模式的
1
2
复制代码Food food = new FoodBuilder().a().b().c().build();
Food food = Food.builder().a().b().c().build();
  • 套路就是先 new 一个 Builder,然后可以链式地调用一堆方法,最后再调用一次 build() 方法,我们需要的对象就有了。
  • 来一个中规中矩的建造者模式:
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
59
60
61
62
63
64
65
66
67
68
复制代码class User {
// 下面是“一堆”的属性
private String name;
private String password;
private String nickName;
private int age;

// 构造方法私有化,不然客户端就会直接调用构造方法了
private User(String name, String password, String nickName, int age) {
this.name = name;
this.password = password;
this.nickName = nickName;
this.age = age;
}
// 静态方法,用于生成一个 Builder,这个不一定要有,不过写这个方法是一个很好的习惯,
// 有些代码要求别人写 new User.UserBuilder().a()...build() 看上去就没那么好
public static UserBuilder builder() {
return new UserBuilder();
}

public static class UserBuilder {
// 下面是和 User 一模一样的一堆属性
private String name;
private String password;
private String nickName;
private int age;

private UserBuilder() {
}

// 链式调用设置各个属性值,返回 this,即 UserBuilder
public UserBuilder name(String name) {
this.name = name;
return this;
}

public UserBuilder password(String password) {
this.password = password;
return this;
}

public UserBuilder nickName(String nickName) {
this.nickName = nickName;
return this;
}

public UserBuilder age(int age) {
this.age = age;
return this;
}

// build() 方法负责将 UserBuilder 中设置好的属性“复制”到 User 中。
// 当然,可以在 “复制” 之前做点检验
public User build() {
if (name == null || password == null) {
throw new RuntimeException("用户名和密码必填");
}
if (age <= 0 || age >= 150) {
throw new RuntimeException("年龄不合法");
}
// 还可以做赋予”默认值“的功能
if (nickName == null) {
nickName = name;
}
return new User(name, password, nickName, age);
}
}
}
  • 核心是:先把所有的属性都设置给 Builder,然后 build() 方法的时候,将这些属性复制给实际产生的对象。
  • 看看客户端的调用:
1
2
3
4
5
6
7
8
9
复制代码public class APP {
public static void main(String[] args) {
User d = User.builder()
.name("foo")
.password("pAss12345")
.age(25)
.build();
}
}
  • 说实话,建造者模式的链式写法很吸引人,但是,多写了很多“无用”的 builder 的代码,感觉这个模式没什么用。不过,当属性很多,而且有些必填,有些选填的时候,这个模式会使代码清晰很多。我们可以在 Builder 的构造方法中强制让调用者提供必填字段,还有,在 build() 方法中校验各个参数比在 User 的构造方法中校验,代码要优雅一些。

题外话,强烈建议读者使用 lombok,用了 lombok 以后,上面的一大堆代码会变成如下这样:

1
2
3
4
5
6
7
复制代码@Builder
class User {
private String name;
private String password;
private String nickName;
private int age;
}

怎么样,省下来的时间是不是又可以干点别的了。

  • 当然,如果你只是想要链式写法,不想要建造者模式,有个很简单的办法,User 的 getter 方法不变,所有的 setter 方法都让其 *return this 就可以了,然后就可以像下面这样调用:
1
复制代码User user = new User().setName("").setPassword("").setAge(20);

原型模式

  • 这是我要说的创建型模式的最后一个设计模式了。
  • 原型模式很简单:有一个原型实例,基于这个原型实例产生新的实例,也就是“克隆”了。
  • Object 类中有一个 clone() 方法,它用于生成一个新的对象,当然,如果我们要调用这个方法,java 要求我们的类必须先实现 Cloneable 接口,此接口没有定义任何方法,但是不这么做的话,在 clone() 的时候,会抛出 CloneNotSupportedException 异常。
1
复制代码protected native Object clone() throws CloneNotSupportedException;

java 的克隆是浅克隆,碰到对象引用的时候,克隆出来的对象和原对象中的引用将指向同一个对象。通常实现深克隆的方法是将对象进行序列化,然后再进行反序列化。

  • 原型模式了解到这里我觉得就够了,各种变着法子说这种代码或那种代码是原型模式,没什么意义。

创建型模式总结

  • 创建型模式总体上比较简单,它们的作用就是为了产生实例对象,算是各种工作的第一步了,因为我们写的是面向对象的代码,所以我们第一步当然是需要创建一个对象了。
  • 简单工厂模式最简单;工厂模式在简单工厂模式的基础上增加了选择工厂的维度,需要第一步选择合适的工厂;抽象工厂模式有产品族的概念,如果各个产品是存在兼容性问题的,就要用抽象工厂模式。单例模式就不说了,为了保证全局使用的是同一对象,一方面是安全性考虑,一方面是为了节省资源;建造者模式专门对付属性很多的那种类,为了让代码更优美;原型模式用得最少,了解和 Object 类中的 clone() 方法相关的知识即可。

结构型模式

  • 前面创建型模式介绍了创建对象的一些设计模式,这节介绍的结构型模式旨在通过改变代码结构来达到解耦的目的,使得我们的代码容易维护和扩展。

代理模式

  • 第一个要介绍的代理模式是最常使用的模式之一了,用一个代理来隐藏具体实现类的实现细节,通常还用于在真实的实现的前后添加一部分逻辑。
  • 既然说是代理,那就要对客户端隐藏真实实现,由代理来负责客户端的所有请求。当然,代理只是个代理,它不会完成实际的业务逻辑,而是一层皮而已,但是对于客户端来说,它必须表现得就是客户端需要的真实实现。

理解代理这个词,这个模式其实就简单了。

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
复制代码
public interface FoodService {
Food makeChicken();
Food makeNoodle();
}

public class FoodServiceImpl implements FoodService {
public Food makeChicken() {
Food f = new Chicken()
f.setChicken("1kg");
f.setSpicy("1g");
f.setSalt("3g");
return f;
}
public Food makeNoodle() {
Food f = new Noodle();
f.setNoodle("500g");
f.setSalt("5g");
return f;
}
}

// 代理要表现得“就像是”真实实现类,所以需要实现 FoodService
public class FoodServiceProxy implements FoodService {

// 内部一定要有一个真实的实现类,当然也可以通过构造方法注入
private FoodService foodService = new FoodServiceImpl();

public Food makeChicken() {
System.out.println("我们马上要开始制作鸡肉了");

// 如果我们定义这句为核心代码的话,那么,核心代码是真实实现类做的,
// 代理只是在核心代码前后做些“无足轻重”的事情
Food food = foodService.makeChicken();

System.out.println("鸡肉制作完成啦,加点胡椒粉"); // 增强
food.addCondiment("pepper");

return food;
}
public Food makeNoodle() {
System.out.println("准备制作拉面~");
Food food = foodService.makeNoodle();
System.out.println("制作完成啦")
return food;
}
}
  • 客户端调用,注意,我们要用代理来实例化接口:
1
2
3
复制代码// 这里用代理类来实例化
FoodService foodService = new FoodServiceProxy();
foodService.makeChicken();

  • 我们发现没有,代理模式说白了就是做 “方法包装” 或做 “方法增强”。在面向切面编程中,算了还是不要吹捧这个名词了,在 AOP 中,其实就是动态代理的过程。比如 Spring 中,我们自己不定义代理类,但是 Spring 会帮我们动态来定义代理,然后把我们定义在 @Before、@After、@Around 中的代码逻辑动态添加到代理中。
  • 说到动态代理,又可以展开说 …… Spring 中实现动态代理有两种,一种是如果我们的类定义了接口,如 UserService 接口和 UserServiceImpl 实现,那么采用 JDK 的动态代理,感兴趣的读者可以去看看 java.lang.reflect.Proxy 类的源码;另一种是我们自己没有定义接口的,Spring 会采用 CGLIB 进行动态代理,它是一个 jar 包,性能还不错。

适配器模式

  • 说完代理模式,说适配器模式,是因为它们很相似,这里可以做个比较。
  • 适配器模式做的就是,有一个接口需要实现,但是我们现成的对象都不满足,需要加一层适配器来进行适配
  • 适配器模式总体来说分三种:默认适配器模式、对象适配器模式、类适配器模式。先不急着分清楚这几个,先看看例子再说。

默认适配器模式

  • 首先,我们先看看最简单的适配器模式**默认适配器模式(Default Adapter)**是怎么样的。
  • 我们用 Appache commons-io 包中的 FileAlterationListener 做例子,此接口定义了很多的方法,用于对文件或文件夹进行监控,一旦发生了对应的操作,就会触发相应的方法。
1
2
3
4
5
6
7
8
9
10
复制代码public interface FileAlterationListener {
void onStart(final FileAlterationObserver observer);
void onDirectoryCreate(final File directory);
void onDirectoryChange(final File directory);
void onDirectoryDelete(final File directory);
void onFileCreate(final File file);
void onFileChange(final File file);
void onFileDelete(final File file);
void onStop(final FileAlterationObserver observer);
}
  • 此接口的一大问题是抽象方法太多了,如果我们要用这个接口,意味着我们要实现每一个抽象方法,如果我们只是想要监控文件夹中的文件创建和文件删除事件,可是我们还是不得不实现所有的方法,很明显,这不是我们想要的。
  • 所以,我们需要下面的一个适配器,它用于实现上面的接口,但是所有的方法都是空方法,这样,我们就可以转而定义自己的类来继承下面这个类即可。
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
复制代码public class FileAlterationListenerAdaptor implements FileAlterationListener {

public void onStart(final FileAlterationObserver observer) {
}

public void onDirectoryCreate(final File directory) {
}

public void onDirectoryChange(final File directory) {
}

public void onDirectoryDelete(final File directory) {
}

public void onFileCreate(final File file) {
}

public void onFileChange(final File file) {
}

public void onFileDelete(final File file) {
}

public void onStop(final FileAlterationObserver observer) {
}
}
  • 比如我们可以定义以下类,我们仅仅需要实现我们想实现的方法就可以了:
1
2
3
4
5
6
7
8
9
10
11
复制代码public class FileMonitor extends FileAlterationListenerAdaptor {
public void onFileCreate(final File file) {
// 文件创建
doSomething();
}

public void onFileDelete(final File file) {
// 文件删除
doSomething();
}
}
  • 当然,上面说的只是适配器模式的其中一种,也是最简单的一种,无需多言。下面,再介绍“正统的”适配器模式。

对象适配器模式

  • 来看一个《Head First 设计模式》中的一个例子,我稍微修改了一下,看看怎么将鸡适配成鸭,这样鸡也能当鸭来用。因为,现在鸭这个接口,我们没有合适的实现类可以用,所以需要适配器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码public interface Duck {
public void quack(); // 鸭的呱呱叫
public void fly(); // 飞
}

public interface Cock {
public void gobble(); // 鸡的咕咕叫
public void fly(); // 飞
}

public class WildCock implements Cock {
public void gobble() {
System.out.println("咕咕叫");
}
public void fly() {
System.out.println("鸡也会飞哦");
}
}
  • 鸭接口有 fly() 和 quare() 两个方法,鸡 Cock 如果要冒充鸭,fly() 方法是现成的,但是鸡不会鸭的呱呱叫,没有 quack() 方法。这个时候就需要适配了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码// 毫无疑问,首先,这个适配器肯定需要 implements Duck,这样才能当做鸭来用
public class CockAdapter implements Duck {

Cock cock;
// 构造方法中需要一个鸡的实例,此类就是将这只鸡适配成鸭来用
public CockAdapter(Cock cock) {
this.cock = cock;
}

// 实现鸭的呱呱叫方法
@Override
public void quack() {
// 内部其实是一只鸡的咕咕叫
cock.gobble();
}

@Override
public void fly() {
cock.fly();
}
}
  • 客户端调用很简单了:
1
2
3
4
5
6
7
复制代码public static void main(String[] args) {
// 有一只野鸡
Cock wildCock = new WildCock();
// 成功将野鸡适配成鸭
Duck duck = new CockAdapter(wildCock);
...
}
  • 到这里,大家也就知道了适配器模式是怎么回事了。无非是我们需要一只鸭,但是我们只有一只鸡,这个时候就需要定义一个适配器,由这个适配器来充当鸭,但是适配器里面的方法还是由鸡来实现的。
  • 我们用一个图来简单说明下:

  • 上图应该还是很容易理解的,我就不做更多的解释了。下面,我们看看类适配模式怎么样的。

类适配器模式

  • 废话少说,直接上图:

  • 看到这个图,大家应该很容易理解的吧,通过继承的方法,适配器自动获得了所需要的大部分方法。这个时候,客户端使用更加简单,直接 Target t = new SomeAdapter(); 就可以了。

适配器模式总结

  • 类适配和对象适配的异同
    • 一个采用继承,一个采用组合;

    • 类适配属于静态实现,对象适配属于组合的动态实现,对象适配需要多实例化一个对象。

    • 总体来说,对象适配用得比较多。

  • 适配器模式和代理模式的异同
    • 比较这两种模式,其实是比较对象适配器模式和代理模式,在代码结构上,它们很相似,都需要一个具体的实现类的实例。但是它们的目的不一样,代理模式做的是增强原方法的活;适配器做的是适配的活,为的是提供“把鸡包装成鸭,然后当做鸭来使用”,而鸡和鸭它们之间原本没有继承关系。

桥梁模式

  • 理解桥梁模式,其实就是理解代码抽象和解耦。
  • 我们首先需要一个桥梁,它是一个接口,定义提供的接口方法。
1
2
3
复制代码public interface DrawAPI {
public void draw(int radius, int x, int y);
}
  • 然后是一系列实现类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码public class RedPen implements DrawAPI {
@Override
public void draw(int radius, int x, int y) {
System.out.println("用红色笔画图,radius:" + radius + ", x:" + x + ", y:" + y);
}
}
public class GreenPen implements DrawAPI {
@Override
public void draw(int radius, int x, int y) {
System.out.println("用绿色笔画图,radius:" + radius + ", x:" + x + ", y:" + y);
}
}
public class BluePen implements DrawAPI {
@Override
public void draw(int radius, int x, int y) {
System.out.println("用蓝色笔画图,radius:" + radius + ", x:" + x + ", y:" + y);
}
}
  • 定义一个抽象类,此类的实现类都需要使用 DrawAPI:
1
2
3
4
5
6
7
8
复制代码public abstract class Shape {
protected DrawAPI drawAPI;

protected Shape(DrawAPI drawAPI){
this.drawAPI = drawAPI;
}
public abstract void draw();
}
  • 定义抽象类的子类:
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
复制代码// 圆形
public class Circle extends Shape {
private int radius;

public Circle(int radius, DrawAPI drawAPI) {
super(drawAPI);
this.radius = radius;
}

public void draw() {
drawAPI.draw(radius, 0, 0);
}
}
// 长方形
public class Rectangle extends Shape {
private int x;
private int y;

public Rectangle(int x, int y, DrawAPI drawAPI) {
super(drawAPI);
this.x = x;
this.y = y;
}
public void draw() {
drawAPI.draw(0, x, y);
}
}
  • 最后,我们来看客户端演示:
1
2
3
4
5
6
7
复制代码public static void main(String[] args) {
Shape greenCircle = new Circle(10, new GreenPen());
Shape redRectangle = new Rectangle(4, 8, new RedPen());

greenCircle.draw();
redRectangle.draw();
}
  • 可能大家看上面一步步还不是特别清晰,我把所有的东西整合到一张图上:

  • 这回大家应该就知道抽象在哪里,怎么解耦了吧。桥梁模式的优点也是显而易见的,就是非常容易进行扩展。

装饰模式

  • 要把装饰模式说清楚明白,不是件容易的事情。也许读者知道 Java IO 中的几个类是典型的装饰模式的应用,但是读者不一定清楚其中的关系,也许看完就忘了,希望看完这节后,读者可以对其有更深的感悟。
  • 首先,我们先看一个简单的图,看这个图的时候,了解下层次结构就可以了:

  • 我们来说说装饰模式的出发点,从图中可以看到,接口 Component其实已经有了 ConcreteComponentA 和 ConcreteComponentB 两个实现类了,但是,如果我们要增强这两个实现类的话,我们就可以采用装饰模式,用具体的装饰器来装饰实现类,以达到增强的目的。

从名字来简单解释下装饰器。既然说是装饰,那么往往就是添加小功能这种,而且,我们要满足可以添加多个小功能。最简单的,代理模式就可以实现功能的增强,但是代理不容易实现多个功能的增强,当然你可以说用代理包装代理的方式,但是那样的话代码就复杂了。

  • 首先明白一些简单的概念,从图中我们看到,所有的具体装饰者们 ConcreteDecorator
  • 都可以作为 Component 来使用,因为它们都实现了 Component 中的所有接口。它们和 Component 实现类 ConcreteComponent 的区别是,它们只是装饰者,起装饰作用,也就是即使它们看上去牛逼轰轰,但是它们都只是在具体的实现中加了层皮来装饰而已。

注意这段话中混杂在各个名词中的 Component 和 Decorator,别搞混了。

  • 下面来看看一个例子,先把装饰模式弄清楚,然后再介绍下 java io 中的装饰模式的应用。
  • 最近大街上流行起来了“快乐柠檬”,我们把快乐柠檬的饮料分为三类:红茶、绿茶、咖啡,在这三大类的基础上,又增加了许多的口味,什么金桔柠檬红茶、金桔柠檬珍珠绿茶、芒果红茶、芒果绿茶、芒果珍珠红茶、烤珍珠红茶、烤珍珠芒果绿茶、椰香胚芽咖啡、焦糖可可咖啡等等,每家店都有很长的菜单,但是仔细看下,其实原料也没几样,但是可以搭配出很多组合,如果顾客需要,很多没出现在菜单中的饮料他们也是可以做的。
  • 在这个例子中,红茶、绿茶、咖啡是最基础的饮料,其他的像金桔柠檬、芒果、珍珠、椰果、焦糖等都属于装饰用的。当然,在开发中,我们确实可以像门店一样,开发这些类:LemonBlackTea、LemonGreenTea、MangoBlackTea、MangoLemonGreenTea……但是,很快我们就发现,这样子干肯定是不行的,这会导致我们需要组合出所有的可能,而且如果客人需要在红茶中加双份柠檬怎么办?三份柠檬怎么办?万一有个变态要四份柠檬,所以这种做法是给自己找加班的。
  • 不说废话了,上代码。
  • 首先,定义饮料抽象基类:
1
2
3
4
5
6
复制代码public abstract class Beverage {
// 返回描述
public abstract String getDescription();
// 返回价格
public abstract double cost();
}
  • 然后是三个基础饮料实现类,红茶、绿茶和咖啡:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码public class BlackTea extends Beverage {
public String getDescription() {
return "红茶";
}
public double cost() {
return 10;
}
}
public class GreenTea extends Beverage {
public String getDescription() {
return "绿茶";
}
public double cost() {
return 11;
}
}
...// 咖啡省略
  • 定义调料,也就是装饰者的基类,此类必须继承自 Beverage:
1
2
3
4
复制代码// 调料
public abstract class Condiment extends Beverage {

}
  • 然后我们来定义柠檬、芒果等具体的调料,它们属于装饰者,毫无疑问,这些调料肯定都需要继承 Condiment 类:
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
复制代码public class Lemon extends Condiment {
private Beverage bevarage;
// 这里很关键,需要传入具体的饮料,如需要传入没有被装饰的红茶或绿茶,
// 当然也可以传入已经装饰好的芒果绿茶,这样可以做芒果柠檬绿茶
public Lemon(Beverage bevarage) {
this.bevarage = bevarage;
}
public String getDescription() {
// 装饰
return bevarage.getDescription() + ", 加柠檬";
}
public double cost() {
// 装饰
return beverage.cost() + 2; // 加柠檬需要 2 元
}
}
public class Mango extends Condiment {
private Beverage bevarage;
public Mango(Beverage bevarage) {
this.bevarage = bevarage;
}
public String getDescription() {
return bevarage.getDescription() + ", 加芒果";
}
public double cost() {
return beverage.cost() + 3; // 加芒果需要 3 元
}
}
...// 给每一种调料都加一个类
  • 看客户端调用
1
2
3
4
5
6
7
8
9
10
复制代码public static void main(String[] args) {
// 首先,我们需要一个基础饮料,红茶、绿茶或咖啡
Beverage beverage = new GreenTea();
// 开始装饰
beverage = new Lemon(beverage); // 先加一份柠檬
beverage = new Mongo(beverage); // 再加一份芒果

System.out.println(beverage.getDescription() + " 价格:¥" + beverage.cost());
//"绿茶, 加柠檬, 加芒果 价格:¥16"
}
  • 如果我们需要芒果珍珠双份柠檬红茶:
1
复制代码Beverage beverage = new Mongo(new Pearl(new Lemon(new Lemon(new BlackTea()))));
  • 是不是很变态?
  • 看看下图可能会清晰一些:

  • 到这里,大家应该已经清楚装饰模式了吧。
  • 下面,我们再来说说 java IO 中的装饰模式。看下图 InputStream 派生出来的部分类

  • 我们知道 InputStream 代表了输入流,具体的输入来源可以是文件(FileInputStream)、管道(PipedInputStream)、数组(ByteArrayInputStream)等,这些就像前面奶茶的例子中的红茶、绿茶,属于基础输入流。
  • FilterInputStream 承接了装饰模式的关键节点,其实现类是一系列装饰器,比如 BufferedInputStream 代表用缓冲来装饰,也就使得输入流具有了缓冲的功能,LineNumberInputStream 代表用行号来装饰,在操作的时候就可以取得行号了,DataInputStream 的装饰,使得我们可以从输入流转换为 java 中的基本类型值。
  • 当然,在 java IO 中,如果我们使用装饰器的话,就不太适合面向接口编程了,如:
1
复制代码InputStream inputStream = new LineNumberInputStream(new BufferedInputStream(new FileInputStream("")));
  • 这样的结果是,InputStream 还是不具有读取行号的功能,因为读取行号的方法定义在 LineNumberInputStream 类中。
  • 我们应该像下面这样使用:
1
2
3
复制代码DataInputStream is = new DataInputStream(
new BufferedInputStream(
new FileInputStream("")));

所以说嘛,要找到纯的严格符合设计模式的代码还是比较难的

门面模式

  • 门面模式(也叫外观模式,Facade Pattern)在许多源码中有使用,比如 slf4j 就可以理解为是门面模式的应用。这是一个简单的设计模式,我们直接上代码再说吧。
  • 首先,我们定义一个接口:
1
2
3
复制代码public interface Shape {
void draw();
}
  • 定义几个实现类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码public class Circle implements Shape {

@Override
public void draw() {
System.out.println("Circle::draw()");
}
}

public class Rectangle implements Shape {

@Override
public void draw() {
System.out.println("Rectangle::draw()");
}
}
  • 客户端调用:
1
2
3
4
5
6
7
8
9
复制代码public static void main(String[] args) {
// 画一个圆形
Shape circle = new Circle();
circle.draw();

// 画一个长方形
Shape rectangle = new Rectangle();
rectangle.draw();
}
  • 以上是我们常写的代码,我们需要画圆就要先实例化圆,画长方形就需要先实例化一个长方形,然后再调用相应的 draw() 方法。
  • 下面,我们看看怎么用门面模式来让客户端调用更加友好一些。
  • 我们先定义一个门面:
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
复制代码public class ShapeMaker {
private Shape circle;
private Shape rectangle;
private Shape square;

public ShapeMaker() {
circle = new Circle();
rectangle = new Rectangle();
square = new Square();
}

/**
* 下面定义一堆方法,具体应该调用什么方法,由这个门面来决定
*/

public void drawCircle(){
circle.draw();
}
public void drawRectangle(){
rectangle.draw();
}
public void drawSquare(){
square.draw();
}
}
  • 看看现在客户端怎么调用:
1
2
3
4
5
6
7
8
复制代码public static void main(String[] args) {
ShapeMaker shapeMaker = new ShapeMaker();

// 客户端调用现在更加清晰了
shapeMaker.drawCircle();
shapeMaker.drawRectangle();
shapeMaker.drawSquare();
}
  • 门面模式的优点显而易见,客户端不再需要关注实例化时应该使用哪个实现类,直接调用门面提供的方法就可以了,因为门面类提供的方法的方法名对于客户端来说已经很友好了。

组合模式

  • 组合模式用于表示具有层次结构的数据,使得我们对单个对象和组合对象的访问具有一致性。
  • 直接看一个例子吧,每个员工都有姓名、部门、薪水这些属性,同时还有下属员工集合(虽然可能集合为空),而下属员工和自己的结构是一样的,也有姓名、部门这些属性,同时也有他们的下属员工集合。
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
复制代码public class Employee {
private String name;
private String dept;
private int salary;
private List<Employee> subordinates; // 下属

public Employee(String name,String dept, int sal) {
this.name = name;
this.dept = dept;
this.salary = sal;
subordinates = new ArrayList<Employee>();
}

public void add(Employee e) {
subordinates.add(e);
}

public void remove(Employee e) {
subordinates.remove(e);
}

public List<Employee> getSubordinates(){
return subordinates;
}

public String toString(){
return ("Employee :[ Name : " + name + ", dept : " + dept + ", salary :" + salary+" ]");
}
}
  • 通常,这种类需要定义 add(node)、remove(node)、getChildren() 这些方法。
  • 这说的其实就是组合模式,这种简单的模式我就不做过多介绍了,相信各位读者也不喜欢看我写废话。

享元模式

  • 英文是 Flyweight Pattern,不知道是谁最先翻译的这个词,感觉这翻译真的不好理解,我们试着强行关联起来吧。Flyweight 是轻量级的意思,享元分开来说就是 共享 元器件,也就是复用已经生成的对象,这种做法当然也就是轻量级的了。
  • 复用对象最简单的方式是,用一个 HashMap 来存放每次新生成的对象。每次需要一个对象的时候,先到 HashMap 中看看有没有,如果没有,再生成新的对象,然后将这个对象放入 HashMap 中。
  • 这种简单的代码我就不演示了。

结构型模式总结

  • 前面,我们说了代理模式、适配器模式、桥梁模式、装饰模式、门面模式、组合模式和享元模式。读者是否可以分别把这几个模式说清楚了呢?在说到这些模式的时候,心中是否有一个清晰的图或处理流程在脑海里呢?
  • 代理模式是做方法增强的,适配器模式是把鸡包装成鸭这种用来适配接口的,桥梁模式做到了很好的解耦,装饰模式从名字上就看得出来,适合于装饰类或者说是增强类的场景,门面模式的优点是客户端不需要关心实例化过程,只要调用需要的方法即可,组合模式用于描述具有层次结构的数据,享元模式是为了在特定的场景中缓存已经创建的对象,用于提高性能。

行为型模式

  • 行为型模式关注的是各个类之间的相互作用,将职责划分清楚,使得我们的代码更加地清晰。

策略模式

  • 策略模式太常用了,所以把它放到最前面进行介绍。它比较简单,我就不废话,直接用代码说事吧。
  • 下面设计的场景是,我们需要画一个图形,可选的策略就是用红色笔来画,还是绿色笔来画,或者蓝色笔来画。
  • 首先,先定义一个策略接口:
1
2
3
复制代码public interface Strategy {
public void draw(int radius, int x, int y);
}
  • 然后我们定义具体的几个策略:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码public class RedPen implements Strategy {
@Override
public void draw(int radius, int x, int y) {
System.out.println("用红色笔画图,radius:" + radius + ", x:" + x + ", y:" + y);
}
}
public class GreenPen implements Strategy {
@Override
public void draw(int radius, int x, int y) {
System.out.println("用绿色笔画图,radius:" + radius + ", x:" + x + ", y:" + y);
}
}
public class BluePen implements Strategy {
@Override
public void draw(int radius, int x, int y) {
System.out.println("用蓝色笔画图,radius:" + radius + ", x:" + x + ", y:" + y);
}
}
  • 使用策略的类:
1
2
3
4
5
6
7
8
9
10
11
复制代码public class Context {
private Strategy strategy;

public Context(Strategy strategy){
this.strategy = strategy;
}

public int executeDraw(int radius, int x, int y){
return strategy.draw(radius, x, y);
}
}
  • 客户端演示:
1
2
3
4
复制代码public static void main(String[] args) {
Context context = new Context(new BluePen()); // 使用绿色笔来画
context.executeDraw(10, 0, 0);
}
  • 放到一张图上,让大家看得清晰些:

  • 这个时候,大家有没有联想到结构型模式中的桥梁模式,它们其实非常相似,我把桥梁模式的图拿过来大家对比下:

  • 要我说的话,它们非常相似,桥梁模式在左侧加了一层抽象而已。桥梁模式的耦合更低,结构更复杂一些。

观察者模式

  • 观察者模式对于我们来说,真是再简单不过了。无外乎两个操作,观察者订阅自己关心的主题和主题有数据变化后通知观察者们。
  • 首先,需要定义主题,每个主题需要持有观察者列表的引用,用于在数据变更的时候通知各个观察者:
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
复制代码public class Subject {

private List<Observer> observers = new ArrayList<Observer>();
private int state;

public int getState() {
return state;
}

public void setState(int state) {
this.state = state;
// 数据已变更,通知观察者们
notifyAllObservers();
}

public void attach(Observer observer){
observers.add(observer);
}

// 通知观察者们
public void notifyAllObservers(){
for (Observer observer : observers) {
observer.update();
}
}
}
  • 定义观察者接口:
1
2
3
4
复制代码public abstract class Observer {
protected Subject subject;
public abstract void update();
}
  • 其实如果只有一个观察者类的话,接口都不用定义了,不过,通常场景下,既然用到了观察者模式,我们就是希望一个事件出来了,会有多个不同的类需要处理相应的信息。比如,订单修改成功事件,我们希望发短信的类得到通知、发邮件的类得到通知、处理物流信息的类得到通知等。
  • 我们来定义具体的几个观察者类:
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
复制代码public class BinaryObserver extends Observer {

// 在构造方法中进行订阅主题
public BinaryObserver(Subject subject) {
this.subject = subject;
// 通常在构造方法中将 this 发布出去的操作一定要小心
this.subject.attach(this);
}

// 该方法由主题类在数据变更的时候进行调用
@Override
public void update() {
String result = Integer.toBinaryString(subject.getState());
System.out.println("订阅的数据发生变化,新的数据处理为二进制值为:" + result);
}
}

public class HexaObserver extends Observer {

public HexaObserver(Subject subject) {
this.subject = subject;
this.subject.attach(this);
}

@Override
public void update() {
String result = Integer.toHexString(subject.getState()).toUpperCase();
System.out.println("订阅的数据发生变化,新的数据处理为十六进制值为:" + result);
}
}
  • 客户端使用也非常简单:
1
2
3
4
5
6
7
8
9
10
复制代码public static void main(String[] args) {
// 先定义一个主题
Subject subject1 = new Subject();
// 定义观察者
new BinaryObserver(subject1);
new HexaObserver(subject1);

// 模拟数据变更,这个时候,观察者们的 update 方法将会被调用
subject.setState(11);
}
  • output:
1
2
复制代码订阅的数据发生变化,新的数据处理为二进制值为:1011
订阅的数据发生变化,新的数据处理为十六进制值为:B
  • 当然,jdk 也提供了相似的支持,具体的大家可以参考 java.util.Observable 和 java.util.Observer 这两个类。
  • 实际生产过程中,观察者模式往往用消息中间件来实现,如果要实现单机观察者模式,笔者建议读者使用 Guava 中的 EventBus,它有同步实现也有异步实现,本文主要介绍设计模式,就不展开说了。

责任链模式

  • 责任链通常需要先建立一个单向链表,然后调用方只需要调用头部节点就可以了,后面会自动流转下去。比如流程审批就是一个很好的例子,只要终端用户提交申请,根据申请的内容信息,自动建立一条责任链,然后就可以开始流转了
  • 有这么一个场景,用户参加一个活动可以领取奖品,但是活动需要进行很多的规则校验然后才能放行,比如首先需要校验用户是否是新用户、今日参与人数是否有限额、全场参与人数是否有限额等等。设定的规则都通过后,才能让用户领走奖品。

如果产品给你这个需求的话,我想大部分人一开始肯定想的就是,用一个 List 来存放所有的规则,然后 foreach 执行一下每个规则就好了。不过,读者也先别急,看看责任链模式和我们说的这个有什么不一样?

  • 首先,我们要定义流程上节点的基类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码public abstract class RuleHandler {

// 后继节点
protected RuleHandler successor;

public abstract void apply(Context context);

public void setSuccessor(RuleHandler successor) {
this.successor = successor;
}
public RuleHandler getSuccessor() {
return successor;
}
}
  • 接下来,我们需要定义具体的每个节点了。
  • 校验用户是否是新用户:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码public class NewUserRuleHandler extends RuleHandler {

public void apply(Context context) {
if (context.isNewUser()) {
// 如果有后继节点的话,传递下去
if (this.getSuccessor() != null) {
this.getSuccessor().apply(context);
}
} else {
throw new RuntimeException("该活动仅限新用户参与");
}
}

}
  • 校验用户所在地区是否可以参与:
1
2
3
4
5
6
7
8
9
10
11
12
复制代码public class LocationRuleHandler extends RuleHandler {
public void apply(Context context) {
boolean allowed = activityService.isSupportedLocation(context.getLocation);
if (allowed) {
if (this.getSuccessor() != null) {
this.getSuccessor().apply(context);
}
} else {
throw new RuntimeException("非常抱歉,您所在的地区无法参与本次活动");
}
}
}
  • 校验奖品是否已领完:
1
2
3
4
5
6
7
8
9
10
11
12
复制代码public class LimitRuleHandler extends RuleHandler {
public void apply(Context context) {
int remainedTimes = activityService.queryRemainedTimes(context); // 查询剩余奖品
if (remainedTimes > 0) {
if (this.getSuccessor() != null) {
this.getSuccessor().apply(userInfo);
}
} else {
throw new RuntimeException("您来得太晚了,奖品被领完了");
}
}
}
  • 客户端:
1
2
3
4
5
6
7
8
9
复制代码public static void main(String[] args) {
RuleHandler newUserHandler = new NewUserRuleHandler();
RuleHandler locationHandler = new LocationRuleHandler();
RuleHandler limitHandler = new LimitRuleHandler();

// 假设本次活动仅校验地区和奖品数量,不校验新老用户
locationHandler.setSuccessor(limitHandler);
locationHandler.apply(context);
}
  • 代码其实很简单,就是先定义好一个链表,然后在通过任意一节点后,如果此节点有后继节点,那么传递下去。
  • 至于它和我们前面说的用一个 List 存放需要执行的规则的做法有什么异同,留给读者自己琢磨吧。

模板方法模式

  • 在含有继承结构的代码中,模板方法模式是非常常用的,这也是在开源代码中大量被使用的。
  • 通常会有一个抽象类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码public abstract class AbstractTemplate {
// 这就是模板方法
public void templateMethod(){
init();
apply(); // 这个是重点
end(); // 可以作为钩子方法
}
protected void init() {
System.out.println("init 抽象层已经实现,子类也可以选择覆写");
}
// 留给子类实现
protected abstract void apply();
protected void end() {
}
}
  • 模板方法中调用了 3 个方法,其中 apply() 是抽象方法,子类必须实现它,其实模板方法中有几个抽象方法完全是自由的,我们也可以将三个方法都设置为抽象方法,让子类来实现。也就是说,模板方法只负责定义第一步应该要做什么,第二步应该做什么,第三步应该做什么,至于怎么做,由子类来实现。
  • 我们写一个实现类:
1
2
3
4
5
6
7
8
复制代码public class ConcreteTemplate extends AbstractTemplate {
public void apply() {
System.out.println("子类实现抽象方法 apply");
}
public void end() {
System.out.println("我们可以把 method3 当做钩子方法来使用,需要的时候覆写就可以了");
}
}
  • 客户端调用演示:
1
2
3
4
5
复制代码public static void main(String[] args) {
AbstractTemplate t = new ConcreteTemplate();
// 调用模板方法
t.templateMethod();
}
  • 代码其实很简单,基本上看到就懂了,关键是要学会用到自己的代码中。

状态模式

  • 废话我就不说了,我们说一个简单的例子。商品库存中心有个最基本的需求是减库存和补库存,我们看看怎么用状态模式来写。
  • 核心在于,我们的关注点不再是 Context 是该进行哪种操作,而是关注在这个 Context 会有哪些操作。
  • 定义状态接口:
1
2
3
复制代码public interface State {
public void doAction(Context context);
}
  • 定义减库存的状态:
1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码public class DeductState implements State {

public void doAction(Context context) {
System.out.println("商品卖出,准备减库存");
context.setState(this);

//... 执行减库存的具体操作
}

public String toString(){
return "Deduct State";
}
}
  • 定义补库存状态:
1
2
3
4
5
6
7
8
9
10
11
复制代码public class RevertState implements State {
public void doAction(Context context) {
System.out.println("给此商品补库存");
context.setState(this);

//... 执行加库存的具体操作
}
public String toString() {
return "Revert State";
}
}
  • 前面用到了 context.setState(this),我们来看看怎么定义 Context 类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码public class Context {
private State state;
private String name;
public Context(String name) {
this.name = name;
}

public void setState(State state) {
this.state = state;
}
public void getState() {
return this.state;
}
}
  • 我们来看下客户端调用,大家就一清二楚了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码public static void main(String[] args) {
// 我们需要操作的是 iPhone X
Context context = new Context("iPhone X");

// 看看怎么进行补库存操作
State revertState = new RevertState();
revertState.doAction(context);

// 同样的,减库存操作也非常简单
State deductState = new DeductState();
deductState.doAction(context);

// 如果需要我们可以获取当前的状态
// context.getState().toString();
}
  • 读者可能会发现,在上面这个例子中,如果我们不关心当前 context 处于什么状态,那么 Context 就可以不用维护 state 属性了,那样代码会简单很多。
  • 不过,商品库存这个例子毕竟只是个例,我们还有很多实例是需要知道当前 context 处于什么状态的。

行为型模式总结

  • 行为型模式部分介绍了策略模式、观察者模式、责任链模式、模板方法模式和状态模式,其实,经典的行为型模式还包括备忘录模式、命令模式等,但是它们的使用场景比较有限,而且本文篇幅也挺大了,我就不进行介绍了。

总结

  • 学习设计模式的目的是为了让我们的代码更加的优雅、易维护、易扩展。这次整理这篇文章,让我重新审视了一下各个设计模式,对我自己而言收获还是挺大的。我想,文章的最大收益者一般都是作者本人,为了写一篇文章,需要巩固自己的知识,需要寻找各种资料,而且,自己写过的才最容易记住,也算是我给读者的建议吧。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…871872873…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%