打开orika的正确方式

缘起

架构分层

开发分布式的项目时,DO持久化对象和DTO传输对象的转换是不可避免的。集中式项目中,DO-DAO-SERVICE-WEB的分层再寻常不过,但分布式架构(或微服务架构)需要拆分模块时,不得不思考一个问题:WEB层能不能出现DAO或者DO对象?我给出的答案是否定的。

新的项目分层结构

这张图曾出现在我过去的文章中,其强调了一个分层的要素:服务层(应用层)和表现层应当解耦,后者不应当触碰到任何持久化对象,其所有的数据来源,均应当由前者提供。

DTO的位置

就系统的某一个模块,可以大致分成领域层model,接口定义层api,接口实现层/服务层service,表现层web。

  • service 依赖 model + api
  • web 依赖 api

在我们系统构建初期,DTO对象被想当然的丢到了model层,这导致web对model产生了依赖;而在后期,为了满足前面的架构分层,最终将DTO对象移动到了api层(没有单独做一层)

没有DTO时的痛点

激发出DTO这样一个新的分层其实还有两个原因。

其一,便是我们再也不能忍受在RPC调用时JPA/hibernate懒加载这一特性带来的坑点。如果试图在消费端获取服务端传来的一个懒加载持久化对象,那么很抱歉,下意识就会发现这行不通,懒加载技术本质是使用字节码技术完成对象的代理,然而代理对象无法天然地远程传输,这与你的协议(RPC or HTTP)无关。

其二,远程调用需要额外注意网络传输的开销,如果生产者方从数据库加载出了一个一对多的依赖,而消费者只需要一这个实体的某个属性,多的实体会使得性能产生下降,并没有很好的方式对其进行控制(忽略手动set)。可能有更多痛点,由此可见,共享持久层,缺少DTO层时,我们的系统灵活性和性能都受到了制约。

从DTO到Orika

各类博客不乏对DTO的讨论,对领域驱动的理解,但却鲜有文章介绍,如何完成DO对象到DTO对象的转换。我们期待有一款高性能的,易用的工具来帮助我们完成实体类的转换。便引出了今天的主角:Orika。

Orika是什么?

Orika是一个简单、快速的JavaBean拷贝框架,它能够递归地将数据从一个JavaBean复制到另一个JavaBean,这在多层应用开发中是非常有用的。

Orika的竞品

相信大多数朋友接触过apache的BeanUtils,直到认识了spring的BeanUtils,前者被后者完爆,后来又出现了Dozer,Orika等重量级的Bean拷贝工具,在性能和特性上都有了很大的提升。

先给结论,众多Bean拷贝工具中,今天介绍的Orika具有想当大的优势。口说无凭,可参考下面文章中的各个工具的对比:http://tech.dianwoda.com/2017/11/04/gao-xing-neng-te-xing-feng-fu-de-beanying-she-gong-ju-orika/?utm_source=tuicool&utm_medium=referral

简单整理后,如下所示:

  • BeanUtils

apache的BeanUtils和spring的BeanUtils中拷贝方法的原理都是先用jdk中 java.beans.Introspector类的getBeanInfo()方法获取对象的属性信息及属性get/set方法,接着使用反射(Methodinvoke(Object obj, Object... args))方法进行赋值。apache支持名称相同但类型不同的属性的转换,spring支持忽略某些属性不进行映射,他们都设置了缓存保存已解析过的BeanInfo信息。

  • BeanCopier

cglib的BeanCopier采用了不同的方法:它不是利用反射对属性进行赋值,而是直接使用ASM的MethodVisitor直接编写各属性的get/set方法(具体过程可见BeanCopier类的generateClass(ClassVisitor v)方法)生成class文件,然后进行执行。由于是直接生成字节码执行,所以BeanCopier的性能较采用反射的BeanUtils有较大提高,这一点可在后面的测试中看出。

  • Dozer

使用以上类库虽然可以不用手动编写get/set方法,但是他们都不能对不同名称的对象属性进行映射。在定制化的属性映射方面做得比较好的有Dozer,Dozer支持简单属性映射、复杂类型映射、双向映射、隐式映射以及递归映射。可使用xml或者注解进行映射的配置,支持自动类型转换,使用方便。但Dozer底层是使用reflect包下Field类的set(Object obj, Object value)方法进行属性赋值,执行速度上不是那么理想。

  • Orika

那么有没有特性丰富,速度又快的Bean映射工具呢,这就是下面要介绍的Orika,Orika是近期在github活跃的项目,底层采用了javassist类库生成Bean映射的字节码,之后直接加载执行生成的字节码文件,因此在速度上比使用反射进行赋值会快很多,下面详细介绍Orika的使用方法。

Orika入门

引入依赖

1
2
3
4
5
<dependency>
<groupId>ma.glasnost.orika</groupId>
<artifactId>orika-core</artifactId>
<version>${orika.version}</version>
</dependency>

基础概念

  • MapperFactory
1
MapperFactory mapperFactory = new DefaultMapperFactory.Builder().build();

MapperFactory用于注册字段映射,配置转换器,自定义映射器等,而我们关注的主要是字段映射这个特性,在下面的小节中会介绍。

  • MapperFacade
1
2
3
MapperFacade mapper = mapperFactory.getMapperFacade();
PersonSource source = new PersonSource();
PersonDest destination = mapper.map(source, PersonDest.class);

MapperFacade和spring,apache中的BeanUtils具有相同的地位,负责对象间的映射,也是实际使用中,我们使用的最多的类。

至于转换器,自定义映射器等等概念,属于Orika的高级特性,也是Orika为什么被称作一个重量级框架的原因,引入Orika的初衷是为了高性能,易用的拷贝对象,引入它们会给系统带来一定的侵入性,所以本文暂不介绍,详细的介绍,可参考官方文档:http://orika-mapper.github.io/orika-docs/intro.html

映射字段名完全相同的对象

如果DO对象和DTO对象的命名遵守一定的规范,那无疑会减少我们很大的工作量。那么,规范是怎么样的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Person {
private String name;
private int age;
private Date birthDate;
List<Address> addresses; // <1>
// getters/setters omitted
}
class PersonDto {
private String name;
private int age;
private Date birthDate;
List<AddressDto> addresses; // <1>
// getters/setters omitted
}
class Address {
private String name;
}
class AddressDto {
private String name;
}

基本字段类型自不用说,关键是打上<1>标签的地方,按照通常的习惯,List<AddressDto>变量名会被命名为addressDtos,但我更加推荐与DO对象统一命名,命名为addresses。这样Orika在映射时便可以自动映射两者。

1
2
3
4
MapperFactory mapperFactory = new DefaultMapperFactory.Builder().build();
Person person = new Person();
//一顿赋值
PersonDto personDto = mapperFactory.getMapperFacade().map(person, PersonDto.class);

这样便完成了两个对象之间的拷贝,你可能会思考:需要我们指定两个类的映射关系吗?集合可以自动映射吗?这一切Orika都帮助我们完成了,在默认行为下,只要类的字段名相同,Orika便会尽自己最大的努力帮助我们映射。

映射字段名不一致的对象

我对于DTO的理解是:DTO应当尽可能与DO的字段保持一致,不增不减不改,但可能出于一些特殊原因,需要映射两个名称不同的字段,Orika当然也支持这样常见的需求。只需要在MapperFactory中事先注册便可。

1
2
3
4
5
6
7
8
9
10
11
public class Person {
private String id;
private Name name;
private List<Name> knownAliases;
private Date birthDate;
}
public class Name {
private String first;
private String last;
}
1
2
3
4
5
6
7
public class PersonDto {
private String personId;
private String firstName;
private String lastName;
private Date birthDate;
private String[][] aliases;
}

完成上述两个结构不甚相似的对象时,则需要我们额外做一些工作,剩下的便和之前一致了:

1
2
3
4
5
6
7
8
9
MapperFactory mapperFactory = new DefaultMapperFactory.Builder().build();
factory.classMap(Person.class, PersonDto.class) // <2>
.field("id","personId")
.field("name.first", "firstName")
.field("name.last", "lastName")
.field("knownAliases{first}", "aliases{[0]}")
.field("knownAliases{last}", "aliases{[1]}")
.byDefault() //<1>
.register();

这些.{}[]这些略微有点复杂的表达式不需要被掌握,只是想表明:如果你有这样需求,Orika也能支持。上述连续点的行为被称为 fluent-style ,这再不少框架中有体现。

<1> 注意byDefault()这个方法,在指定了classMap行为之后,相同字段互相映射这样的默认行为需要调用一次这个方法,才能被继承。

<2> classMap()方法返回了一个ClassMapBuilder对象,如上所示,我们见识到了它的field(),byDefault(),register()方法,这个建造者指定了对象映射的众多行为,还包括几个其他有用的方法:

1
2
3
4
5
classMapBuilder.field("a","b");//Person和PersonDto的双向映射
classMapBuilder.fieldAToB("a","b");//单向映射
classMapBuilder.fieldBToA("a","b");//单向映射
classMapBuilder.exclude("a");//移除指定的字段映射,即使字段名相同也不会拷贝
classMapBuilder.field("a","b").mapNulls(false).mapNullsInReverse(false);//是否拷贝空属性,默认是true

更多的API可以参见源码

集合映射

在类中我们之前已经见识过了List

与List的映射。如果根对象就是一个集合,List 映射为 List也是很常见的需求,这也很方便:

1
2
3
MapperFactory mapperFactory = new DefaultMapperFactory.Builder().build();
List<Person> persons = new ArrayList<>();
List<PersonDto> personDtos = mapperFactory.getMapperFacade().mapAsList(persons, PersonDto.class);

递归映射

1
2
3
4
5
6
7
8
9
10
11
12
class A {
private B b;
}
class B {
private C c;
}
class C {
private D d;
}
class D {
private String name;
}

Orika默认支持递归映射。

泛型映射

对泛型的支持是Orika的另一强大功能,这点在文档中只是被提及,网上并没有找到任何一个例子,所以在此我想稍微着重的介绍一下。既然文档没有相关的介绍,那如何了解Orika是怎样支持泛型映射的呢?只能翻出Orika的源码,在其丰富的测试用例中,可以窥见其对各种泛型特性的支持:https://github.com/orika-mapper/orika/tree/master/tests/src/main/java/ma/glasnost/orika/test/generics

1
2
3
4
5
6
public class Response<T> {
private T data;
}
public class ResponseDto<T> {
private T data;
}

当出现泛型时,按照前面的思路去拷贝,看看结果会如何,泛型示例1

1
2
3
4
5
6
7
8
@Test
public void genericTest1(){
MapperFactory mapperFactory = new DefaultMapperFactory.Builder().build();
Response<String> response = new Response<>();
response.setData("test generic");
ResponseDto<String> responseDto = mapperFactory.getMapperFacade().map(response, ResponseDto.class);// *
Assert.assertFalse("test generic".equals(responseDto.getData()));
}

会发现responseDto并不会Copy成功吗,特别是在*处,你会发现无所适从,没办法把ResponseDto传递进去 ,同样的,还有下面的泛型示例2

1
2
3
4
5
6
7
8
9
10
@Test
public void genericTest2(){
MapperFactory mapperFactory = new DefaultMapperFactory.Builder().build();
Response<Person> response = new Response<>();
Person person = new Person();
person.setName("test generic");
response.setData(person);
Response<PersonDto> responseDto = mapperFactory.getMapperFacade().map(response, Response.class);
Assert.assertFalse(responseDto.getData() instanceof PersonDto);
}

