Spring Security(三)--核心配置解读

上一篇文章《Spring Security(二)–Guides》,通过Spring Security的配置项了解了Spring Security是如何保护我们的应用的,本篇文章对上一次的配置做一个讲解。

[TOC]

3 核心配置解读

3.1 功能介绍

这是Spring Security入门指南中的配置项:

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
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/", "/home").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout()
.permitAll();
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth
.inMemoryAuthentication()
.withUser("admin").password("admin").roles("USER");
}
}

当配置了上述的javaconfig之后,我们的应用便具备了如下的功能:

  • 除了“/”,”/home”(首页),”/login”(登录),”/logout”(注销),之外,其他路径都需要认证。
  • 指定“/login”该路径为登录页面,当未认证的用户尝试访问任何受保护的资源时,都会跳转到“/login”。
  • 默认指定“/logout”为注销页面
  • 配置一个内存中的用户认证器,使用admin/admin作为用户名和密码,具有USER角色

3.2 解读@EnableWebSecurity

我们自己定义的配置类WebSecurityConfig加上了@EnableWebSecurity注解,同时继承了WebSecurityConfigurerAdapter。你可能会在想谁的作用大一点,先给出结论:毫无疑问@EnableWebSecurity起到决定性的配置作用,他其实是个组合注解,背后SpringBoot做了非常多的配置。

分享到 评论

Spring Security(二)--Guides

上一篇文章《Spring Security(一)–Architecture Overview》,我们介绍了Spring Security的基础架构,这一节我们通过Spring官方给出的一个guides例子,来了解Spring Security是如何保护我们的应用的,之后会对进行一个解读。

[TOC]

2 Spring Security Guides

2.1 引入依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>

由于我们集成了springboot,所以不需要显示的引入Spring Security文档中描述core,config依赖,只需要引入spring-boot-starter-security即可。

查看更多

分享到 评论

Spring Security(一)--Architecture Overview

一直以来我都想写一写Spring Security系列的文章,但是整个Spring Security体系强大却又繁杂。陆陆续续从最开始的guides接触它,到项目中看了一些源码,到最近这个月为了写一写这个系列的文章,阅读了好几遍文档,最终打算尝试一下,写一个较为完整的系列文章。

较为简单或者体量较小的技术,完全可以参考着demo直接上手,但系统的学习一门技术则不然。以我的认知,一般的文档大致有两种风格:Architecture First和Code First。前者致力于让读者先了解整体的架构,方便我们对自己的认知有一个宏观的把控,而后者以特定的demo配合讲解,可以让读者在解决问题的过程中顺便掌握一门技术。关注过我博客或者公众号的朋友会发现,我之前介绍技术的文章,大多数是Code First,提出一个需求,介绍一个思路,解决一个问题,分析一下源码,大多如此。而学习一个体系的技术,我推荐Architecture First,正如本文标题所言,这篇文章是我Spring Security系列的第一篇,主要是根据Spring Security文档选择性翻译整理而成的一个架构概览,配合自己的一些注释方便大家理解。写作本系列文章时,参考版本为Spring Security 4.2.3.RELEASE。

[TOC]

1 核心组件

这一节主要介绍一些在Spring Security中常见且核心的Java类,它们之间的依赖,构建起了整个框架。想要理解整个架构,最起码得对这些类眼熟。

1.1 SecurityContextHolder

SecurityContextHolder用于存储安全上下文(security context)的信息。当前操作的用户是谁,该用户是否已经被认证,他拥有哪些角色权限…这些都被保存在SecurityContextHolder中。SecurityContextHolder默认使用ThreadLocal 策略来存储认证信息。看到ThreadLocal 也就意味着,这是一种与线程绑定的策略。Spring Security在用户登录时自动绑定认证信息到当前线程,在用户退出时,自动清除当前线程的认证信息。但这一切的前提,是你在web场景下使用Spring Security,而如果是Swing界面,Spring也提供了支持,SecurityContextHolder的策略则需要被替换,鉴于我的初衷是基于web来介绍Spring Security,所以这里以及后续,非web的相关的内容都一笔带过。

获取当前用户的信息

因为身份信息是与线程绑定的,所以可以在程序的任何地方使用静态方法获取用户信息。一个典型的获取当前登录用户的姓名的例子如下所示:

1
2
3
4
5
6
7
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof UserDetails) {
String username = ((UserDetails)principal).getUsername();
} else {
String username = principal.toString();
}

getAuthentication()返回了认证信息,再次getPrincipal()返回了身份信息,UserDetails便是Spring对身份信息封装的一个接口。Authentication和UserDetails的介绍在下面的小节具体讲解,本节重要的内容是介绍SecurityContextHolder这个容器。

查看更多

分享到 评论

浅析分布式下的事件驱动机制(PubSub模式)

上一篇文章《浅析Spring中的事件驱动机制》简单介绍了Spring对事件的支持。Event的整个生命周期,从publisher发出,经过applicationContext容器通知到EventListener,都是发生在单个Spring容器中,而在分布式场景下,有些时候一个事件的产生,可能需要被多个实例响应,本文主要介绍分布式场景下的事件驱动机制,由于使用了Redis,ActiveMQ,也可以换一个名词来理解:分布式下的发布订阅模式。

JMS规范

在日常项目开发中,我们或多或少的发现一些包一些类位于java或javax中,他们主要提供抽象类,接口,提供了一种规范,如JPA,JSR,JNDI,JTA,JMS,他们是由java指定的标准规范,一流企业做标准、二流企业做品牌、三流企业做产品,虽然有点调侃的意味,但也可以见得它的重要意义。而JMS就是java在消息服务上指定的标准

