Spring的那些坑
这篇文章列举了我在学习、使用 Spring 框架(包括但不限于 Spring Security, Spring MVC, Spring Data 等派生框架)过程中产生的疑问及问题。
Spring Security 的配置上下文问题
最近按照《Spring 实战(第四版)》进行一个新项目的配置,前面的几步都很轻松,直到配置 Spring Security 的时候就出了问题。
日志提示:
SEVERE [RMI TCP Connection(3)-127.0.0.1] org.apache.catalina.core.StandardContext.startInternal One or more Filters failed to start. Full details will be found in the appropriate container log file
运用排除法可以判断是 Spring Security 的配置出了问题。跑到 Tomcat 的 localhost log 一看,啥都没有,空空如也:
后面才发现是消息级别设置错误了,点击右上角的 Warning 设置成 All 就可以看到如下信息:
No bean named 'springSecurityFilterChain' available
至此,可以确定是 Spring Security 的问题无疑。但是,配置的步骤都和书上的一致,按照书上的说法,在 WebSecurityConfigurerAdapter
的子类中标注了 @EnableWebSecurity
,就应该能够把 springSecurityFilterChain
这一系列的 filter 配置妥当。但是从日志看来,并没有。
经过查找和搜索,才发现上述 WebSecurityConfigurerAdapter
的子类 SecurityConfig
,应该列在 RootConfigClasses
里面,而不是 ServletConfigClasses
里面。更换后问题解决。
public class WebInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { @Override protected Class<?>[] getRootConfigClasses() { return new Class[]{RootConfig.class, SecurityConfig.class}; } @Override protected Class<?>[] getServletConfigClasses() { return new Class[]{WebConfig.class}; } @Override protected String[] getServletMappings() { return new String[]{"/"}; } }
为什么书中的例子没有使用这种办法也可以运行妥当呢?秘密就在于它的 RootConfig 类,书中的 RootConfig 类使用了如下的注解:
@ComponentScan(value = "packages", excludeFilters = {@ComponentScan.Filter(type = FilterType.ANNOTATION, value = EnableWebMvc.class)})
这会使得,即使是在 web 包中的 Config 类,也会被 RootConfig 组件扫描到,最终可以进行初始化。
按照 Spring 官方关于 Spring Security 的文档,其实我们还可以在我们的 AbstractSecurityWebApplicationInitializer
子类的构造器中传入配置类,代码如下,但是我尝试后发生了以下错误:
public class SecurityWebInitializer extends AbstractSecurityWebApplicationInitializer { public SecurityWebInitializer() { super(SecurityConfig.class); } }
SEVERE [RMI TCP Connection(32)-127.0.0.1] org.apache.catalina.core.StandardContext.listenerStart Exception sending context initialized event to listener instance of class [org.springframework.web.context.ContextLoaderListener] java.lang.IllegalStateException: Cannot initialize context because there is already a root application context present - check whether you have multiple ContextLoader* definitions in your web.xml!
提示创建了多次 ContextLoaderListener
的 context。
参考:https://stackoverflow.com/questions/23572516/no-bean-named-springsecurityfilterchain-is-defined-error-with-javaconfig?utm_medium=organic&utm_source=google_rich_qa&utm_campaign=google_rich_qa、https://spring.io/blog/2013/07/03/spring-security-java-config-preview-web-security/
Spring Security 莫名其妙的密码验证错误
初次学习使用 Spring Security 的时候感觉 Spring Security 真是挺方便的,但是在配置好之后却出现以下问题:
按照 Spring Security 的模式配置好了数据库,其中账户数据表我使用了自己的 schema,而其它两个表则是按照 Spring 默认的数据库模式。对应的 Java Config 代码如下:
@Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth .jdbcAuthentication() .dataSource(dataSource) .usersByUsernameQuery("SELECT Username, HashedPassword, TRUE FROM Users " + "WHERE Username = ?") .passwordEncoder(new PasswordAuthentication()); }
配置好了数据库,也填入了对应的信息,正打算登录试试看,却发现一直提示密码错误。然而密码却是正确的。
检查了输入的密码和数据库中对应的密码一致,百思不得其解。难道是我的 login 拦截器没配置好吗?尝试修正后发现也不是这里的问题。
最后对相关方法进行断点调试,最初的时候 hash 是一致的,但是后面竟然又不一致了?最终发现是另外一个数据表——authorities 的问题,由于我没有在该表中填入用户的权限信息,Spring 在这个表中查找不到该用户的权限信息,就会出现错误。
解决办法很简单,在用户注册的时候也往 authorities 表中填写用户的权限信息即可。
Repository 的 Proxy 问题
在编写 Hibernate 的 Repository 的时候出了个这样的问题:
首先我要说明一下我编写 Repository 的类结构,我并没有使用 Spring 主推的接口 + 实现方法,而是按照如下结构进行编写:
BaseRepository (abstract class with implementation)- extends -> ConcreteRepository (concrete class with implementation)
在我看来,这样的结构适合我在 BaseRepository
中编写一些公共实现代码,并且能够在 ConcreteRepository
中编写针对各个 Model 的更具体的代码。
在 Controller 中使用时,我直接就使用了实现类进行自动装配:
@AutoWired private ConcreteRepository concreteRepository;
当然这个和使用接口并没有什么冲突,我可以为 ConcreteRepository
创建一个接口,然后再在 Controller 中使用接口来装配。但是 ConcreteRepository
数量繁多,并且是从遗留代码中稍加改造而来的,我不太愿意花太多精力在这个事情上面。
使用的时候就出问题了:
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'whateverController': Unsatisfied dependency expressed through field 'concreteRepository'; nested exception is org.springframework.beans.factory.BeanNotOfRequiredTypeException: Bean named 'concreteRepository' is expected to be of type 'dao.repository.ConcreteRepository' but was actually of type 'com.sun.proxy.$Proxy68'
怪了,哪来的代理啊……
搜索了一下,发现这个问题是因为 Spring 需要对 Repository 创建代理以完成诸如事务处理等工作,但是由于我在上面使用了具体类,因此创建代理后的实际类型就和具体类的类型不符了。
一个简单解决办法当然是为每一个 ConcreteRepository
创建一个接口,这样就不会出现这种问题了。另外一个解决办法是换用 CGLIB 的代理方法(虽然不是很清楚这个代理和 JDK 动态代理具体不一样的地方)。
换用 CGLIB 的方法很简单,只需要在 @Configuration 类中添加如下注解即可:
@EnableAspectJAutoProxy(proxyTargetClass = true)
当然,你还需要引入 cglib 的依赖。
但是加上去后还是老样子啊,按照搜索到的其它方法,尝试添加 @EnableTransactionManagement(proxyTargetClass = true) 也无果。
最后根据某个帖子的解决办法,发现其实问题在于 BeanPostTranslator
上,需要在 Bean 声明中将该项打开才行:
@Bean public BeanPostProcessor persistenceTranslation() { PersistenceExceptionTranslationPostProcessor processor = new PersistenceExceptionTranslationPostProcessor(); processor.setProxyTargetClass(true); return processor; }
设置好后就没问题了。
Hibernate Repository 的事务 Session 问题
我学习 Spring 使用的书籍是《Spring 实战》,按照书里面的步骤,我配置好了 DataSource,SessionFactory 和 Repository。但是却遇到了以下问题:
org.springframework.web.util.NestedServletException: Request processing failed; nested exception is org.springframework.orm.hibernate5.HibernateSystemException: Could not obtain transaction-synchronized Session for current thread; nested exception is org.hibernate.HibernateException: Could not obtain transaction-synchronized Session for current thread
查了一下,需要添加一个事务处理器:
@Bean public PlatformTransactionManager transactionManager(SessionFactory sessionFactory) { HibernateTransactionManager transactionManager = new HibernateTransactionManager(sessionFactory); return transactionManager; }
整合步骤和上述的代码可以从 http://www.baeldung.com/hibernate-5-spring 中得到。
再看了一下,需要在 Repository 中添加 @Transactional
注解。添加后又出现上面的 Proxy 问题,添加 @EnableTransactionManagement(proxyTargetClass = true) 后解决。
完成上述步骤后,还是一样的问题。干脆进行调试,从以下代码开始跟踪:
protected Session getCurrentSession() { return sessionFactory.getCurrentSession(); }
一直跟踪到 SpringSessionContext
的 currentSession()
方法,跟着跟着就发现不太对劲了——怎么这个 context 的 transactionManager
的属性是 null
?明明已经配置了事务处理器了啊?
最后发现是 BaseRepository
中没有加上 @Transactional
注解,加上后就 OK 了。由于该注解可以被继承,因此诸多的 ConcreteRepository
也不需要加这个注解了。
另外在跟踪该方法时,可能会以为和 jtaPlatform
变量有关(显示的是 NoJtaPlatform
),但实际和这个没关系,不要尝试修改 hibernate.transaction.jta.platform
。另外也不要修改 hibernate.current_session_context_class
,保持默认的 SpringSessionContext
即可。
参考资料:https://stackoverflow.com/questions/40084173/spring-hibernate-migration-currentsession-method-throwing-exception、http://blog.csdn.net/qq_18757713/article/details/51250101
Hibernate 检索数据输出 JSON 时导致的延迟加载(Lazy Initialize) 问题
搞定上述问题后可以正常检索了。由于我要开发一个 REST API,因此需要使用 Spring 的消息转换器(Message Converter)将检索的对象转换为 JSON 字符串发送给客户端。
然后就出现以下错误:
org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: cn.edu.gcu.swimminginfsys.model.Competition.competitionItems, could not initialize proxy - no Session
用过 Hibernate 基本都知道这是怎么回事:Hibernate 默认会对对象属性进行懒加载,它使用代理模式在查询实际发生的时候才实际查询属性。但是一旦关联的 Session 被关闭,对象就变为 detach 状态,无法进行懒加载属性的查询。
解决这个问题有好几种办法:
- 放弃懒加载,全部属性一次性加载完
- 使用 Spring 的 OpenSessionInView 模式,在一个请求中保持一个 Session
- 使用 DTO——创建一个类,将检索出的对象的信息填充到这个 DTO 类,起到接力的作用
- 使用
@JsonIgnore
注解让 Jackson 忽略某些属性 - 使用
jackson-datatype-hibernate
库:https://github.com/FasterXML/jackson-datatype-hibernate
列举一下缺点:
- 放弃懒加载的话,将会对所有查询生效,因此会影响数据库查询的效率(API 只是数据库查询的一个应用而已)
- OpenSessionInView 在 StackOverflow 上被人称作反模式?
- 创建 DTO 所需要的类和代码较大
- 忽略懒加载集合只是治标不治本的做法
那就使用 jackson-datatype-hibernate
库吧!但是一番配置后发现这个库只支持基本属性,不支持集合,而且对 Hibernate 5.2 以上的版本不适用。(配置方法:https://stackoverflow.com/questions/21708339/avoid-jackson-serialization-on-non-fetched-lazy-objects/21760361#21760361、https://stackoverflow.com/questions/38273640/spring-hibernate-jackson-hibernate5module)
使用 DTO
既然无法使用 jackson-datatype-hibernate
,那就只能退而求其次,使用 DTO 作为中继来传递数据。可以使用 ModelMapper
库来简化 Entity 和 DTO 之间的映射关联代码,详细见:http://www.baeldung.com/entity-to-and-from-dto-for-a-java-spring-application
按照这样映射之后,还是出现了一样的错误。看样子,得要搞明白两件事情:
- 什么时候
Session
会自动关闭? - 应该怎样在
Session
打开的时候读取完毕所有数据?
Session 与事务管理
一番查阅之后才发现,Spring 的 @Transactional
规定了事务边界,Spring 将会在事务结束的时候关闭 Session
。也就是说,@Transactional
不仅要加在 Repository 上,还需要加到 Controller 里面,以表明事务是从 Controller 方法开始的,这样在从 Repository 返回 Contoller 的处理方法时,不会将 Session 关闭掉。
需要注意的是,需要在对应的 Context 的配置类上加上 @EnableTransactionManagement
。由于 Controller 是在 WebApplicationContext
上注册的,因此要将上面的注解加在 WebApplicationContext
的配置类上。如果加到了错误的配置类上将不起作用。
关于 Spring 的事务管理,这里有一篇文章讲的不错:https://www.ibm.com/developerworks/cn/education/opensource/os-cn-spring-trans/index.html
使 Hibernate 强制读取懒加载属性/集合
如果阅读过 Hibernate 相关资料的话,应该会知道 Hibernate.initialize
可以强制读取懒加载属性或集合。但是给类的每个属性都加上这些手工加载代码显然不是什么好事。所以要使用反射对类的所有成员调用一次Hibernate.initialize
来确保成员都被加载了,这样即使 Session
关闭了也不会抛出异常。
这里有一份使用反射对类成员进行加载的代码,可以直接复制过来使用。但是对于级联的集合,还需要递归加载才行,因此我修改了一下这份代码:
public static <T> T forceLoadObject(T object) { for (Field field : object.getClass().getDeclaredFields()) { if (field.getAnnotation(LazyCollection.class) != null) { field.setAccessible(true); try { Object lazyObject = field.get(object); Hibernate.initialize(lazyObject); if (lazyObject instanceof Collection) { Collection<Object> lazyObjects = (Collection<Object>) lazyObject; for (Object o : lazyObjects) { forceLoadObject(o); } } } catch (IllegalAccessException e) { e.printStackTrace(); } } } return object; }
这样就 OK 了。然后对要强制加载的属性或集合加上 @LazyCollection
注解即可。
使用切面简化 Controller 方法
使用上面的代码已经可以解决 “No Session” 问题了,可以在 Controller 方法中直接返回 Entity,然后再由 Spring 的消息转换器转换为 JSON 了。但是所有返回的 Entity 都需要执行一次上面的 forceLoadObject
方法进行加载才行。既然 Spring 有 AOP 功能,为什么不利用它让 Spring 帮我们执行这种调用呢?
@Aspect public class ModelForceLoadAspect { @Pointcut("execution(public * *(..)) && " + "@annotation(annotation.ForceLoadResult)") public void forceLoadPointcut() { } @AfterReturning(value = "forceLoadPointcut()", returning = "result") public void forceLoadModel(Object result) { HibernateUtil.forceLoadObject(result); } }
ForceLoadResult
是我自己定义的注解,配置好这个切面后,只需要在 Controller 方法中标注 @ForceLoadResult
,然后直接返回 Entity 即可。Spring 会拦截方法返回的结果并且自动帮我们执行 forceLoadObject
方法。
RootConfig 的环境配置问题
Spring 支持以 @Profile 指定在不同环境下使用的 Bean,因此,我们可以这样:
@Bean @Profile("development") public DataSource dataSource() { // ... }
你可以通过几种手段,指定应用上下文的当前 profile,我惯用的一种手段是在 DispatcherServlet
的参数中指定:
@Override protected void customizeRegistration(ServletRegistration.Dynamic registration) { registration.setInitParameter("spring.profiles.default", "development"); registration.setInitParameter("spring.profiles.active", "development"); }
但这只能对 WebApplicationContext
生效,对于由 ContextLoaderListener
创建的上下文来说,需要另外指定:
@Override protected void registerContextLoaderListener(ServletContext servletContext) { servletContext.setInitParameter("spring.profiles.default", "development"); servletContext.setInitParameter("spring.profiles.active", "development"); super.registerContextLoaderListener(servletContext); }
本文采用 CC BY-NC-SA 3.0 协议进行许可,在您遵循此协议的情况下,可以自由共享与演绎本文章。
本文链接:https://blog.codeingboy.me/questions-about-spring-framework/