Response中的String和PersonDto在运行时(Runtime)泛型擦除这一特性难住了不少人,那么,Orika如何解决泛型映射呢?

我们可以发现MapperFacade的具有一系列的重载方法,对各种类型的泛型拷贝进行支持

泛型支持

可以看到几乎每个方法都传入了一个Type,用于获取拷贝类的真实类型,而不是传入.class字节码,下面介绍正确的打开姿势:

1
2
3
4
5
6
7
8
9
10
@Test
public void genericTest1() {
MapperFactory mapperFactory = new DefaultMapperFactory.Builder().build();
Response<String> response = new Response<>();
response.setData("test generic");
Type<Response<String>> fromType = new TypeBuilder<Response<String>>() {}.build();
Type<ResponseDto<String>> toType = new TypeBuilder<ResponseDto<String>>() {}.build();
ResponseDto<String> responseDto = mapperFactory.getMapperFacade().map(response, fromType, toType);
Assert.assertTrue("test generic".equals(responseDto.getData()));
}
1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void genericTest2() {
MapperFactory mapperFactory = new DefaultMapperFactory.Builder().build();
Response<Person> response = new Response<>();
Person person = new Person();
person.setName("test generic");
response.setData(person);
Type<Response<Person>> fromType = new TypeBuilder<Response<Person>>() {}.build();
Type<Response<PersonDto>> toType = new TypeBuilder<Response<PersonDto>>() {}.build();
Response<PersonDto> responseDto = mapperFactory.getMapperFacade().map(response, fromType, toType);
Assert.assertEquals("test generic" , responseDto.getData().getName());
}

浅拷贝or深拷贝

虽然不值得一提,但职业敏感度还是催使我们想要测试一下,Orika是深拷贝还是浅拷贝,毕竟浅拷贝有时候会出现一些意想不到的坑点

1
2
3
4
5
6
7
8
9
@Test
public void deepCloneTest() throws Exception {
MapperFactory mapperFactory = new DefaultMapperFactory.Builder().build();
Person person = new Person();
Address address = new Address();
person.setAddress(address);
PersonDto personDto = mapperFactory.getMapperFacade().map(person, PersonDto.class);
Assert.assertFalse(personDto.getAddress().hashCode() == person.getAddress().hashCode());
}

结论:在使用Orika时可以放心,其实现的是深拷贝,不用担心原始类和克隆类指向同一个对象的问题。

更多的特性?

你如果关心Orika是否能完成你某项特殊的需求,在这里可能会对你有所帮助:http://orika-mapper.github.io/orika-docs/faq.html

怎么样,你是不是还在使用BeanUtils呢?尝试一下Orika吧!

分享到 评论

JAVA拾遗--关于SPI机制

JDK提供的SPI(Service Provider Interface)机制,可能很多人不太熟悉,因为这个机制是针对厂商或者插件的,也可以在一些框架的扩展中看到。其核心类java.util.ServiceLoader可以在jdk1.8的文档中看到详细的介绍。虽然不太常见,但并不代表它不常用,恰恰相反,你无时无刻不在用它。玄乎了,莫急,思考一下你的项目中是否有用到第三方日志包,是否有用到数据库驱动?其实这些都和SPI有关。再来思考一下,现代的框架是如何加载日志依赖,加载数据库驱动的,你可能会对class.forName(“com.mysql.jdbc.Driver”)这段代码不陌生,这是每个java初学者必定遇到过的,但如今的数据库驱动仍然是这样加载的吗?你还能找到这段代码吗?这一切的疑问,将在本篇文章结束后得到解答。

首先介绍SPI机制是个什么东西

实现一个自定义的SPI

1 项目结构

SPI项目结构

  1. invoker是我们的用来测试的主项目。
  2. interface是针对厂商和插件商定义的接口项目,只提供接口,不提供实现。
  3. good-printer,bad-printer分别是两个厂商对interface的不同实现,所以他们会依赖于interface项目。

这个简单的demo就是让大家体验,在不改变invoker代码,只更改依赖的前提下,切换interface的实现厂商。

2 interface模块

2.1 moe.cnkirito.spi.api.Printer

1
2
3
public interface Printer {
void print();
}

interface只定义一个接口,不提供实现。规范的制定方一般都是比较牛叉的存在,这些接口通常位于java,javax前缀的包中。这里的Printer就是模拟一个规范接口。

3 good-printer模块

3.1 good-printer\pom.xml

1
2
3
4
5
6
7
<dependencies>
<dependency>
<groupId>moe.cnkirito</groupId>
<artifactId>interface</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>

规范的具体实现类必然要依赖规范接口

3.2 moe.cnkirito.spi.api.GoodPrinter

1
2
3
4
5
public class GoodPrinter implements Printer {
public void print() {
System.out.println("你是个好人~");
}
}

作为Printer规范接口的实现一

3.3 resources\META-INF\services\moe.cnkirito.spi.api.Printer

1
moe.cnkirito.spi.api.GoodPrinter

这里需要重点说明,每一个SPI接口都需要在自己项目的静态资源目录中声明一个services文件,文件名为实现规范接口的类名全路径,在此例中便是moe.cnkirito.spi.api.Printer,在文件中,则写上一行具体实现类的全路径,在此例中便是moe.cnkirito.spi.api.GoodPrinter

这样一个厂商的实现便完成了。

4 bad-printer模块

我们在按照和good-printer模块中定义的一样的方式,完成另一个厂商对Printer规范的实现。

4.1 bad-printer\pom.xml

1
2
3
4
5
6
7
<dependencies>
<dependency>
<groupId>moe.cnkirito</groupId>
<artifactId>interface</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>

4.2 moe.cnkirito.spi.api.BadPrinter

1
2
3
4
5
6
public class BadPrinter implements Printer {
public void print() {
System.out.println("我抽烟,喝酒,蹦迪,但我知道我是好女孩~");
}
}

4.3 resources\META-INF\services\moe.cnkirito.spi.api.Printer

1
moe.cnkirito.spi.api.BadPrinter

这样,另一个厂商的实现便完成了。

5 invoker模块

这里的invoker便是我们自己的项目了。如果一开始我们想使用厂商good-printer的Printer实现,是需要将其的依赖引入。

1
2
3
4
5
6
7
8
9
10
11
12
<dependencies>
<dependency>
<groupId>moe.cnkirito</groupId>
<artifactId>interface</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>moe.cnkirito</groupId>
<artifactId>good-printer</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>

5.1 编写调用主类

1
2
3
4
5
6
7
8
9
10
public class MainApp {
public static void main(String[] args) {
ServiceLoader<Printer> printerLoader = ServiceLoader.load(Printer.class);
for (Printer printer : printerLoader) {
printer.print();
}
}
}

ServiceLoader是java.util提供的用于加载固定类路径下文件的一个加载器,正是它加载了对应接口声明的实现类。

5.2 打印结果1

1
你是个好人~

如果在后续的方案中,想替换厂商的Printer实现,只需要将依赖更换

1
2
3
4
5
6
7
8
9
10
11
12
<dependencies>
<dependency>
<groupId>moe.cnkirito</groupId>
<artifactId>interface</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>moe.cnkirito</groupId>
<artifactId>bad-printer</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>

调用主类无需变更代码,这符合开闭原则

5.3 打印结果2

1
我抽烟,喝酒,蹦迪,但我知道我是好女孩~

是不是很神奇呢?这一切对于调用者来说都是透明的,只需要切换依赖即可!

SPI在实际项目中的应用

先总结下有什么新知识,resources/META-INF/services下的文件似乎我们之前没怎么接触过,ServiceLoader也没怎么接触过。那么现在我们打开自己项目的依赖,看看有什么发现。

  1. 在mysql-connector-java-xxx.jar中发现了META-INF\services\java.sql.Driver文件,里面只有两行记录:

    1
    2
    com.mysql.jdbc.Driver
    com.mysql.fabric.jdbc.FabricMySQLDriver

    我们可以分析出,java.sql.Driver是一个规范接口,com.mysql.jdbc.Driver
    com.mysql.fabric.jdbc.FabricMySQLDriver则是mysql-connector-java-xxx.jar对这个规范的实现接口。

  2. 在jcl-over-slf4j-xxxx.jar中发现了META-INF\services\org.apache.commons.logging.LogFactory文件,里面只有一行记录:

    1
    org.apache.commons.logging.impl.SLF4JLogFactory

    相信不用我赘述,大家都能理解这是什么含义了

  3. 更多的还有很多,有兴趣可以自己翻一翻项目路径下的那些jar包

既然说到了数据库驱动,索性再多说一点,还记得一道经典的面试题:class.forName(“com.mysql.jdbc.Driver”)到底做了什么事?

先思考下:自己会怎么回答?

都知道class.forName与类加载机制有关,会触发执行com.mysql.jdbc.Driver类中的静态方法,从而使主类加载数据库驱动。如果再追问,为什么它的静态块没有自动触发?可答:因为数据库驱动类的特殊性质,JDBC规范中明确要求Driver类必须向DriverManager注册自己,导致其必须由class.forName手动触发,这可以在java.sql.Driver中得到解释。完美了吗?还没,来到最新的DriverManager源码中,可以看到这样的注释,翻译如下:

DriverManager 类的方法 getConnectiongetDrivers 已经得到提高以支持 Java Standard Edition Service Provider 机制。 JDBC 4.0 Drivers 必须包括 META-INF/services/java.sql.Driver 文件。此文件包含 java.sql.Driver 的 JDBC 驱动程序实现的名称。例如,要加载 my.sql.Driver 类,META-INF/services/java.sql.Driver 文件需要包含下面的条目:

my.sql.Driver

应用程序不再需要使用 Class.forName() 显式地加载 JDBC 驱动程序。当前使用 Class.forName() 加载 JDBC 驱动程序的现有程序将在不作修改的情况下继续工作。

可以发现,Class.forName已经被弃用了,所以,这道题目的最佳回答,应当是和面试官牵扯到JAVA中的SPI机制,进而聊聊加载驱动的演变历史。

java.sql.DriverManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public Void run() {
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
try{
while(driversIterator.hasNext()) {
driversIterator.next();
}
} catch(Throwable t) {
// Do nothing
}
return null;
}

当然那,本节的内容还是主要介绍SPI,驱动这一块这是引申而出,如果不太理解,可以多去翻一翻jdk1.8中Driver和DriverManager的源码,相信会有不小的收获。

SPI在扩展方面的应用

SPI不仅仅是为厂商指定的标准,同样也为框架扩展提供了一个思路。框架可以预留出SPI接口,这样可以在不侵入代码的前提下,通过增删依赖来扩展框架。前提是,框架得预留出核心接口,也就是本例中interface模块中类似的接口,剩下的适配工作便留给了开发者。

例如我的上一篇文章 https://www.cnkirito.moe/2017/11/07/spring-cloud-sleuth/ 中介绍的motan中Filter的扩展,便是采用了SPI机制,熟悉这个设定之后再回头去了解一些框架的SPI扩展就不会太陌生了。

分享到 评论

使用Spring Cloud Sleuth实现链路监控

在服务比较少的年代,一个系统的接口响应缓慢通常能够迅速被发现,但如今的微服务模块,大多具有规模大,依赖关系复杂等特性,错综复杂的网状结构使得我们不容易定位到某一个执行缓慢的接口。分布式的服务跟踪组件就是为了解决这一个问题。其次,它解决了另一个难题,在没有它之前,我们客户会一直询问:你们的系统有监控吗?你们的系统有监控吗?你们的系统有监控吗?现在,谢天谢地,他们终于不问了。是有点玩笑的成分,但可以肯定的一点是,实现全链路监控是保证系统健壮性的关键因子。