The Java Message Service (JMS) API is a messaging standard that allows application components based on the Java Platform Enterprise Edition (Java EE) to create, send, receive, and read messages. It enables distributed communication that is loosely coupled, reliable, and asynchronous.

JMS(JAVA Message Service,java消息服务)API是一个消息服务的标准或者说是规范,允许应用程序组件基于JavaEE平台创建、发送、接收和读取消息。它使分布式通信耦合度更低,消息服务更加可靠以及异步性。

消息中间件有非常多的实现,如ActiveMQ,RabbitMQ,RocketMQ,而他们同一遵循的接口规范,便是JMS。在下文中即将出现的ConnectionFactory,Destination,Connection,Session,MessageListener,Topic,Queue等等名词,都是JMS核心的接口,由于本文的初衷并不是讲解MQ&JMS,所以这些机制暂且跳过。

定义分布式事件需求

在上一个项目中,我们对接了外网的http接口,而安全性的保障则是交给OAuth2来完成,作为OAuth2的客户端,我们需要获取服务端返回的token,而token接口的获取次数每个月是有限制的,于是我们选择使用Redis来保存,定时刷新。由于每次发起请求时都要携带token,为了更高的性能减少一次redis io,我们在TokenService中使用了本地变量缓存token。于是形成如下的token获取机制:

token获取流程

这个图并不复杂,只是为了方便描述需求:首先去本地变量中加载token,若token==null,则去Redis加载,若Redis未命中(token过期了),则最终调用外部的http接口获取实时的token,同时存入redis中和本地变量中。

这个需求设计到这样一个问题:大多数情况下是单个实例中发现redis中的token为空,而它需要同时获取最新token,并通知其他的实例也去加载最新的token,这个时候事件广播就可以派上用场了。

由于token缓存在了Redis中,我们首先介绍Redis的发布订阅机制。

查看更多

分享到 评论

上一个电商项目的反思

加入中科软已经有了一个年头,从去年实习到今年转正,陆陆续续接触了大概四个项目。有电商类,互联网保险类,也经历过管理系统。幸运的是,这些项目都是从零开始,避免了让我去维护不堪入目的老旧系统。而这么多项目中令我印象最深刻的,就要属上一个电商项目了。这也是我接触到的真正意义的第一个微服务项目,到今天回首去看曾经的这个项目,有很多突破性地尝试,同时不可避免地也踩入了一些坑点,毕竟摸着石头过河。今天想聊聊我对上一个电商项目的反思。

项目简介

准确的说是一个第三方的电商项目,商品来源是由主流电商的http接口提供(目前接入了京东,苏宁),打造我们自己的商城体系。使用的技术包括springboot,jpa,rpc框架使用的是motan,数据库使用的是oracle,基本都还算是主流的技术。

盲目地拆分微服务

使用了springboot就是微服务了吗?使用rpc通信就是微服务了吗?刚接触到所谓的微服务架构时,无疑是让人兴奋的,但也没有太多的经验,以至于每提出一个新的需求,几乎就会新建一个服务。没有从宏观去思考如何拆分服务,那时还没有项目组成员尝试去使用领域驱动设计的思想去划分服务的边界,会议室中讨论最多的话题也是:我们的数据库该如何设计,而不是我们的领域该如何划分。项目初期,使用单体式的思想开发着分布式项目,新技术的引入还只是使人有点稍微的不顺手,但是项目越做越大后,越来越大的不适感逐渐侵蚀着我们的开发速度。

说道微服务的拆分,有很多个维度,这里主要谈两个维度:

  • 系统维度:业务功能不同的需求,交给不同的系统完成,如订单,商品,地址,用户等系统需要拆分。
  • 模块维度:基础架构层(如公用util),领域层,接口层,服务层,表现层的拆分。

在项目的初期,我们错误地认为微服务的拆分仅仅是系统维度的拆分,如商品系统和订单系统,而在模块维度上,缺少拆分的意识,如订单模块的表现层和服务层,我们虽然做了隔离(两个独立的tomcat)。但在后来,业务添加了一个新的需求:商城增加积分支持,让用户可以使用积分购买商品。我们突然发现,所谓的服务层和表现层严重的耦合,仅仅是在物理上进行了隔离,逻辑层面并没有拆分,这导致新的积分服务模块从原先的订单服务层拷贝了大量的代码。吸取了这个教训后,我们新的项目中采取了如下的分层方式:

新的架构分层

其中比较关键的一点便是表现层与应用层的完全分离,交互完全使用DTO对象。不少同事产生了困惑,抱怨在表现层不能访问数据库,这让他们获取数据变得十分“麻烦”,应用层和表现层还多了一次数据拷贝的工作,用于将DO持久化对象转换成DTO对象。但这样的好处从长远来看,是不言而喻的。总结为以下几点:

1 应用层高度重用,没有表现形式的阻碍,PC端,移动端,外部服务都可以同时接入,需要组装什么样的数据,请自行组装。

2 应用层和领域层可以交由经验较为丰富的程序员负责,避免了一些低性能的数据操作,错误的并发控制等等。

3 解决远程调用数据懒加载的问题。从前的设计中,表现层拿到了领域层的对象,而领域层会使用懒加载技术,当表现层想要获取懒加载属性时,或得到一个no session的异常。在没有这个分层之前,如何方便地解决这个问题一度困扰了我们很长的一段时间。

数据库的滥用

项目使用了oracle,我们所有的数据都存在于同一个oracle实例中,各个系统模块并没有做到物理层面的数据库隔离。这并不符合设计,一方面这给那些想要跨模块执行join操作的人留了后门,如执行订单模块和用户模块的级联查询;另一方面,还困扰了一部分对微服务架构不甚了解的程序员,在他们的想法中,同一个数据库实例反而方便了他们的数据操作。