介绍Spring Cloud Sleuth和Zipkin的文章在网上其实并不少,所以我打算就我目前的系统来探讨一下,如何实现链路监控。全链路监控这个词意味着只要是不同系统模块之间的调用都应当被监控,这就包括了如下几种常用的交互方式:

1 Http协议,如RestTemplate,Feign,Okhttp3,HttpClient…

2 Rpc远程调用,如Motan,Dubbo,GRPC…

3 分布式Event,如RabbitMq,Kafka…

而我们项目目前混合使用了Http协议,Motan Rpc协议,所以本篇文章会着墨于实现这两块的链路监控。

项目结构

项目结构

上面的项目结构是本次demo的核心结构,其中

  1. zipkin-server作为服务跟踪的服务端,记录各个模块发送而来的调用请求,最终形成调用链路的报告。
  2. order,goods两个模块为用来做测试的业务模块,分别实现了http形式和rpc形式的远程调用,最终我们会在zipkin-server的ui页面验证他们的调用记录。
  3. interface存放了order和goods模块的公用接口,rpc调用需要一个公用的接口。
  4. filter-opentracing存放了自定义的motan扩展代码,用于实现motan rpc调用的链路监控。

Zipkin服务端

添加依赖

全部依赖

核心依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
<dependency>
<groupId>io.zipkin.java</groupId>
<artifactId>zipkin-server</artifactId>
</dependency>
<dependency>
<groupId>io.zipkin.java</groupId>
<artifactId>zipkin-autoconfigure-ui</artifactId>
</dependency>
<dependency>
<groupId>io.zipkin.java</groupId>
<artifactId>zipkin-storage-mysql</artifactId>
<version>1.28.0</version>
</dependency>

zipkin-autoconfigure-ui提供了默认了UI页面,zipkin-storage-mysql选择将链路调用信息存储在mysql中,更多的选择可以有elasticsearchcassandra

zipkin-server/src/main/resources/application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
spring:
application:
name: zipkin-server
datasource:
url: jdbc:mysql://localhost:3306/zipkin
username: root
password: root
driver-class-name: com.mysql.jdbc.Driver
zipkin:
storage:
type: mysql
server:
port: 9411

创建启动类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@SpringBootApplication
@EnableZipkinServer
public class ZipkinServerApp {
@Bean
public MySQLStorage mySQLStorage(DataSource datasource) {
return MySQLStorage.builder().datasource(datasource).executor(Runnable::run).build();
}
public static void main(String[] args) {
SpringApplication.run(ZipkinServerApp.class, args);
}
}

当前版本在手动配置数据库之后才不会启动报错,可能与版本有关。mysql相关的脚本可以在此处下载:mysql初始化脚本

zipkin-server单独启动后,就可以看到链路监控页面了,此时由于没有收集到任何链路调用记录,显示如下:

zipkin服务端页面

HTTP链路监控

编写order和goods两个服务,在order暴露一个http端口,在goods中使用RestTemplate远程调用,之后查看在zipkin服务端查看调用信息。

首先添加依赖,让普通的应用具备收集和发送报告的能力,这一切在spring cloud sleuth的帮助下都变得很简单

添加依赖

全部依赖

核心依赖

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zipkin</artifactId>
</dependency>

spring-cloud-starter-zipkin依赖内部包含了两个依赖,等于同时引入了spring-cloud-starter-sleuthspring-cloud-sleuth-zipkin两个依赖。名字特别像,注意区分。

以order为例介绍配置文件

order/src/main/resources/application.yml

1
2
3
4
5
6
7
8
9
10
11
spring:
application:
name: order # 1
zipkin:
base-url: http://localhost:9411 # 2
sleuth:
enabled: true
sampler:
percentage: 1 # 3
server:
port: 8060

<1> 指定项目名称可以方便的标记应用,在之后的监控页面可以看到这里的配置名称

<2> 指定zipkin的服务端,用于发送链路调用报告

<3> 采样率,值为[0,1]之间的任意实数,顾名思义,这里代表100%采集报告。

编写调用类

服务端order

1
2
3
4
5
6
7
8
9
10
11
12
@RestController
@RequestMapping("/api")
public class OrderController {
Logger logger = LoggerFactory.getLogger(OrderController.class);
@RequestMapping("/order/{id}")
public MainOrder getOrder(@PathVariable("id") String id) {
logger.info("order invoking ..."); //<1>
return new MainOrder(id, new BigDecimal(200D), new Date());
}
}

客户端goods

1
2
3
4
5
public MainOrder test(){
ResponseEntity<MainOrder> mainOrderResponseEntity = restTemplate.getForEntity("http://localhost:8060/api/order/1144", MainOrder.class);
MainOrder body = mainOrderResponseEntity.getBody();
return body;
}

<1> 首先观察这一行日志在控制台是如何输出的

1
2017-11-08 09:54:00.633 INFO [order,d251f40af64361d2,e46132755dc395e1,true] 2780 --- [nio-8060-exec-1] m.c.sleuth.order.web.OrderController : order invoking ...

比没有引入sleuth之前多了一些信息,其中order,d251f40af64361d2,e46132755dc395e1,true分别代表了应用名称,traceId,spanId,当前调用是否被采集,关于trace,span这些专业词语,强烈建议去看看Dapper这篇论文,有很多中文翻译版本,并不是想象中的学术范,非常容易理解,很多链路监控文章中的截图都来自于这篇论文,我在此就不再赘述概念了。

紧接着,回到zipkin-server的监控页面,查看变化

应用名称

调用详细记录

依赖关系

到这里,Http监控就已经完成了,如果你的应用使用了其他的Http工具,如okhttp3,也可以去[opentracing,zipkin相关的文档中寻找依赖。

RPC链路监控

虽说spring cloud是大势所趋,其推崇的http调用方式也是链路监控的主要对象,但不得不承认目前大多数的系统内部调用仍然是RPC的方式,至少我们内部的系统是如此,由于我们内部采用的RPC框架是weibo开源的motan,这里以此为例,介绍RPC的链路监控。motan使用SPI机制,实现了对链路监控的支持,https://github.com/weibocom/motan/issues/304这条issue中可以得知其加入了opentracing标准化追踪。但目前只能通过自己添加组件的方式才能配合spring-cloud-sleuth使用,下面来看看实现步骤。

filter-opentracing

实现思路:引入SleuthTracingFilter,作为全局的motan过滤器,给每一次motan的调用打上traceId和spanId,并编写一个SleuthTracingContext,持有一个SleuthTracerFactory工厂,用于适配不同的Tracer实现。

具体的实现可以参考文末的地址

order/src/main/resources/META-INF/services/com.weibo.api.motan.filter.Filter

1
com.weibo.api.motan.filter.sleuth.SleuthTracingFilter

添加一行过滤器的声明,使得项目能够识别

配置SleuthTracingContext

1
2
3
4
5
6
7
8
9
10
11
12
@Bean
SleuthTracingContext sleuthTracingContext(@Autowired(required = false) org.springframework.cloud.sleuth.Tracer tracer){
SleuthTracingContext context = new SleuthTracingContext();
context.setTracerFactory(new SleuthTracerFactory() {
@Override
public org.springframework.cloud.sleuth.Tracer getTracer() {
return tracer;
}
});
return context;
}

使用spring-cloud-sleuth的Tracer作为motan调用的收集器

为服务端和客户端配置过滤器

1
2
3
basicServiceConfigBean.setFilter("sleuth-tracing");
basicRefererConfigBean.setFilter("sleuth-tracing");

编写调用测试类

order作为客户端

1
2
3
4
5
6
7
8
@MotanReferer
GoodsApi goodsApi;
@RequestMapping("/goods")
public String getGoodsList() {
logger.info("getGoodsList invoking ...");
return goodsApi.getGoodsList();
}

goods作为服务端

1
2
3
4
5
6
7
8
9
10
11
@MotanService
public class GoodsApiImpl implements GoodsApi {
Logger logger = LoggerFactory.getLogger(GoodsApiImpl.class);
@Override
public String getGoodsList() {
logger.info("GoodsApi invoking ...");
return "success";
}
}

查看调用关系

motan调用详细信息

依赖关系

第一张图中,使用前缀http和motan来区别调用的类型,第二张图中,依赖变成了双向的,因为一开始的http调用goods依赖于order,而新增了motan rpc调用之后order又依赖于goods。

总结

系统间交互的方式除了http,rpc,还有另外的方式如mq,以后还可能会有更多的方式,但实现的监控的思路都是一致的,即如何无侵入式地给调用打上标签,记录报告。Dapper给实现链路监控提供了一个思路,而OpenTracing为各个框架不同的调用方式提供了适配接口….Spring Cloud Sleuth则是遵循了Spring一贯的风格,整合了丰富的资源,为我们的系统集成链路监控提供了很大的便捷性。

关于motan具体实现链路监控的代码由于篇幅限制,将源码放在了我的github中,如果你的系统使用了motan,可以用于参考:https://github.com/lexburner/sleuth-starter

参考

《Spring Cloud微服务实战》– 翟永超

黄桂钱老师的指导

分享到 评论

Spring Data Redis(二)--序列化

默认序列化方案

在上一篇文章《Spring Data Redis(一)》中,我们执行了这样一个操作:

1
redisTemplate.opsForValue().set("student:1","kirito");

试图使用RedisTemplate在Redis中存储一个键为“student:1”,值为“kirito”的String类型变量(redis中通常使用‘:’作为键的分隔符)。那么是否真的如我们所预想的那样,在Redis中存在这样的键值对呢?

这可以说是Redis中最基础的操作了,但严谨起见,还是验证一下为妙,使用RedisDesktopManager可视化工具,或者redis-cli都可以查看redis中的数据。

查看redis

emmmmm,大概能看出是我们的键值对,但前面似乎多了一些奇怪的16进制字符,在不了解RedisTemplate工作原理的情况下,自然会对这个现象产生疑惑。

首先看看springboot如何帮我们自动完成RedisTemplate的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
protected static class RedisConfiguration {
@Bean
@ConditionalOnMissingBean(name = "redisTemplate")
public RedisTemplate<Object, Object> redisTemplate(
RedisConnectionFactory redisConnectionFactory)
throws UnknownHostException {
RedisTemplate<Object, Object> template = new RedisTemplate<Object, Object>();
template.setConnectionFactory(redisConnectionFactory);
return template;
}
}

没看出什么特殊的设置,于是我们进入RedisTemplate自身的源码中一窥究竟。

首先是在类开头声明了一系列的序列化器:

1
2
3
4
5
6
7
8
9
private boolean enableDefaultSerializer = true;// 配置默认序列化器
private RedisSerializer<?> defaultSerializer;
private ClassLoader classLoader;
private RedisSerializer keySerializer = null;
private RedisSerializer valueSerializer = null;
private RedisSerializer hashKeySerializer = null;
private RedisSerializer hashValueSerializer = null;
private RedisSerializer<String> stringSerializer = new StringRedisSerializer();

看到了我们关心的keySerializervalueSerializer,在RedisTemplate.afterPropertiesSet()方法中,可以看到,默认的序列化方案:

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
public void afterPropertiesSet() {
super.afterPropertiesSet();
boolean defaultUsed = false;
if (defaultSerializer == null) {
defaultSerializer = new JdkSerializationRedisSerializer(
classLoader != null ? classLoader : this.getClass().getClassLoader());
}
if (enableDefaultSerializer) {
if (keySerializer == null) {
keySerializer = defaultSerializer;
defaultUsed = true;
}
if (valueSerializer == null) {
valueSerializer = defaultSerializer;
defaultUsed = true;
}
if (hashKeySerializer == null) {
hashKeySerializer = defaultSerializer;
defaultUsed = true;
}
if (hashValueSerializer == null) {
hashValueSerializer = defaultSerializer;
defaultUsed = true;
}
}
...
initialized = true;
}

默认的方案是使用了JdkSerializationRedisSerializer,所以导致了前面的结果,注意:字符串和使用jdk序列化之后的字符串是两个概念。

我们可以查看set方法的源码:

1
2
3
4
5
6
7
8
9
10
public void set(K key, V value) {
final byte[] rawValue = rawValue(value);
execute(new ValueDeserializingRedisCallback(key) {
protected byte[] inRedis(byte[] rawKey, RedisConnection connection) {
connection.set(rawKey, rawValue);
return null;
}
}, true);
}

最终与Redis交互使用的是原生的connection,键值则全部是字节数组,意味着所有的序列化都依赖于应用层完成,Redis只认字节!这也是引出本节介绍的初衷,序列化是与Redis打交道很关键的一个环节。

StringRedisSerializer

在我不长的使用Redis的时间里,其实大多数操作是字符串操作,键值均为字符串,String.getBytes()即可满足需求。spring-data-redis也考虑到了这一点,其一,提供了StringRedisSerializer的实现,其二,提供了StringRedisTemplate,继承自RedisTemplate。

1
2
3
4
5
6
7
8
9
10
public class StringRedisTemplate extends RedisTemplate<String, String>{
public StringRedisTemplate() {
RedisSerializer<String> stringSerializer = new StringRedisSerializer();
setKeySerializer(stringSerializer);
setValueSerializer(stringSerializer);
setHashKeySerializer(stringSerializer);
setHashValueSerializer(stringSerializer);
}
...
}

即只能存取字符串。尝试执行如下的代码:

1
2
3
4
@Autowired
StringRedisTemplate stringRedisTemplate;
stringRedisTemplate.opsForValue().set("student:2", "SkYe");

再同样观察RedisDesktopManager中的变化:

查看redis

由于更换了序列化器,我们得到的结果也不同了。

项目中序列化器使用的注意点

理论上,字符串(本质是字节)其实是万能格式,是否可以使用StringRedisTemplate将复杂的对象存入Redis中,答案当然是肯定的。可以在应用层手动将对象序列化成字符串,如使用fastjson,jackson等工具,反序列化时也是通过字符串还原出原来的对象。而如果是用redisTemplate.opsForValue().set("student:3",new Student(3,"kirito"));便是依赖于内部的序列化器帮我们完成这样的一个流程,和使用stringRedisTemplate.opsForValue().set("student:3",JSON.toJSONString(new Student(3,"kirito")));

其实是一个等价的操作。但有两点得时刻记住两点:

  1. Redis只认字节。
  2. 使用什么样的序列化器序列化,就必须使用同样的序列化器反序列化。

曾经在review代码时发现,项目组的两位同事操作redis,一个使用了RedisTemplate,一个使用了StringRedisTemplate,当他们操作同一个键时,key虽然相同,但由于序列化器不同,导致无法获取成功。差异虽小,但影响是非常可怕的。

另外一点是,微服务不同模块连接了同一个Redis,在共享内存中交互数据,可能会由于版本升级,模块差异,导致相互的序列化方案不一致,也会引起问题。如果项目中途切换了序列化方案,也可能会引起Redis中老旧持久化数据的反序列化异常,同样需要引起注意。最优的方案自然是在项目初期就统一好序列化方案,所有模块引用同一份依赖,避免不必要的麻烦(或者干脆全部使用默认配置)。

序列化接口RedisSerializer

无论是RedisTemplate中默认使用的JdkSerializationRedisSerializer,还是StringRedisTemplate中使用的StringRedisSerializer都是实现自统一的接口RedisSerializer

1
2
3
4
public interface RedisSerializer<T> {
byte[] serialize(T t) throws SerializationException;
T deserialize(byte[] bytes) throws SerializationException;
}

在spring-data-redis中提供了其他的默认实现,用于替换默认的序列化方案。

  • GenericToStringSerializer 依赖于内部的ConversionService,将所有的类型转存为字符串
  • GenericJackson2JsonRedisSerializer和Jackson2JsonRedisSerializer 以JSON的形式序列化对象
  • OxmSerializer 以XML的形式序列化对象

我们可能出于什么样的目的修改序列化器呢?按照个人理解可以总结为以下几点:

  1. 各个工程间约定了数据格式,如使用JSON等通用数据格式,可以让异构的系统接入Redis同样也能识别数据,而JdkSerializationRedisSerializer则不具备这样灵活的特性
  2. 数据的可视化,在项目初期我曾经偏爱JSON序列化,在运维时可以清晰地查看各个value的值,非常方便。
  3. 效率问题,如果需要将大的对象存入Value中,或者Redis IO非常频繁,替换合适的序列化器便可以达到优化的效果。

替换默认的序列化器

可以将全局的RedisTemplate覆盖,也可以在使用时在局部实例化一个RedisTemplate替换(不依赖于IOC容器)需要根据实际的情况选择替换的方式,以Jackson2JsonRedisSerializer为例介绍全局替换的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate redisTemplate = new RedisTemplate();
redisTemplate.setConnectionFactory(redisConnectionFactory);
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper = new ObjectMapper();// <1>
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
redisTemplate.setKeySerializer(new StringRedisSerializer()); // <2>
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer); // <2>
redisTemplate.afterPropertiesSet();
return redisTemplate;
}

<1> 修改Jackson序列化时的默认行为

<2> 手动指定RedisTemplate的Key和Value的序列化器

然后使用RedisTemplate进行保存:

1
2
3
4
5
6
7
8
9
10
@Autowired
StringRedisTemplate stringRedisTemplate;
public void test() {
Student student3 = new Student();
student3.setName("kirito");
student3.setId("3");
student3.setHobbies(Arrays.asList("coding","write blog","eat chicken"));
redisTemplate.opsForValue().set("student:3",student3);
}

紧接着,去RedisDesktopManager中查看结果:

查看Redis

标准的JSON格式

实现Kryo序列化

我们也可以考虑根据自己项目和需求的特点,扩展序列化器,这是非常方便的。比如前面提到的,为了追求性能,可能考虑使用Kryo序列化器替换缓慢的JDK序列化器,如下是一个参考实现(为了demo而写,未经过生产验证)

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
public class KryoRedisSerializer<T> implements RedisSerializer<T> {
private final static Logger logger = LoggerFactory.getLogger(KryoRedisSerializer.class);
private static final ThreadLocal<Kryo> kryos = new ThreadLocal<Kryo>() {
protected Kryo initialValue() {
Kryo kryo = new Kryo();
return kryo;
};
};
@Override
public byte[] serialize(Object obj) throws SerializationException {
if (obj == null) {
throw new RuntimeException("serialize param must not be null");
}
Kryo kryo = kryos.get();
Output output = new Output(64, -1);
try {
kryo.writeClassAndObject(output, obj);
return output.toBytes();
} finally {
closeOutputStream(output);
}
}
@Override
public T deserialize(byte[] bytes) throws SerializationException {
if (bytes == null) {
return null;
}
Kryo kryo = kryos.get();
Input input = null;
try {
input = new Input(bytes);
return (T) kryo.readClassAndObject(input);
} finally {
closeInputStream(input);
}
}
private static void closeOutputStream(OutputStream output) {
if (output != null) {
try {
output.flush();
output.close();
} catch (Exception e) {
logger.error("serialize object close outputStream exception", e);
}
}
}
private static void closeInputStream(InputStream input) {
if (input != null) {
try {
input.close();
} catch (Exception e) {
logger.error("serialize object close inputStream exception", e);
}
}
}
}

由于Kyro是线程不安全的,所以使用了一个ThreadLocal来维护,也可以挑选其他高性能的序列化方案如Hessian,Protobuf…

分享到 评论

Spring Data Redis(一)--解析RedisTemplate

谈及系统优化,缓存一直是不可或缺的一点。在缓存中间件层面,我们有MemCache,Redis等选择;在系统分层层面,又需要考虑多级缓存;在系统可用性层面,又要考虑到缓存雪崩,缓存穿透,缓存失效等常见的缓存问题…缓存的使用与优化值得我们花费一定的精力去深入理解。《Spring Data Redis》这个系列打算围绕spring-data-redis来进行分析,从hello world到源码分析,夹杂一些不多实战经验(经验有限),不止限于spring-data-redis本身,也会扩展谈及缓存这个大的知识点。

至于为何选择redis,相信不用我赘述,redis如今非常流行,几乎成了项目必备的组件之一。而spring-boot-starter-data-redis模块又为我们在spring集成的项目中提供了开箱即用的功能,更加便捷了我们开发。系列的第一篇便是简单介绍下整个组件最常用的一个工具类:RedisTemplate。

1 引入依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.5.7.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

springboot的老用户会发现redis依赖名称发生了一点小的变化,在springboot1.4之前,redis依赖的名称为:spring-boot-starter-redis,而在之后较新的版本中,使用spring-boot-starter-redis依赖,则会在项目启动时得到一个过期警告。意味着,我们应该彻底放弃旧的依赖。spring-data这个项目定位为spring提供一个统一的数据仓库接口,如(spring-boot-starter-data-jpa,spring-boot-starter-data-mongo,spring-boot-starter-data-rest),将redis纳入后,改名为了spring-boot-starter-data-redis。

2 配置redis连接

resources/application.yml

1
2
3
4
5
6
spring:
redis:
host: 127.0.0.1
database: 0
port: 6379
password:

本机启动一个单点的redis即可,使用redis的0号库作为默认库(默认有16个库),在生产项目中一般会配置redis集群和哨兵保证redis的高可用,同样可以在application.yml中修改,非常方便。

3 编写测试类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import org.assertj.core.api.Assertions;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.junit4.SpringRunner;
@RunWith(SpringRunner.class)
@SpringBootTest
public class ApplicationTests {
@Autowired
private RedisTemplate redisTemplate;// <1>
@Test
public void test() throws Exception {
redisTemplate.opsForValue().set("student:1", "kirito"); // <2>
Assertions.assertThat(redisTemplate.opsForValue().get("student:1")).isEqualTo("kirito");
}
}

<1> 引入了RedisTemplate,这个类是spring-starter-data-redis提供给应用直接访问redis的入口。从其命名就可以看出,其是模板模式在spring中的体现,与restTemplate,jdbcTemplate类似,而springboot为我们做了自动的配置,具体会在下文详解。

<2> redisTemplate通常不直接操作键值,而是通过opsForXxx()访问,在本例中,key和value均为字符串类型。绑定字符串在实际开发中也是最为常用的操作类型。

4 详解RedisTemplate的API

RedisTemplate为我们操作Redis提供了丰富的API,可以将他们简单进行下归类。

4.1 常用数据操作

这一类API也是我们最常用的一类。

众所周知,redis存在5种数据类型:

字符串类型(string),散列类型(hash),列表类型(list),集合类型(set),有序集合类型(zset)

而redisTemplate实现了RedisOperations接口,在其中,定义了一系列与redis相关的基础数据操作接口,数据类型分别于下来API对应:

1
2
3
4
5
6
7
8
9
10
11
12
//非绑定key操作
ValueOperations<K, V> opsForValue();
<HK, HV> HashOperations<K, HK, HV> opsForHash();
ListOperations<K, V> opsForList();
SetOperations<K, V> opsForSet();
ZSetOperations<K, V> opsForZSet();
//绑定key操作
BoundValueOperations<K, V> boundValueOps(K key);
<HK, HV> BoundHashOperations<K, HK, HV> boundHashOps(K key);
BoundListOperations<K, V> boundListOps(K key);
BoundSetOperations<K, V> boundSetOps(K key);
BoundZSetOperations<K, V> boundZSetOps(K key);

若以bound开头,则意味着在操作之初就会绑定一个key,后续的所有操作便默认认为是对该key的操作,算是一个小优化。

4.2 对原生Redis指令的支持

Redis原生指令中便提供了一些很有用的操作,如设置key的过期时间,判断key是否存在等等…

常用的API列举:

RedisTemplate API 原生Redis指令 说明
public void delete(K key) DEL key [key …] 删除给定的一个或多个 key
public Boolean hasKey(K key) EXISTS key 检查给定 key 是否存在
public Boolean expire/expireAt(…) EXPIRE key seconds 为给定 key 设置生存时间,当 key 过期时(生存时间为 0 ),它会被自动删除。
public Long getExpire(K key) TTL key 以秒为单位,返回给定 key 的剩余生存时间(TTL, time to live)。

更多的原生Redis指令支持可以参考javadoc

4.3 CAS操作

CAS(Compare and Swap)通常有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。CAS也通常与并发,乐观锁,非阻塞,机器指令等关键词放到一起讲解。可能会有很多朋友在秒杀场景的架构设计中见到了Redis,本质上便是利用了Redis分布式共享内存的特性以及一系列的CAS指令。还记得在4.1中通过redisTemplate.opsForValue()或者redisTemplate.boundValueOps()可以得到一个ValueOperations或BoundValueOperations接口(以值为字符串的操作接口为例),这些接口除了提供了基础操作外,还提供了一系列CAS操作,也可以放到RedisTemplate中一起理解。

常用的API列举:

ValueOperations API 原生Redis指令 说明
Boolean setIfAbsent(K key, V value) SETNX key value key 的值设为 value ,当且仅当 key 不存在。设置成功,返回 1 , 设置失败,返回 0
V getAndSet(K key, V value) GETSET key value 将给定 key 的值设为 value ,并返回 key 的旧值(old value)。
Long increment(K key, long delta)/Double increment(K key, double delta) INCR/INCRBY/INCRBYFLOAT key 所储存的值加上增量 increment 。 如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行INCR/INCRBY/INCRBYFLOAT命令。线程安全的+

关于CAS的理解可以参考我之前的文章java并发实践–CAS或者其他博文。

4.4 发布订阅

redis之所以被冠以银弹,万金油的称号,关键在于其实现的功能真是太多了,甚至实现了一部分中间件队列的功能,其内置的channel机制,可以用于实现分布式的队列和广播。

RedisTemplate提供了convertAndSend()功能,用于发送消息,与RedisMessageListenerContainer 配合接收,便实现了一个简易的发布订阅。如果想要使用Redis实现发布订阅,可以参考我之前的文章。浅析分布式下的事件驱动机制

4.5 Lua脚本

RedisTemplate中包含了这样一个Lua执行器,意味着我们可以使用RedisTemplate执行Lua脚本。

1
private ScriptExecutor<K> scriptExecutor;

Lua这门语言也非常有意思,小巧而精悍,有兴趣的朋友可以去了解一下nginx+lua开发,使用openResty框架。而Redis内置了Lua的解析器,由于Redis单线程的特性(不严谨),可以使用Lua脚本,完成一些线程安全的符合操作(CAS操作仅仅只能保证单个操作的线程安全,无法保证复合操作,如果你有这样的需求,可以考虑使用Redis+Lua脚本)。

1
2
3
public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) {
return scriptExecutor.execute(script, keys, args);
}

上述操作便可以完成对Lua脚本的调用。这儿有一个简单的示例,使用Redis+Lua脚本实现分布式的应用限流。分布式限流

5 总结

Spring Data Redis系列的第一篇,介绍了spring-data对redis操作的封装,顺带了解redis具备的一系列特性,如果你对redis的理解还仅仅停留在它是一个分布式的key-value数据库,那么相信现在你一定会感叹其竟然如此强大。后续将会对缓存在项目中的应用以及spring-boot-starter-data-redis进一步解析。

分享到 评论

java小技巧(一)--远程debug

该系列介绍一些java开发中常用的一些小技巧,多小呢,从不会到会只需要一篇文章这么小。这一篇介绍如何使用jdk自带的扩展包配合Intellij IDEA实现远程debug。

项目中经常会有出现这样的问题,会令程序员抓狂:关键代码段没有打印日志,本地环境正常生产环境却又问题…这时候,远程debug可能会启动作用。

1 准备用于debug的代码

准备一个RestController用于接收请求,最后可以通过本地断点验证是否成功开启了远程debug

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
public class TestController {
@RequestMapping("/test")
public Integer test() {
int i = 0;
i++;
i++;
i++;
i++;
i++;
return i;
}
}

项目使用springboot和maven构建,依赖就省略了,使用springboot提供的maven打包插件,方便我们打包成可运行的jar。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<executable>true</executable>
</configuration>
</plugin>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
</plugins>
</build>

2 使用maven插件打包成jar

maven插件

3 准备启动脚本

1
java -jar -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=64057 remote-debug-1.0-SNAPSHOT.jar
  1. 使用java -jar的方式启动程序,并且添加了一串特殊的参数,这是我们能够开启远程debug的关键,以-开头的参数是jvm的标准启动参数,关于jvm启动参数相关的知识可以先去其他博客了解。
  2. -agentlib:libname[=options], 用于装载本地lib包。在这条指令中便是加载了jdwp(Java Debug Wire Protocol)这个用于远程调试java的扩展包。而transport=dt_socket,server=y,suspend=n,address=64057这些便是jdwp装载时的定制参数,详细的参数作用可以搜索jdwp进行了解。我们需要关心的只有address=64057这个参数选项,本地调试程序使用64057端口与其通信,从而远程调试。

4 配置IDEA

IDEA配置

  1. 与脚本中的指令完全一致
  2. 远程jar包运行的host,由于我的jar运行在本地,所以使用的是localhost,一般线上环境自然是修改为线上的地址
  3. 与远程jar包进行交互的端口号,idea会根据指令自动帮我们输入
  4. 选择与远程jar包一致的本地代码

请务必保证远程jar包的代码与本地代码一致!!!

5 验证

保存第4步的配置后,先执行脚本让远程的jar包跑起来,再在IDEA中运行remote-debug

运行remote-jar

如上便代表连接运行成功了

在本地打上断点,访问localhost:8080/test

远程debug信息展示

可以在本地看到堆栈信息,大功告成。一行指令便完成了远程调试。

分享到 评论

浅析项目中的并发(二)

分布式遭遇并发

在前面的章节,并发操作要么发生在单个应用内,一般使用基于JVM的lock解决并发问题,要么发生在数据库,可以考虑使用数据库层面的锁,而在分布式场景下,需要保证多个应用实例都能够执行同步代码,则需要做一些额外的工作,一个最典型分布式同步方案便是使用分布式锁。

分布式锁由很多种实现,但本质上都是类似的,即依赖于共享组件实现锁的询问和获取,如果说单体式应用中的Monitor是由JVM提供的,那么分布式下Monitor便是由共享组件提供,而典型的共享组件大家其实并不陌生,包括但不限于:Mysql,Redis,Zookeeper。同时他们也代表了三种类型的共享组件:数据库,缓存,分布式协调组件。基于Consul的分布式锁,其实和基于Zookeeper的分布式锁大同小异,都是借助于分布式协调组件实现锁,大而化之,这三种类型的分布式锁,原理也都差不多,只不过,锁的特性和实现细节有所差异。

Redis实现分布式锁

定义需求:A应用需要完成添加库存的操作,部署了A1,A2,A3多个实例,实例之间的操作要保证同步。

分析需求:显然,此时依赖于JVM的lock已经没办法解决问题了,A1添加锁,无法保证A2,A3的同步,这种场景可以考虑使用分布式锁应对。

建立一张Stock表,包含id,number两个字段,分别让A1,A2,A3并发对其操作,保证线程安全。

1
2
3
4
5
6
@Entity
public class Stock {
@Id
private String id;
private Integer number;
}

定义数据库访问层:

1
2
public interface StockRepository extends JpaRepository<Stock,String> {
}

这一节的主角,redis分布式锁,使用开源的redis分布式锁实现:Redisson。

引入Redisson依赖:

1
2
3
4
5
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.5.4</version>
</dependency>

定义测试类:

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
@RestController
public class StockController {
@Autowired
StockRepository stockRepository;
ExecutorService executorService = Executors.newFixedThreadPool(10);
@Autowired
RedissonClient redissonClient;
final static String id = "1";
@RequestMapping("/addStock")
public void addStock() {
RLock lock = redissonClient.getLock("redisson:lock:stock:" + id);
for (int i = 0; i < 100; i++) {
executorService.execute(() -> {
lock.lock();
try {
Stock stock = stockRepository.findOne(id);
stock.setNumber(stock.getNumber() + 1);
stockRepository.save(stock);
} finally {
lock.unlock();
}
});
}
}
}

上述的代码使得并发发生在多个层面。其一,在应用内部,启用线程池完成库存的加1操作,本身便是线程不安全的,其二,在多个应用之间,这样的加1操作更加是不受约束的。若初始化id为1的Stock数量为0。分别在本地启用A1(8080),A2(8081),A3(8082)三个应用,同时并发执行一次addStock(),若线程安全,必然可以使得数据库中的Stock为300,这便是我们的检测依据。

简单解读下上述的代码,使用redisson获取一把RLock,RLock是java.util.concurrent.locks.Lock接口的实现类,Redisson帮助我们屏蔽Redis分布式锁的实现细节,使用过java.util.concurrent.locks.Lock的朋友都会知道下述的代码可以被称得上是同步的起手范式,毕竟这是Lock的java doc中给出的代码:

1
2
3
4
5
6
7
Lock l = ...;
l.lock();
try {
// access the resource protected by this lock
} finally {
l.unlock();
}

redissonClient.getLock("redisson:lock:stock:" + id)则是以"redisson:lock:stock:" + id该字符串作痛同步的Monitor,保证了不同id之间是互相不阻塞的。

为了保证发生并发,实际测试中我加入了Thread.sleep(1000),使竞争得以发生。测试结果:

测试结果

Redis分布式锁的确起了作用。

锁的注意点

如果仅仅是实现一个能够用于demo的Redis分布式锁并不难,但为何大家更偏向于使用开源的实现呢?主要还是可用性和稳定性,we make things work是我在写博客,写代码时牢记在脑海中的,如果真的要细究如何自己实现一个分布式锁,或者平时使用锁保证并发,需要有哪些注意点呢?列举几点:阻塞,超时时间,可重入,可用性,其他特性。

阻塞