严格意义上,不仅仅是不同系统之间的数据库不能互相访问。同一个系统维度的不同模块也应当限制,正如前面一节的分层架构中,表现层(web层)是不应该出现DAO的,pom文件中也不应该出现任何JPA,Hibernate,Mybatis一类的依赖,它所有的数据来源,必须是应用层。

另外一方面,由于历史遗留问题,需要对接一个老系统,他们的表和这个电商的oracle实例是同一个,而我竟然在他们的表上发现了触发器这种操作…在新的项目中,我们已经禁止使用数据库层面的触发器和物理约束。

在新的项目中,我们采用了阿里云的RDS(mysql)作为oracle的替代品,核心业务数据则放到了分布式数据库DRDS中,严格做到了数据库层面的拆分。

并发的控制

电商系统不同于OA系统,CMS系统,余额,订单等等操作都是敏感操作,实实在在跟钱打交道的东西容不得半点马虎,然而即使是一些有经验的程序员,也写出了这样的扣减余额操作:

1
2
3
4
5
6
7
8
public void reduce(String accountId,BigDecimal cost){
Account account = accountService.findOne(accountId);
BigDecimal balance = account.getBalance();
if(balance > cost)
balance = balance - cost;//用四则运算代替BigDecimal的api,方便表达
account.setBalance(balance);
accountService.save(account);
}

很多人没有控制并发的意识,即使意识到了,也不知道如何根据业务场景采取合适的手段控制并发,是使用JPA中的乐观锁,还是使用数据库的行级自旋锁完成简单并发控制,还是for update悲观锁(这不建议被使用),还是基于redis或zookeeper一类的分布式锁?

这种错误甚至都不容许等到code revivew时才被发现,而应该是尽力地杜绝。

代码规范

小到java的变量的驼峰命名法,数据库中用‘_’分割单词,到业务代码该如何规范的书写,再到并发规范,性能调优。准确的说,没有人管理这些事,这样的工作落到了每个有悟性的开发者身上。模块公用的常量,系统公用的常量应当区分放置,禁止使用魔鬼数字,bool变量名不能以is开头等等细小的但是重要的规范,大量的条件查询findByxxx污染了DAO层,完全可以被predicates,criteria替代,RESTFUL规范指导设计web接口等等…

在新的项目中,一条条规范被逐渐添加到了项目单独的模块READ.me中。作为公司的一个junior developer,在建议其他成员使用规范开发项目时,得到的回应通常是:我的功能都已经实现了,干嘛要改;不符合规范又怎么样,要改你改时。有时候也是挺无力的,算是个人的一点牢骚吧。

软件设计的一点不足

还是拿订单系统和商品系统来说事,虽然两个系统在物理上被拆分开了,但如果需要展示订单列表,订单详情,如今系统的设计会发起多次的远程调用,用于查询订单的归属商品,这是违背领域驱动设计的。订单中的商品就应当是归属于订单模块,正确的设计应该是使用冗余,代替频繁的跨网络节点远程调用。

另外一点便是高可用,由于机器内存的限制,所有的系统都只部署了单个实例,这其实并不是微服务的最佳实践。从系统应用,到zookeeper,redis,mq等中间件,都应当保证高可用,避免单点问题。没有真正实现做到横向扩展(知识理论上实现了),实在是有点遗憾。

系统没有熔断,降级处理,在新的项目中,由于我们引入了Spring Cloud,很多地方都可以out of box式使用框架提供的fallback处理,而这上一个电商项目由于框架的限制以及接口设计之初就没有预想到要做这样的操作,使得可靠性再减了几分。

自动化运维的缺失

单体式应用的美好时代,只需要发布同一份war包。而微服务项目中,一切都变得不同,在我们这个不算特别庞大的电商系统中,需要被运行的服务模块也到达了30-40个。由于这个电商系统是部署在甲方自己的服务器中,一方面是业务部门的业务审批流程,一方面是如此众多的jar包运行,没有自动发布,没有持续集成。令我比较难忘的是初期发布版本,始终有一两个服务莫名奇妙的挂掉,对着终端中的服务列表,一个个排查,这种痛苦的经历。至今,这个系统仍然依靠运维人员,手动管理版本。

上一个项目有一些不可控的项目因素,而新的项目中,系统服务全部在阿里云上部署,也引入了Jenkins,一切都在逐渐变好,其他的devops工具仍然需要完善,以及docker一类的容器技术还未在计划日程之内,这些都是我们今年努力的目标。

总结

原本积累了很多自己的想法,可惜落笔之后能够捕捉到一些点,便只汇聚成了上述这些,而这上一个电商项目在逐渐的迭代开发之后也变得越来越好了(我去了新的项目组后,其他同事负责了后续的开发)。这个经历,于我是非常珍贵的,它比那些大牛直接告诉我微服务设计的要素要更加有意义。知道了不足之处,经历了自己解决问题的过程,才会了解到好的方案的优势,了解到开源方案到底是为了解决什么样的问题而设计的。

分享到 评论

浅析Spring中的事件驱动机制