意味着各个操作之间的等待,A1正在执行增加库存时,A1其他的线程被阻塞,A2,A3中所有的线程被阻塞,在Redis中可以使用轮询策略以及redis底层提供的CAS原语(如setnx)来实现。(初学者可以理解为:在redis中设置一个key,想要执行lock代码时先询问是否有该key,如果有则代表其他线程在执行过程中,若没有,则设置该key,并且执行代码,执行完毕,释放key,而setnx保证操作的原子性)

超时时间

在特殊情况,可能会导致锁无法被释放,如死锁,死循环等等意料之外的情况,锁超时时间的设置是有必要的,一个很直观的想法是给key设置过期时间即可。

如在Redisson中,lock提供了一个重载方法lock(long t, TimeUnit timeUnit);可以自定义过期时间。

可重入

这个特性很容易被忽视,可重入其实并不难理解,顾名思义,一个方法在调用过程中是否可以被再次调用。实现可重入需要满足三个特性:

  1. 可以在执行的过程中可以被打断;
  2. 被打断之后,在该函数一次调用执行完之前,可以再次被调用(或进入,reentered)。
  3. 再次调用执行完之后,被打断的上次调用可以继续恢复执行,并正确执行。

比如下述的代码引用了全局变量,便是不可重入的:

1
2
3
4
5
6
7
8
int t;
void swap(int x, int y) {
t = x;
x = y;
y = t;
System.out.println("x is" + x + " y is " + y);
}

一个更加直观的例子便是,同一个线程中,某个方法的递归调用不应该被阻塞,所以如果要实现这个特性,简单的使用某个key作为Monitor是欠妥的,可以加入线程编号,来保证可重入。

使用可重入分布式锁的来测试计算斐波那契数列(只是为了验证可重入性):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@RequestMapping("testReentrant")
public void ReentrantLock() {
RLock lock = redissonClient.getLock("fibonacci");
lock.lock();
try {
int result = fibonacci(10);
System.out.println(result);
} finally {
lock.unlock();
}
}
int fibonacci(int n) {
RLock lock = redissonClient.getLock("fibonacci");
try {
if (n <= 1) return n;
else
return fibonacci(n - 1) + fibonacci(n - 2);
} finally {
lock.unlock();
}
}

最终输出:55,可以发现,只要是在同一线程之内,无论是递归调用还是外部加锁(同一把锁),都不会造成死锁。

可用性

借助于第三方中间件实现的分布式锁,都有这个问题,中间件挂了,会导致锁不可用,所以需要保证锁的高可用,这就需要保证中间件的可用性,如redis可以使用哨兵+集群,保证了中间件的可用性,便保证了锁的可用性、

其他特性

除了可重入锁,锁的分类还有很多,在分布式下也同样可以实现,包括但不限于:公平锁,联锁,信号量,读写锁。Redisson也都提供了相关的实现类,其他的特性如并发容器等可以参考官方文档。

新手遭遇并发

基本算是把项目中遇到的并发过了一遍了,案例其实很多,再简单罗列下一些新手可能会遇到的问题。

使用了线程安全的容器就是线程安全了吗?很多新手误以为使用了并发容器如:concurrentHashMap就万事大吉了,却不知道,一知半解的隐患可能比全然不懂更大。来看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ConcurrentHashMapTest {
static Map<String, Integer> counter = new ConcurrentHashMap();
public static void main(String[] args) throws InterruptedException {
counter.put("stock1", 0);
ExecutorService executorService = Executors.newFixedThreadPool(10);
CountDownLatch countDownLatch = new CountDownLatch(100);
for (int i = 0; i < 100; i++) {
executorService.execute(new Runnable() {
@Override
public void run() {
counter.put("stock1", counter.get("stock1") + 1);
countDownLatch.countDown();
}
});
}
countDownLatch.await();
System.out.println("result is " + counter.get("stock1"));
}
}

counter.put("stock1", counter.get("stock1") + 1)并不是原子操作,并发容器保证的是单步操作的线程安全特性,这一点往往初级程序员特别容易忽视。

总结

项目中的并发场景是非常多的,而根据场景不同,同一个场景下的业务需求不同,以及数据量,访问量的不同,都会影响到锁的使用,架构中经常被提到的一句话是:业务决定架构,放到并发中也同样适用:业务决定控制并发的手段,如本文未涉及的队列的使用,本质上是化并发为串行,也解决了并发问题,都是控制的手段。了解锁的使用很简单,但如果使用,在什么场景下使用什么样的锁,这才是价值所在。

同一个线程之间的递归调用不应该被阻塞,所以如果要实现这个特性,简单的使用某个key作为Monitor是欠妥的,可以加入线程编号,来保证可重入。

分享到 评论

浅析项目中的并发(一)

前言

控制并发的方法很多,从最基础的synchronized,juc中的lock,到数据库的行级锁,乐观锁,悲观锁,再到中间件级别的redis,zookeeper分布式锁。特别是初级程序员,对于所谓的锁一直都是听的比用的多,第一篇文章不深入探讨并发,更多的是一个入门介绍,适合于初学者,主题是“根据并发出现的具体业务场景,使用合理的控制并发手段”。

什么是并发

由一个大家都了解的例子引入我们今天的主题:并发

类共享变量遭遇并发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Demo {
public Integer count = 0;
public static void main(String[] args) {
final Demo demo = new Demo();
Executor executor = Executors.newFixedThreadPool(10);
for(int i=0;i<1000;i++){
executor.execute(new Runnable() {
@Override
public void run() {
demo.count++;
}
});
}
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("final count value:"+demo1.count);
}
}

final count value:973

本例中创建了一个初始化时具有10个线程的线程池,多线程对类变量count进行自增操作。这个过程中,自增操作并不是线程安全的,happens-before原则并不会保障多个线程执行的先后顺序,导致了最终结果并不是想要的1000

下面,我们把并发中的共享资源从类变量转移到数据库中。

充血模型遭遇并发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class Demo2 {
@Autowired
TestNumDao testNumDao;
@Transactional
public void test(){
TestNum testNum = testNumDao.findOne("1");
testNum.setCount(testNum.getCount()+1);
testNumDao.save(testNum);
}
}

依旧使用多线程,对数据库中的记录进行+1操作

Demo2 demo2;

public String test(){
    Executor executor = Executors.newFixedThreadPool(10);
    for(int i=0;i<1000;i++){
        executor.execute(new Runnable() {
            @Override
            public void run() {
                demo2.test();
            }
        });
    }
    return "test";
}

数据库的记录

1
2
id | count
1 | 344

初窥门径的程序员会认为事务最基本的ACID中便包含了原子性,但是事务的原子性和今天所讲的并发中的原子操作仅仅是名词上有点类似。而有点经验的程序员都能知道这中间发生了什么,这只是暴露了项目中并发问题的冰山一角,千万不要认为上面的代码没有必要列举出来,我在实际项目开发中,曾经见到有多年工作经验的程序员仍然写出了类似于上述会出现并发问题的代码。

贫血模型遭遇并发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RequestMapping("testSql")
@ResponseBody
public String testSql() throws InterruptedException {
final CountDownLatch countDownLatch = new CountDownLatch(1000);
long start = System.currentTimeMillis();
Executor executor = Executors.newFixedThreadPool(10);
for(int i=0;i<1000;i++){
executor.execute(new Runnable() {
@Override
public void run() {
jdbcTemplate.execute("update test_num set count = count + 1 where id = '1'");
countDownLatch.countDown();
}
});
}
countDownLatch.await();
long costTime =System.currentTimeMillis() - start;
System.out.println("共花费:"+costTime+" s");
return "testSql";
}

数据库结果: count : 1000 达到了预期效果
这个例子我顺便记录了耗时,控制台打印:共花费:113 ms
简单对比一下二,三两个例子,都是想对数据库的count进行+1操作,唯一的区别就是,后者的+1计算发生在数据库,而前者的计算依赖于事先查出来的值,并且计算发生在程序的内存中。而现在大部分的ORM框架,导致了写充血模型的程序员变多,不注意并发的话,就会出现问题。下面我们来看看具体的业务场景。

业务场景

  1. 修改个人信息
  2. 修改商品信息
  3. 扣除账户余额,扣减库存

业务场景分析

第一个场景,互联网如此众多的用户修改个人信息,这算不算并发?答案是:算也不算。
算,从程序员角度来看,每一个用户请求进来,都是调用的同一个修改入口,具体一点,就是映射到controller层的同一个requestMapping,所以一定是并发的。
不算,虽然程序是并发的,但是从用户角度来分析,每个人只可以修改自己的信息,所以,不同用户的操作其实是隔离的,所以不算“并发”。这也是为什么很多开发者,在日常开发中一直不注意并发控制,却也没有发生太大问题的原因,大多数初级程序员开发的还都是CRM,OA,CMS系统。

回到我们的并发,第一种业务场景,是可以使用如上模式的,对于一条用户数据的修改,我们允许程序员读取数据到内存中,内存计算修改(耗时操作),提交更改,提交事务。

1
2
3
4
5
6
7
//Transaction start
User user = userDao.findById("1");
user.setName("newName");
user.setAge(user.getAge()+1);
...//其他耗时操作
userDao.save(user);
//Transaction commit

这个场景变现为:几乎不存在并发,不需要控制,场景乐观。

为了严谨,也可以选择控制并发,但我觉得这需要交给写这段代码的同事,让他自由发挥。

第二个场景已经有所不同了,同样是修改一个记录,但是系统中可能有多个操作员来维护,此时,商品数据表现为一个共享数据,所以存在微弱的并发,通常表现为数据的脏读,例如操作员A,B同时对一个商品信息维护,我们希望只能有一个操作员修改成功,另外一个操作员得到错误提示(该商品信息已经发生变化),否则,两个人都以为自己修改成功了,但是其实只有一个人完成了操作,另一个人的操作被覆盖了。

这个场景表现为:存在并发,需要控制,允许失败,场景乐观。

通常我建议这种场景使用乐观锁,即在商品属性添加一个version字段标记修改的版本,这样两个操作员拿到同一个版本号,第一个操作员修改成功后版本号变化,另一个操作员的修改就会失败了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Goods{
@Version
int version;
}
//Transaction start
try{
Goods goods = goodsDao.findById("1");
goods.setName("newName");
goods.setPrice(goods.getPrice()+100.00);
...//其他耗时操作
goodsDao.save(goods);
}catch(org.hibernate.StaleObjectStateException e){
//返回给前台
}
//Transaction commit

springdata配合jpa可以自动捕获version异常,也可以自动手动对比。

第三个场景
这个场景表现为:存在频繁的并发,需要控制,不允许失败,场景悲观。

强调一下,本例不应该使用在项目中,只是为了举例而设置的一个场景,因为这种贫血模型无法满足复杂的业务场景,而且依靠单机事务来保证一致性,并发性能和可扩展性能不好。

一个简易的秒杀场景,大量请求在短时间涌入,是不可能像第二种场景一样,100个并发请求,一个成功,其他99个全部异常的。

设计方案应该达到的效果是:有足够库存时,允许并发,库存到0时,之后的请求全部失败;有足够金额时,允许并发,金额不够支付时立刻告知余额不足。

可以利用数据库的行级锁,
update set balance = balance - money where userId = ? and balance >= money;
update stock = stock - number where goodsId = ? and stock >= number ; 然后在后台 查看返回值是否影响行数为1,判断请求是否成功,利用数据库保证并发。

需要补充一点,我这里所讲的秒杀,并不是指双11那种级别的秒杀,那需要多层架构去控制并发,前端拦截,负载均衡….不能仅仅依赖于数据库的,会导致严重的性能问题。为了留一下一个直观的感受,这里对比一下oracle,mysql的两个主流存储引擎:innodb,myisam的性能问题。

1
2
3
4
5
6
oracle:
10000个线程共计1000000次并发请求:共花费:101017 ms =>101s
innodb:
10000个线程共计1000000次并发请求:共花费:550330 ms =>550s
myisam:
10000个线程共计1000000次并发请求:共花费:75802 ms =>75s

可见,如果真正有大量请求到达数据库,光是依靠数据库解决并发是不现实的,所以仅仅只用数据库来做保障而不是完全依赖。需要根据业务场景选择合适的控制并发手段。

分享到 评论

Spring Security(五)--动手实现一个IP_Login

在开始这篇文章之前,我们似乎应该思考下为什么需要搞清楚Spring Security的内部工作原理?按照第二篇文章中的配置,一个简单的表单认证不就达成了吗?更有甚者,为什么我们不自己写一个表单认证,用过滤器即可完成,大费周章引入Spring Security,看起来也并没有方便多少。对的,在引入Spring Security之前,我们得首先想到,是什么需求让我们引入了Spring Security,以及为什么是Spring Security,而不是shiro等等其他安全框架。我的理解是有如下几点:

1 在前文的介绍中,Spring Security支持防止csrf攻击,session-fixation protection,支持表单认证,basic认证,rememberMe…等等一些特性,有很多是开箱即用的功能,而大多特性都可以通过配置灵活的变更,这是它的强大之处。

2 Spring Security的兄弟的项目Spring Security SSO,OAuth2等支持了多种协议,而这些都是基于Spring Security的,方便了项目的扩展。

3 SpringBoot的支持,更加保证了Spring Security的开箱即用。

4 为什么需要理解其内部工作原理?一个有自我追求的程序员都不会满足于浅尝辄止,如果一个开源技术在我们的日常工作中十分常用,那么我偏向于阅读其源码,这样可以让我们即使排查不期而至的问题,也方便日后需求扩展。

5 Spring及其子项目的官方文档是我见过的最良心的文档!相比较于Apache的部分文档

这一节,为了对之前分析的Spring Security源码和组件有一个清晰的认识,介绍一个使用IP完成登录的简单demo。

查看更多

分享到 评论

Spring Security(四)--核心过滤器源码分析

[TOC]

前面的部分,我们关注了Spring Security是如何完成认证工作的,但是另外一部分核心的内容:过滤器,一直没有提到,我们已经知道Spring Security使用了springSecurityFillterChian作为了安全过滤的入口,这一节主要分析一下这个过滤器链都包含了哪些关键的过滤器,并且各自的使命是什么。

4 过滤器详解

4.1 核心过滤器概述

由于过滤器链路中的过滤较多,即使是Spring Security的官方文档中也并未对所有的过滤器进行介绍,在之前,《Spring Security(二)–Guides》入门指南中我们配置了一个表单登录的demo,以此为例,来看看这过程中Spring Security都帮我们自动配置了哪些过滤器。

1
2
3
4
5
6
7
8
9
10
11
12
Creating filter chain: o.s.s.web.util.matcher.AnyRequestMatcher@1,
[o.s.s.web.context.SecurityContextPersistenceFilter@8851ce1,
o.s.s.web.header.HeaderWriterFilter@6a472566, o.s.s.web.csrf.CsrfFilter@61cd1c71,
o.s.s.web.authentication.logout.LogoutFilter@5e1d03d7,
o.s.s.web.authentication.UsernamePasswordAuthenticationFilter@122d6c22,
o.s.s.web.savedrequest.RequestCacheAwareFilter@5ef6fd7f,
o.s.s.web.servletapi.SecurityContextHolderAwareRequestFilter@4beaf6bd,
o.s.s.web.authentication.AnonymousAuthenticationFilter@6edcad64,
o.s.s.web.session.SessionManagementFilter@5e65afb6,
o.s.s.web.access.ExceptionTranslationFilter@5b9396d3,
o.s.s.web.access.intercept.FilterSecurityInterceptor@3c5dbdf8
]

上述的log信息是我从springboot启动的日志中CV所得,spring security的过滤器日志有一个特点:log打印顺序与实际配置顺序符合,也就意味着SecurityContextPersistenceFilter是整个过滤器链的第一个过滤器,而FilterSecurityInterceptor则是末置的过滤器。另外通过观察过滤器的名称,和所在的包名,可以大致地分析出他们各自的作用,如UsernamePasswordAuthenticationFilter明显便是与使用用户名和密码登录相关的过滤器,而FilterSecurityInterceptor我们似乎看不出它的作用,但是其位于web.access包下,大致可以分析出他与访问限制相关。第四篇文章主要就是介绍这些常用的过滤器,对其中关键的过滤器进行一些源码分析。先大致介绍下每个过滤器的作用:

  • SecurityContextPersistenceFilter 两个主要职责:请求来临时,创建SecurityContext安全上下文信息,请求结束时清空SecurityContextHolder
  • HeaderWriterFilter (文档中并未介绍,非核心过滤器) 用来给http响应添加一些Header,比如X-Frame-Options, X-XSS-Protection*,X-Content-Type-Options.
  • CsrfFilter 在spring4这个版本中被默认开启的一个过滤器,用于防止csrf攻击,了解前后端分离的人一定不会对这个攻击方式感到陌生,前后端使用json交互需要注意的一个问题。
  • LogoutFilter 顾名思义,处理注销的过滤器
  • UsernamePasswordAuthenticationFilter 这个会重点分析,表单提交了username和password,被封装成token进行一系列的认证,便是主要通过这个过滤器完成的,在表单认证的方法中,这是最最关键的过滤器。
  • RequestCacheAwareFilter (文档中并未介绍,非核心过滤器) 内部维护了一个RequestCache,用于缓存request请求
  • SecurityContextHolderAwareRequestFilter 此过滤器对ServletRequest进行了一次包装,使得request具有更加丰富的API
  • AnonymousAuthenticationFilter 匿名身份过滤器,这个过滤器个人认为很重要,需要将它与UsernamePasswordAuthenticationFilter 放在一起比较理解,spring security为了兼容未登录的访问,也走了一套认证流程,只不过是一个匿名的身份。
  • SessionManagementFilter 和session相关的过滤器,内部维护了一个SessionAuthenticationStrategy,两者组合使用,常用来防止session-fixation protection attack,以及限制同一用户开启多个会话的数量
  • ExceptionTranslationFilter 直译成异常翻译过滤器,还是比较形象的,这个过滤器本身不处理异常,而是将认证过程中出现的异常交给内部维护的一些类去处理,具体是那些类下面详细介绍
  • FilterSecurityInterceptor 这个过滤器决定了访问特定路径应该具备的权限,访问的用户的角色,权限是什么?访问的路径需要什么样的角色和权限?这些判断和处理都是由该类进行的。

其中加粗的过滤器可以被认为是Spring Security的核心过滤器,将在下面,一个过滤器对应一个小节来讲解。

4.2 SecurityContextPersistenceFilter

试想一下,如果我们不使用Spring Security,如果保存用户信息呢,大多数情况下会考虑使用Session对吧?在Spring Security中也是如此,用户在登录过一次之后,后续的访问便是通过sessionId来识别,从而认为用户已经被认证。具体在何处存放用户信息,便是第一篇文章中提到的SecurityContextHolder;认证相关的信息是如何被存放到其中的,便是通过SecurityContextPersistenceFilter。在4.1概述中也提到了,SecurityContextPersistenceFilter的两个主要作用便是请求来临时,创建SecurityContext安全上下文信息和请求结束时清空SecurityContextHolder。顺带提一下:微服务的一个设计理念需要实现服务通信的无状态,而http协议中的无状态意味着不允许存在session,这可以通过setAllowSessionCreation(false) 实现,这并不意味着SecurityContextPersistenceFilter变得无用,因为它还需要负责清除用户信息。在Spring Security中,虽然安全上下文信息被存储于Session中,但我们在实际使用中不应该直接操作Session,而应当使用SecurityContextHolder。

源码分析

org.springframework.security.web.context.SecurityContextPersistenceFilter

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
public class SecurityContextPersistenceFilter extends GenericFilterBean {
static final String FILTER_APPLIED = "__spring_security_scpf_applied";
//安全上下文存储的仓库
private SecurityContextRepository repo;
public SecurityContextPersistenceFilter() {
//HttpSessionSecurityContextRepository是SecurityContextRepository接口的一个实现类
//使用HttpSession来存储SecurityContext
this(new HttpSessionSecurityContextRepository());
}
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (request.getAttribute(FILTER_APPLIED) != null) {
// ensure that filter is only applied once per request
chain.doFilter(request, response);
return;
}
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
//包装request,response
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,
response);
//从Session中获取安全上下文信息
SecurityContext contextBeforeChainExecution = repo.loadContext(holder);
try {
//请求开始时,设置安全上下文信息,这样就避免了用户直接从Session中获取安全上下文信息
SecurityContextHolder.setContext(contextBeforeChainExecution);
chain.doFilter(holder.getRequest(), holder.getResponse());
}
finally {
//请求结束后,清空安全上下文信息
SecurityContext contextAfterChainExecution = SecurityContextHolder
.getContext();
SecurityContextHolder.clearContext();
repo.saveContext(contextAfterChainExecution, holder.getRequest(),
holder.getResponse());
request.removeAttribute(FILTER_APPLIED);
if (debug) {
logger.debug("SecurityContextHolder now cleared, as request processing completed");
}
}
}
}

过滤器一般负责核心的处理流程,而具体的业务实现,通常交给其中聚合的其他实体类,这在Filter的设计中很常见,同时也符合职责分离模式。例如存储安全上下文和读取安全上下文的工作完全委托给了HttpSessionSecurityContextRepository去处理,而这个类中也有几个方法可以稍微解读下,方便我们理解内部的工作流程

org.springframework.security.web.context.HttpSessionSecurityContextRepository

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
public class HttpSessionSecurityContextRepository implements SecurityContextRepository {
// 'SPRING_SECURITY_CONTEXT'是安全上下文默认存储在Session中的键值
public static final String SPRING_SECURITY_CONTEXT_KEY = "SPRING_SECURITY_CONTEXT";
...
private final Object contextObject = SecurityContextHolder.createEmptyContext();
private boolean allowSessionCreation = true;
private boolean disableUrlRewriting = false;
private String springSecurityContextKey = SPRING_SECURITY_CONTEXT_KEY;
private AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl();
//从当前request中取出安全上下文,如果session为空,则会返回一个新的安全上下文
public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
HttpServletRequest request = requestResponseHolder.getRequest();
HttpServletResponse response = requestResponseHolder.getResponse();
HttpSession httpSession = request.getSession(false);
SecurityContext context = readSecurityContextFromSession(httpSession);
if (context == null) {
context = generateNewContext();
}
...
return context;
}
...
public boolean containsContext(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return false;
}
return session.getAttribute(springSecurityContextKey) != null;
}
private SecurityContext readSecurityContextFromSession(HttpSession httpSession) {
if (httpSession == null) {
return null;
}
...
// Session存在的情况下,尝试获取其中的SecurityContext
Object contextFromSession = httpSession.getAttribute(springSecurityContextKey);
if (contextFromSession == null) {
return null;
}
...
return (SecurityContext) contextFromSession;
}
//初次请求时创建一个新的SecurityContext实例
protected SecurityContext generateNewContext() {
return SecurityContextHolder.createEmptyContext();
}
}