今天来简单地聊聊事件驱动,其实写这篇文章挺令我挺苦恼的,因为事件驱动这个名词,我没有找到很好的定性解释,担心自己的表述有误,而说到事件驱动可能立刻联想到如此众多的概念:观察者模式,发布订阅模式,消息队列MQ,消息驱动,事件,EventSourcing…为了不产生歧义,笔者把自己所了解的这些模棱两可的概念都列了出来,再开始今天的分享。

  • 在设计模式中,观察者模式可以算得上是一个非常经典的行为型设计模式,猫叫了,主人醒了,老鼠跑了,这一经典的例子,是事件驱动模型在设计层面的体现。
  • 另一模式,发布订阅模式往往被人们等同于观察者模式,但我的理解是两者唯一区别,是发布订阅模式需要有一个调度中心,而观察者模式不需要,例如观察者的列表可以直接由被观察者维护。不过两者即使被混用,互相替代,通常不影响表达。
  • MQ,中间件级别的消息队列(e.g. ActiveMQ,RabbitMQ),可以认为是发布订阅模式的一个具体体现。事件驱动->发布订阅->MQ,从抽象到具体。
  • java和spring中都拥有Event的抽象,分别代表了语言级别和三方框架级别对事件的支持。
  • EventSourcing这个概念就要关联到领域驱动设计,DDD对事件驱动也是非常地青睐,领域对象的状态完全是由事件驱动来控制,由其衍生出了CQRS架构,具体实现框架有AxonFramework。
  • Nginx可以作为高性能的应用服务器(e.g. openResty),以及Nodejs事件驱动的特性,这些也是都是事件驱动的体现。

本文涵盖的内容主要是前面4点。

Spring对Event的支持

Spring的文档对Event的支持翻译之后描述如下:

ApplicationContext通过ApplicationEvent类和ApplicationListener接口进行事件处理。 如果将实现ApplicationListener接口的bean注入到上下文中,则每次使用ApplicationContext发布ApplicationEvent时,都会通知该bean。 本质上,这是标准的观察者设计模式。

而在spring4.2之后,提供了注解式的支持,我们可以使用任意的java对象配合注解达到同样的效果,首先来看看不适用注解如何在Spring中使用事件驱动机制。

定义业务需求:用户注册后,系统需要给用户发送邮件告知用户注册成功,需要给用户初始化积分;隐含的设计需求,用户注册后,后续需求可能会添加其他操作,如再发送一条短信等等,希望程序具有扩展性,以及符合开闭原则。

如果不使用事件驱动,代码可能会像这样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class UserService {
@Autowired
EmailService emailService;
@Autowired
ScoreService scoreService;
@Autowired
OtherService otherService;
public void register(String name) {
System.out.println("用户:" + name + " 已注册!");
emailService.sendEmail(name);
scoreService.initScore(name);
otherService.execute(name);
}
}

要说有什么毛病,其实也不算有,因为可能大多数人在开发中都会这么写,喜欢写同步代码。但这么写,实际上并不是特别的符合隐含的设计需求,假设增加更多的注册项service,我们需要修改register的方法,并且让UserService注入对应的Service。而实际上,register并不关心这些“额外”的操作,如何将这些多余的代码抽取出去呢?便可以使用Spring提供的Event机制。

定义用户注册事件

1
2
3
4
5
6
7
public class UserRegisterEvent extends ApplicationEvent{
public UserRegisterEvent(String name) { //name即source
super(name);
}
}

ApplicationEvent是由Spring提供的所有Event类的基类,为了简单起见,注册事件只传递了name(可以复杂的对象,但注意要了解清楚序列化机制)。

定义用户注册服务(事件发布者)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service // <1>
public class UserService implements ApplicationEventPublisherAware { // <2>
public void register(String name) {
System.out.println("用户:" + name + " 已注册!");
applicationEventPublisher.publishEvent(new UserRegisterEvent(name));// <3>
}
private ApplicationEventPublisher applicationEventPublisher; // <2>
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) { // <2>
this.applicationEventPublisher = applicationEventPublisher;
}
}

<1> 服务必须交给Spring容器托管

<2> ApplicationEventPublisherAware是由Spring提供的用于为Service注入ApplicationEventPublisher事件发布器的接口,使用这个接口,我们自己的Service就拥有了发布事件的能力。

<3> 用户注册后,不再是显示调用其他的业务Service,而是发布一个用户注册事件。

定义邮件服务,积分服务,其他服务(事件订阅者)

1
2
3
4
5
6
7
8
@Service // <1>
public class EmailService implements ApplicationListener<UserRegisterEvent> { // <2>
@Override
public void onApplicationEvent(UserRegisterEvent userRegisterEvent) {
System.out.println("邮件服务接到通知,给 " + userRegisterEvent.getSource() + " 发送邮件...");// <3>
}
}

<1> 事件订阅者的服务同样需要托管于Spring容器

<2> ApplicationListener<E extends ApplicationEvent>接口是由Spring提供的事件订阅者必须实现的接口,我们一般把该Service关心的事件类型作为泛型传入。

<3> 处理事件,通过event.getSource()即可拿到事件的具体内容,在本例中便是用户的姓名。

其他两个Service,也同样编写,实际的业务操作仅仅是打印一句内容即可,篇幅限制,这里省略。

编写启动类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@SpringBootApplication
@RestController
public class EventDemoApp {
public static void main(String[] args) {
SpringApplication.run(EventDemoApp.class, args);
}
@Autowired
UserService userService;
@RequestMapping("/register")
public String register(){
userService.register("kirito");
return "success";
}
}

当我们调用userService.register(“kirito”);方法时,控制台打印信息如下:

用户注册

他们的顺序是无序的,如果需要控制顺序,需要重写order接口,这点不做介绍。其次,我们完成了用户注册和其他服务的解耦,这也是事件驱动的最大特性之一,如果需要在用户注册时完成其他操作,只需要再添加相应的事件订阅者即可。

Spring 对Event的注解支持