SecurityContextPersistenceFilter和HttpSessionSecurityContextRepository配合使用,构成了Spring Security整个调用链路的入口,为什么将它放在最开始的地方也是显而易见的,后续的过滤器中大概率会依赖Session信息和安全上下文信息。

4.3 UsernamePasswordAuthenticationFilter

表单认证是最常用的一个认证方式,一个最直观的业务场景便是允许用户在表单中输入用户名和密码进行登录,而这背后的UsernamePasswordAuthenticationFilter,在整个Spring Security的认证体系中则扮演着至关重要的角色。

http://ov0zuistv.bkt.clouddn.com/2011121410543010.jpg

上述的时序图,可以看出UsernamePasswordAuthenticationFilter主要肩负起了调用身份认证器,校验身份的作用,至于认证的细节,在前面几章花了很大篇幅进行了介绍,到这里,其实Spring Security的基本流程就已经走通了。

源码分析

org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter#attemptAuthentication

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
//获取表单中的用户名和密码
String username = obtainUsername(request);
String password = obtainPassword(request);
...
username = username.trim();
//组装成username+password形式的token
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
//交给内部的AuthenticationManager去认证,并返回认证信息
return this.getAuthenticationManager().authenticate(authRequest);
}

UsernamePasswordAuthenticationFilter本身的代码只包含了上述这么一个方法,非常简略,而在其父类AbstractAuthenticationProcessingFilter中包含了大量的细节,值得我们分析:

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 abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
implements ApplicationEventPublisherAware, MessageSourceAware {
//包含了一个身份认证器
private AuthenticationManager authenticationManager;
//用于实现remeberMe
private RememberMeServices rememberMeServices = new NullRememberMeServices();
private RequestMatcher requiresAuthenticationRequestMatcher;
//这两个Handler很关键,分别代表了认证成功和失败相应的处理器
private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
...
Authentication authResult;
try {
//此处实际上就是调用UsernamePasswordAuthenticationFilter的attemptAuthentication方法
authResult = attemptAuthentication(request, response);
if (authResult == null) {
//子类未完成认证,立刻返回
return;
}
sessionStrategy.onAuthentication(authResult, request, response);
}
//在认证过程中可以直接抛出异常,在过滤器中,就像此处一样,进行捕获
catch (InternalAuthenticationServiceException failed) {
//内部服务异常
unsuccessfulAuthentication(request, response, failed);
return;
}
catch (AuthenticationException failed) {
//认证失败
unsuccessfulAuthentication(request, response, failed);
return;
}
//认证成功
if (continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
//注意,认证成功后过滤器把authResult结果也传递给了成功处理器
successfulAuthentication(request, response, chain, authResult);
}
}

整个流程理解起来也并不难,主要就是内部调用了authenticationManager完成认证,根据认证结果执行successfulAuthentication或者unsuccessfulAuthentication,无论成功失败,一般的实现都是转发或者重定向等处理,不再细究AuthenticationSuccessHandler和AuthenticationFailureHandler,有兴趣的朋友,可以去看看两者的实现类。

4.4 AnonymousAuthenticationFilter

匿名认证过滤器,可能有人会想:匿名了还有身份?我自己对于Anonymous匿名身份的理解是Spirng Security为了整体逻辑的统一性,即使是未通过认证的用户,也给予了一个匿名身份。而AnonymousAuthenticationFilter该过滤器的位置也是非常的科学的,它位于常用的身份认证过滤器(如UsernamePasswordAuthenticationFilterBasicAuthenticationFilterRememberMeAuthenticationFilter)之后,意味着只有在上述身份过滤器执行完毕后,SecurityContext依旧没有用户信息,AnonymousAuthenticationFilter该过滤器才会有意义—-基于用户一个匿名身份。

源码分析

org.springframework.security.web.authentication.AnonymousAuthenticationFilter

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
public class AnonymousAuthenticationFilter extends GenericFilterBean implements
InitializingBean {
private AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource = new WebAuthenticationDetailsSource();
private String key;
private Object principal;
private List<GrantedAuthority> authorities;
//自动创建一个"anonymousUser"的匿名用户,其具有ANONYMOUS角色
public AnonymousAuthenticationFilter(String key) {
this(key, "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
}
/**
*
* @param key key用来识别该过滤器创建的身份
* @param principal principal代表匿名用户的身份
* @param authorities authorities代表匿名用户的权限集合
*/
public AnonymousAuthenticationFilter(String key, Object principal,
List<GrantedAuthority> authorities) {
Assert.hasLength(key, "key cannot be null or empty");
Assert.notNull(principal, "Anonymous authentication principal must be set");
Assert.notNull(authorities, "Anonymous authorities must be set");
this.key = key;
this.principal = principal;
this.authorities = authorities;
}
...
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
//过滤器链都执行到匿名认证过滤器这儿了还没有身份信息,塞一个匿名身份进去
if (SecurityContextHolder.getContext().getAuthentication() == null) {
SecurityContextHolder.getContext().setAuthentication(
createAuthentication((HttpServletRequest) req));
}
chain.doFilter(req, res);
}
protected Authentication createAuthentication(HttpServletRequest request) {
//创建一个AnonymousAuthenticationToken
AnonymousAuthenticationToken auth = new AnonymousAuthenticationToken(key,
principal, authorities);
auth.setDetails(authenticationDetailsSource.buildDetails(request));
return auth;
}
...
}

其实对比AnonymousAuthenticationFilter和UsernamePasswordAuthenticationFilter就可以发现一些门道了,UsernamePasswordAuthenticationToken对应AnonymousAuthenticationToken,他们都是Authentication的实现类,而Authentication则是被SecurityContextHolder(SecurityContext)持有的,一切都被串联在了一起。

4.5 ExceptionTranslationFilter

ExceptionTranslationFilter异常转换过滤器位于整个springSecurityFilterChain的后方,用来转换整个链路中出现的异常,将其转化,顾名思义,转化以意味本身并不处理。一般其只处理两大类异常:AccessDeniedException访问异常和AuthenticationException认证异常。

这个过滤器非常重要,因为它将Java中的异常和HTTP的响应连接在了一起,这样在处理异常时,我们不用考虑密码错误该跳到什么页面,账号锁定该如何,只需要关注自己的业务逻辑,抛出相应的异常便可。如果该过滤器检测到AuthenticationException,则将会交给内部的AuthenticationEntryPoint去处理,如果检测到AccessDeniedException,需要先判断当前用户是不是匿名用户,如果是匿名访问,则和前面一样运行AuthenticationEntryPoint,否则会委托给AccessDeniedHandler去处理,而AccessDeniedHandler的默认实现,是AccessDeniedHandlerImpl。所以ExceptionTranslationFilter内部的AuthenticationEntryPoint是至关重要的,顾名思义:认证的入口点。

源码分析

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 ExceptionTranslationFilter extends GenericFilterBean {
//处理异常转换的核心方法
private void handleSpringSecurityException(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, RuntimeException exception)
throws IOException, ServletException {
if (exception instanceof AuthenticationException) {
//重定向到登录端点
sendStartAuthentication(request, response, chain,
(AuthenticationException) exception);
}
else if (exception instanceof AccessDeniedException) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authenticationTrustResolver.isAnonymous(authentication) || authenticationTrustResolver.isRememberMe(authentication)) {
//重定向到登录端点
sendStartAuthentication(
request,
response,
chain,
new InsufficientAuthenticationException(
"Full authentication is required to access this resource"));
}
else {
//交给accessDeniedHandler处理
accessDeniedHandler.handle(request, response,
(AccessDeniedException) exception);
}
}
}
}

剩下的便是要搞懂AuthenticationEntryPoint和AccessDeniedHandler就可以了。

AuthenticationEntryPoint

选择了几个常用的登录端点,以其中第一个为例来介绍,看名字就能猜到是认证失败之后,让用户跳转到登录页面。还记得我们一开始怎么配置表单登录页面的吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/", "/home").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()//FormLoginConfigurer
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll();
}
}

我们顺着formLogin返回的FormLoginConfigurer往下找,看看能发现什么,最终在FormLoginConfigurer的父类AbstractAuthenticationFilterConfigurer中有了不小的收获:

1
2
3
4
5
6
7
8
public abstract class AbstractAuthenticationFilterConfigurer extends ...{
...
//formLogin不出所料配置了AuthenticationEntryPoint
private LoginUrlAuthenticationEntryPoint authenticationEntryPoint;
//认证失败的处理器
private AuthenticationFailureHandler failureHandler;
...
}

具体如何配置的就不看了,我们得出了结论,formLogin()配置了之后最起码做了两件事,其一,为UsernamePasswordAuthenticationFilter设置了相关的配置,其二配置了AuthenticationEntryPoint。

登录端点还有Http401AuthenticationEntryPoint,Http403ForbiddenEntryPoint这些都是很简单的实现,有时候我们访问受限页面,又没有配置登录,就看到了一个空荡荡的默认错误页面,上面显示着401,403,就是这两个入口起了作用。

还剩下一个AccessDeniedHandler访问决策器未被讲解,简单提一下:AccessDeniedHandlerImpl这个默认实现类会根据errorPage和状态码来判断,最终决定跳转的页面

org.springframework.security.web.access.AccessDeniedHandlerImpl#handle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException,
ServletException {
if (!response.isCommitted()) {
if (errorPage != null) {
// Put exception into request scope (perhaps of use to a view)
request.setAttribute(WebAttributes.ACCESS_DENIED_403,
accessDeniedException);
// Set the 403 status code.
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
// forward to error page.
RequestDispatcher dispatcher = request.getRequestDispatcher(errorPage);
dispatcher.forward(request, response);
}
else {
response.sendError(HttpServletResponse.SC_FORBIDDEN,
accessDeniedException.getMessage());
}
}
}

4.6 FilterSecurityInterceptor

想想整个认证安全控制流程还缺了什么?我们已经有了认证,有了请求的封装,有了Session的关联…还缺一个:由什么控制哪些资源是受限的,这些受限的资源需要什么权限,需要什么角色…这一切和访问控制相关的操作,都是由FilterSecurityInterceptor完成的。

FilterSecurityInterceptor的工作流程用笔者的理解可以理解如下:FilterSecurityInterceptor从SecurityContextHolder中获取Authentication对象,然后比对用户拥有的权限和资源所需的权限。前者可以通过Authentication对象直接获得,而后者则需要引入我们之前一直未提到过的两个类:SecurityMetadataSource,AccessDecisionManager。理解清楚决策管理器的整个创建流程和SecurityMetadataSource的作用需要花很大一笔功夫,这里,暂时只介绍其大概的作用。

在JavaConfig的配置中,我们通常如下配置路径的访问控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/resources/**", "/signup", "/about").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/db/**").access("hasRole('ADMIN') and hasRole('DBA')")
.anyRequest().authenticated()
.withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
public <O extends FilterSecurityInterceptor> O postProcess(
O fsi) {
fsi.setPublishAuthorizationSuccess(true);
return fsi;
}
});
}

在ObjectPostProcessor的泛型中看到了FilterSecurityInterceptor,以笔者的经验,目前并没有太多机会需要修改FilterSecurityInterceptor的配置。

总结

本篇文章在介绍过滤器时,顺便进行了一些源码的分析,目的是方便理解整个Spring Security的工作流。伴随着整个过滤器链的介绍,安全框架的轮廓应该已经浮出水面了,下面的章节,主要打算通过自定义一些需求,再次分析其他组件的源码,学习应该如何改造Spring Security,为我们所用。

分享到 评论