上述的几个接口已经非常清爽了,如果习惯使用注解,Spring也提供了,不再需要显示实现

注解式的事件发布者

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class UserService {
public void register(String name) {
System.out.println("用户:" + name + " 已注册!");
applicationEventPublisher.publishEvent(new UserRegisterEvent(name));
}
@Autowired
private ApplicationEventPublisher applicationEventPublisher;
}

Spring4.2之后,ApplicationEventPublisher自动被注入到容器中,采用Autowired即可获取。

注解式的事件订阅者

1
2
3
4
5
6
7
8
@Service
public class EmailService {
@EventListener
public void listenUserRegisterEvent(UserRegisterEvent userRegisterEvent) {
System.out.println("邮件服务接到通知,给 " + userRegisterEvent.getSource() + " 发送邮件...");
}
}

@EventListener注解完成了ApplicationListener<E extends ApplicationEvent>接口的使命。

更多的特性可以参考SpringFramework的文档。

Spring中事件的应用

在以往阅读Spring源码的经验中,接触了不少使用事件的地方,大概列了以下几个,加深以下印象:

  • Spring Security中使用AuthenticationEventPublisher处理用户认证成功,认证失败的消息处理。

    1
    2
    3
    4
    5
    6
    7
    public interface AuthenticationEventPublisher {
    void publishAuthenticationSuccess(Authentication authentication);
    void publishAuthenticationFailure(AuthenticationException exception,
    Authentication authentication);
    }
  • Hibernate中持久化对象属性的修改是如何被框架得知的?正是采用了一系列持久化相关的事件,如DefaultSaveEventListenerDefaultUpdateEventListener,事件非常多,有兴趣可以去org.hibernate.event包下查看。

  • Spring Cloud Zuul中刷新路由信息使用到的ZuulRefreshListener

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    private static class ZuulRefreshListener implements ApplicationListener<ApplicationEvent> {
    ...
    public void onApplicationEvent(ApplicationEvent event) {
    if(!(event instanceof ContextRefreshedEvent) && !(event instanceof RefreshScopeRefreshedEvent) && !(event instanceof RoutesRefreshedEvent)) {
    if(event instanceof HeartbeatEvent && this.heartbeatMonitor.update(((HeartbeatEvent)event).getValue())) {
    this.zuulHandlerMapping.setDirty(true);
    }
    } else {
    this.zuulHandlerMapping.setDirty(true);
    }
    }
    }
  • Spring容器生命周期相关的一些默认Event

    1
    ContextRefreshedEvent,ContextStartedEvent,ContextStoppedEvent,ContextClosedEvent,RequestHandledEvent

    。。。其实吧,非常多。。。

总结

本文暂时只介绍了Spring中的一些简单的事件驱动机制,相信如果之后再看到Event,Publisher,EventListener一类的单词后缀时,也能立刻和事件机制联系上了。再阅读Spring源码时,如果发现出现了某个Event,但由于不是同步调用,所以很容易被忽视,我一般习惯下意识的去寻找有没有提供默认的Listener,这样不至于漏掉一些“隐藏”的特性。下一篇文章打算聊一聊分布式场景下,事件驱动使用的注意点。

公众号刚刚创立,如果觉得文章不错,希望能分享到您的朋友圈,如果对文章有什么想法和建议,可以与我沟通。

分享到 评论

从Feign使用注意点到RESUFUL接口设计规范

最近项目中大量使用了Spring Cloud Feign来对接http接口,踩了不少坑,也产生了一些对RESTFUL接口设计的想法,特此一篇记录下。

[TOC]

SpringMVC的请求参数绑定机制

了解Feign历史的朋友会知道,Feign本身是Netflix的产品,Spring Cloud Feign是在原生Feign的基础上进行了封装,引入了大量的SpringMVC注解支持,这一方面使得其更容易被广大的Spring使用者开箱即用,但也产生了不小的混淆作用。所以在使用Spring Cloud Feign之前,笔者先介绍一下SpringMVC的一个入参机制。预设一个RestController,在本地的8080端口启动一个应用,用于接收http请求。

1
2
3
4
5
6
7
8
9
@RestController
public class BookController {
@RequestMapping(value = "/hello") // <1>
public String hello(String name) { // <2>
return "hello " + name;
}
}

这个接口写起来非常简单,但实际springmvc做了非常多的兼容,使得这个接口可以接受多种请求方式。

<1> RequestMapping代表映射的路径,使用GET,POST,PUT,DELETE方式都可以映射到该端点。

<2> SpringMVC中常用的请求参数注解有(@RequestParam,@RequestBody,@PathVariable)等。name被默认当做@RequestParam。形参String name由框架使用字节码技术获取name这个名称,自动检测请求参数中key值为name的参数,也可以使用@RequestParam(“name”)覆盖变量本身的名称。当我们在url中携带name参数或者form表单中携带name参数时,会被获取到。

1
2
3
4
5
POST /hello HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded
name=formParam

1
2
GET /hello?name=queryString HTTP/1.1
Host: localhost:8080

Feign的请求参数绑定机制

上述的SpringMVC参数绑定机制,大家应该都是非常熟悉的,但这一切在Feign中有些许的不同。

我们来看一个非常简单的,但是实际上错误的接口写法:

1
2
3
4
5
6
7
8
//注意:错误的接口写法
@FeignClient("book")
public interface BookApi {
@RequestMapping(value = "/hello",method = RequestMethod.GET)
String hello(String name);
}

配置请求地址:

1
2
3
4
5
6
7
ribbon:
eureka:
enabled: false
book:
ribbon:
listOfServers: http://localhost:8080

我们按照写SpringMVC的RestController的习惯写了一个FeignClient,按照我们的一开始的想法,由于指定了请求方式是GET,那么name应该会作为QueryString拼接到Url中吧?发出一个这样的GET请求:

1
2
GET /hello?name=xxx HTTP/1.1
Host: localhost:8080

而实际上,RestController并没有接收到,我们在RestController一侧的应用中获得了一些提示:

服务端DEBUG信息

  • 并没有按照期望使用GET方式发送请求,而是POST方式
  • name参数没有被封装,获得了一个null值

查看文档发现,如果不加默认的注解,Feign则会对参数默认加上@RequestBody注解,而RequestBody一定是包含在请求体中的,GET方式无法包含。所以上述两个现象得到了解释。Feign在GET请求包含RequestBody时强制转成了POST请求,而不是报错。

理解清楚了这个机制我们就可以在开发Feign接口避免很多坑。而解决上述这个问题也很简单

  • 在Feign接口中为name添加@RequestParam(“name”)注解,name必须指定,Feign的请求参数不会利用SpringMVC字节码的机制自动给定一个默认的名称。
  • 由于Feign默认使用@RequestBody,也可以改造RestController,使用@RequestBody接收。但是,请求参数通常是多个,推荐使用上述的@RequestParam,而@RequestBody一般只用于传递对象。

Feign绑定复合参数

指定请求参数的类型与请求方式,上述问题的出现实际上是由于在没有理清楚Feign内部机制的前提下想当然的和SpringMVC进行了类比。同样,在使用对象作为参数时,也需要注意这样的问题。

对于这样的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@FeignClient("book")
public interface BookApi {
@RequestMapping(value = "/book",method = RequestMethod.POST)
Book book(@RequestBody Book book); // <1>
@RequestMapping(value = "/book",method = RequestMethod.POST)
Book book(@RequestParam("id") String id,@RequestParam("name") String name); // <2>
@RequestMapping(value = "/book",method = RequestMethod.POST)
Book book(@RequestParam Map map); // <3>
//错误的写法
@RequestMapping(value = "/book",method = RequestMethod.POST)
Book book(@RequestParam Book book); // <4>
}

<1> 使用@RequestBody传递对象是最常用的方式。

<2> 如果参数并不是很多,可以平铺开使用@RequestParam

<3> 使用Map,这也是完全可以的,但不太符合面向对象的思想,不能从代码立刻看出该接口需要什么样的参数。

<4> 错误的用法,Feign没有提供这样的机制自动转换实体为Map。

Feign中使用@PathVariable与RESTFUL规范

这涉及到一个如何设计RESTFUL接口的话题,我们知道在自从RESTFUL在2000年初被提出来之后,就不乏文章提到资源,契约规范,CRUD对应增删改查操作等等。下面笔者从两个实际的接口来聊聊自己的看法。

根据id查找用户接口:

1
2
3
4
5
6
7
@FeignClient("user")
public interface UserApi {
@RequestMapping(value = "/user/{userId}",method = RequestMethod.GET)
String findById(@PathVariable("id") String userId);
}

这应该是没有争议的,注意前面强调的,@PathVariable(“id”)括号中的id不可以忘记。那如果是“根据邮箱查找用户呢”?很有可能下意识的写出这样的接口:

1
2
3
4
5
6
7
@FeignClient("user")
public interface UserApi {
@RequestMapping(value = "/user/{email}",method = RequestMethod.GET)
String findByEmail(@PathVariable("email") String email);
}
  • 首先看看Feign的问题。email中通常包含’.‘这个特殊字符,如果在路径中包含,会出现意想不到的结果。我不想探讨如何去解决它(实际上可以使用{email:.+}的方式),因为我觉得这不符合设计。
  • 再谈谈规范的问题。这两个接口是否是相似的,email是否应该被放到path中?这就要聊到RESTFUL的初衷,为什么userId这个属性被普遍认为适合出现在RESTFUL路径中,因为id本身起到了资源定位的作用,他是资源的标记。而email不同,它可能是唯一的,但更多的,它是资源的属性,所以,笔者认为不应该在路径中出现非定位性的动态参数。而是把email作为@RequestParam参数。

RESUFTL结构化查询

笔者成功的从Feign的话题过度到了RESTFUL接口的设计问题,也导致了本文的篇幅变长了,不过也不打算再开一片文章谈了。

再考虑一个接口设计,查询某一个月某个用户的订单,可能还会携带分页参数,这时候参数变得很多,按照传统的设计,这应该是一个查询操作,也就是与GET请求对应,那是不是意味着应当将这些参数拼接到url后呢?再思考Feign,正如本文的第二段所述,是不支持GET请求携带实体类的,这让我们设计陷入了两难的境地。而实际上参考一些DSL语言的设计如elasticSearch,也是使用POST JSON的方式来进行查询的,所以在实际项目中,笔者并不是特别青睐CRUD与四种请求方式对应的这种所谓的RESTFUL规范,如果说设计RESTFUL应该遵循什么规范,那大概是另一些名词,如契约规范和领域驱动设计。

1
2
3
4
5
6
7
@FeignClient("order")
public interface BookApi {
@RequestMapping(value = "/order/history",method = RequestMethod.POST)
Page<List<Orders>> queryOrderHistory(@RequestBody QueryVO queryVO);
}

RESTFUL行为限定

在实际接口设计中,我遇到了这样的需求,用户模块的接口需要支持修改用户密码,修改用户邮箱,修改用户姓名,而笔者之前阅读过一篇文章,也是讲舍弃CRUD而是用领域驱动设计来规范RESTFUL接口的定义,与项目中我的想法不谋而合。看似这三个属性是同一个实体类的三个属性,完全可以如下设计:

1
2
3
4
5
6
7
@FeignClient("user")
public interface UserApi {
@RequestMapping(value = "/user",method = RequestMethod.POST)
User update(@RequestBody User user);
}

但实际上,如果再考虑多一层,就应该产生这样的思考:这三个功能所需要的权限一致吗?真的应该将他们放到一个接口中吗?实际上,笔者并不希望接口调用方传递一个实体,因为这样的行为是不可控的,完全不知道它到底是修改了什么属性,如果真的要限制行为,还需要在User中添加一个操作类型的字段,然后在接口实现方加以校验,这太麻烦了。而实际上,笔者觉得规范的设计应当如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@FeignClient("user")
public interface UserApi {
@RequestMapping(value = "/user/{userId}/password/update",method = RequestMethod.POST)
ResultBean<Boolean> updatePassword(@PathVariable("userId) String userId,@RequestParam("password") password);
@RequestMapping(value = "/user/{userId}/email/update",method = RequestMethod.POST)
ResultBean<Boolean> updateEmail(@PathVariable("userId) String userId,@RequestParam("email") String email);
@RequestMapping(value = "/user/{userId}/username/update",method = RequestMethod.POST)
ResultBean<Boolean> updateUsername(@PathVariable("userId) String userId,@RequestParam("username") String username);
}
  • 一般意义上RESTFUL接口不应该出现动词,这里的update并不是一个动作,而是标记着操作的类型,因为针对某个属性可能出现的操作类型可能会有很多,所以我习惯加上一个update后缀,明确表达想要进行的操作,而不是仅仅依赖于GET,POST,PUT,DELETE。实际上,修改操作推荐使用的请求方式应当是PUT,这点笔者的理解是,已经使用update标记了行为,实际开发中不习惯使用PUT。
  • password,email,username都是user的属性,而userId是user的识别符号,所以userId以PathVariable的形式出现在url中,而三个属性出现在ReqeustParam中。

顺带谈谈逻辑删除,如果一个需求是删除用户的常用地址,这个api的操作类型,我通常也不会设计为DELETE请求,而是同样使用delete来标记操作行为

1
2
@RequestMapping(value = "/user/{userId}/address/{addressId}/delete",method = RequestMethod.POST)
ResultBean<Boolean> updateEmail(@PathVariable("userId") String userId,@PathVariable("userId") String email);

总结

本文从Feign的使用注意点,聊到了RESTFUL接口的设计问题,其实是一个互相补充的行为。接口设计需要载体,所以我以Feign的接口风格谈了谈自己对RESTFUL设计的理解,而Feign中一些坑点,也正是我想要规范RESTFUL设计的出发点。如有对RESTFUL设计不同的理解,欢迎与我沟通。

分享到 评论

Re:从零开始的Spring Session(三)

上一篇文章中,我们使用Redis集成了Spring Session。大多数的配置都是Spring Boot帮我们自动配置的,这一节我们介绍一点Spring Session较为高级的特性。

集成Spring Security

之所以把Spring Session和Spring Security放在一起讨论,是因为我们的应用在集成Spring Security之后,用户相关的认证与Session密不可分,如果不注意一些细节,会引发意想不到的问题。

与Spring Session相关的依赖可以参考上一篇文章,这里给出增量的依赖:

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

我们引入依赖后,就已经自动配置了Spring Security,我们在application.yml添加一个内存中的用户:

1
2
3
4
security:
user:
name: admin
password: admin

测试登录点沿用上一篇文章的端点,访问http://localhost:8080/test/cookie?browser=chrome端点后会出现http basic的认证框,我们输入admin/admin,即可获得结果,也遇到了第一个坑点,我们会发现每次请求,sessionId都会被刷新,这显然不是我们想要的结果。

诡异的运行结果

这个现象笔者研究了不少源码,但并没有得到非常满意的解释,只能理解为SecurityAutoConfiguration提供的默认配置,没有触发到响应的配置,导致了session的不断刷新(如果读者有合理的解释可以和我沟通)。Spring Session之所以能够替换默认的tomcat httpSession是因为配置了springSessionRepositoryFilter这个过滤器,且提供了非常高的优先级,这归功于AbstractSecurityWebApplicationInitializerAbstractHttpSessionApplicationInitializer 这两个初始化器,当然,也保证了Spring Session会在Spring Security之前起作用。

而解决上述的诡异现象也比较容易(但原理不清),我们使用@EnableWebSecurity对Spring Security进行一些配置,即可解决这个问题。

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
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// @formatter:off
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/resources/**").permitAll()
.anyRequest().authenticated()
.and()
.httpBasic()//<1>
.and()
.logout().permitAll();
}
// @formatter:on
// @formatter:off
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth
.inMemoryAuthentication()
.withUser("admin").password("admin").roles("USER");//<2>
}
// @formatter:on
}

<1> 不想大费周章写一个登录页面,于是开启了http basic认证

<2> 配置了security config之后,springboot的autoConfig就会失效,于是需要手动配置用户。

再次请求,可以发现SessionId返回正常,@EnableWebSecurity似乎触发了相关的配置,当然了,我们在使用Spring Security时不可能使用autoconfig,但是这个现象的确是一个疑点。

使用自定义CookieSerializer

1
2
3
4
5
6
7
8
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("JSESSIONID");
serializer.setCookiePath("/");
serializer.setDomainNamePattern("^.+?\\.(\\w+\\.[a-z]+)$");
return serializer;
}

使用上述配置后,我们可以将Spring Session默认的Cookie Key从SESSION替换为原生的JSESSIONID。而CookiePath设置为根路径且配置了相关的正则表达式,可以达到同父域下的单点登录的效果,在未涉及跨域的单点登录系统中,这是一个非常优雅的解决方案。如果我们的当前域名是moe.cnkirito.moe,该正则会将Cookie设置在父域cnkirito.moe中,如果有另一个相同父域的子域名blog.cnkirito.moe也会识别这个Cookie,便可以很方便的实现同父域下的单点登录。

根据用户名查找用户归属的SESSION

这个特性听起来非常有意思,你可以在一些有趣的场景下使用它,如知道用户名后即可删除其SESSION。一直以来我们都是通过线程绑定的方式,让用户操作自己的SESSION,包括获取用户名等操作。但如今它提供了一个反向的操作,根据用户名获取SESSION,恰巧,在一些项目中真的可以使用到这个特性,最起码,当别人问起你,或者讨论到和SESSION相关的知识时,你可以明晰一点,这是可以做到的。

我们使用Redis作为Session Store还有一个好处,就是其实现了FindByIndexNameSessionRepository接口,下面让我们来见证这一点。

1
2
3
4
5
6
7
8
9
10
11
12
@Controller
public class CookieController {
@Autowired
FindByIndexNameSessionRepository<? extends ExpiringSession> sessionRepository;
@RequestMapping("/test/findByUsername")
@ResponseBody
public Map findByUsername(@RequestParam String username) {
Map<String, ? extends ExpiringSession> usersSessions = sessionRepository.findByIndexNameAndIndexValue(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, username);
return usersSessions;
}
}

由于一个用户可能拥有多个Session,所以返回的是一个Map信息,而这里的username,则就是与Spring Security集成之后的用户名,最令人感动Spring厉害的地方,是这一切都是自动配置好的。我们在内存中配置的用户的username是admin,于是我们访问这个端点,可以看到如下的结果

用户名访问session

连同我们存入session中的browser=chrome,browser=360都可以看见(只有键名)。

总结

Spring Session对各种场景下的Session管理提供一套非常完善的实现。笔者所介绍的,仅仅是Spring Session常用的一些特性,更多的知识点可以在spring.io的文档中一览无余,以及本文中作者存在的一个疑惑,如有兴趣可与我沟通。

分享到 评论

Re:从零开始的Spring Session(二)

上一篇文章介绍了一些Session和Cookie的基础知识,这篇文章开始正式介绍Spring Session是如何对传统的Session进行改造的。官网这么介绍Spring Session:

Spring Session provides an API and implementations for managing a user’s session information. It also provides transparent integration with:

  • HttpSession - allows replacing the HttpSession in an application container (i.e. Tomcat) neutral way. Additional features include:
    • Clustered Sessions - Spring Session makes it trivial to support clustered sessions without being tied to an application container specific solution.
    • Multiple Browser Sessions - Spring Session supports managing multiple users’ sessions in a single browser instance (i.e. multiple authenticated accounts similar to Google).
    • RESTful APIs - Spring Session allows providing session ids in headers to work with RESTful APIs
  • WebSocket - provides the ability to keep the HttpSession alive when receiving WebSocket messages

其具体的特性非常之多,具体的内容可以从文档中了解到,笔者做一点自己的总结,Spring Session的特性包括但不限于以下:

  • 使用GemFire来构建C/S架构的httpSession(不关注)
  • 使用第三方仓储来实现集群session管理,也就是常说的分布式session容器,替换应用容器(如tomcat的session容器)。仓储的实现,Spring Session提供了三个实现(redis,mongodb,jdbc),其中redis使我们最常用的。程序的实现,使用AOP技术,几乎可以做到透明化地替换。(核心)
  • 可以非常方便的扩展Cookie和自定义Session相关的Listener,Filter。
  • 可以很方便的与Spring Security集成,增加诸如findSessionsByUserName,rememberMe,限制同一个账号可以同时在线的Session数(如设置成1,即可达到把前一次登录顶掉的效果)等等

介绍完特性,下面开始一步步集成Spring Session

查看更多

分享到 评论

Re:从零开始的Spring Session(一)

Session和Cookie这两个概念,在学习java web开发之初,大多数人就已经接触过了。最近在研究跨域单点登录的实现时,发现对于Session和Cookie的了解,并不是很深入,所以打算写两篇文章记录一下自己的理解。在我们的应用集成Spring Session之前,先补充一点Session和Cookie的关键知识。

Session与Cookie基础

由于http协议是无状态的协议,为了能够记住请求的状态,于是引入了Session和Cookie的机制。我们应该有一个很明确的概念,那就是Session是存在于服务器端的,在单体式应用中,他是由tomcat管理的,存在于tomcat的内存中,当我们为了解决分布式场景中的session共享问题时,引入了redis,其共享内存,以及支持key自动过期的特性,非常契合session的特性,我们在企业开发中最常用的也就是这种模式。但是只要你愿意,也可以选择存储在JDBC,Mongo中,这些,spring都提供了默认的实现,在大多数情况下,我们只需要引入配置即可。而Cookie则是存在于客户端,更方便理解的说法,可以说存在于浏览器。Cookie并不常用,至少在我不长的web开发生涯中,并没有什么场景需要我过多的关注Cookie。http协议允许从服务器返回Response时携带一些Cookie,并且同一个域下对Cookie的数量有所限制,之前说过Session的持久化依赖于服务端的策略,而Cookie的持久化则是依赖于本地文件。虽然说Cookie并不常用,但是有一类特殊的Cookie却是我们需要额外关注的,那便是与Session相关的sessionId,他是真正维系客户端和服务端的桥梁。

查看更多

分享到 评论