SpringMVC WebFlux 高级教程(六)
十二、Spring Security
据说信息就是力量。如今,当所有的 web 应用都托管在云中,部署在别人设计的容器中,并且希望得到安全保护和审计时,这种情况就更加真实了。开发人员至少可以保证密码的安全。大多数 web 应用都有任何人都可以看到的公共页面和为经过验证的用户保留的私有页面。例如,书店应用应该有添加、编辑和删除图书条目的页面,这些页面只对具有管理角色的用户可用。在前面的章节中,Spring MVC 被用来构建一个 Spring web 应用。Spring Security 是保护 Spring web 应用的最佳框架。
Spring Security 是一个高度可定制的认证和访问控制框架。这个框架是基于 Acegi security, 1 编写的,当时 Spring 还在襁褓中。它为使用 Spring 框架构建的 Java 企业应用提供了强大而灵活的安全解决方案。Spring Security 为身份验证、授权和防范常见攻击提供了全面的支持。它还提供了与其他库的集成,以简化其使用。
这一章详细介绍了保护 Spring web 应用的不同方法。让我们从几个关键的安全术语和原则开始。
安全基础知识
访问 web 应用并能执行操作的实体被称为主体。委托人使用被称为凭证的标识密钥来标识自己。当访问 web 应用时,计算机的浏览器会创建一个小的信息包并存储在您的系统中。这被称为 cookie *。2cookie 可以存储个性化信息(如语言和主题),以便定制您的下次访问。在项目中搜索本书的CookieResolver。这是一个典型的 Spring bean 类型,旨在创建一个名为org.springframework.web.servlet.i18n.CookieLocaleResolver.LOCALE的 cookie 来存储您的首选语言环境。这里提到的 cookie 名称是 Spring 设置的默认名称,但是可以在为CookieResolver bean 配置时进行定制。这些 cookies 被称为持久型,即使你关闭了浏览器,它们仍然在运行。它们作为文件存储在浏览器的一个子文件夹中,直到您手动删除它们,或者您的浏览器根据永久 cookie 文件中包含的过期时间删除它们。一些 web 应用使用这种类型的 cookies 来存储您的凭据,因此您不必在每次使用网站时都键入它们。
非永久性的 cookie 被称为会话 cookie,它们的生命周期由 HTTP 用户会话的生命周期决定。HTTP 用户会话在您登录 web 应用时开始,在您注销时结束。有些应用还会在页面关闭时结束用户的会话。存储在会话 cookie 中的信息在站点的页面之间共享。购物网站使用这种类型的 cookies 来存储您添加到虚拟购物车中的产品。
当用户登录时,服务器会在您的浏览器中设置一个临时 cookie,以记住您当前已登录,并且您的身份已得到确认。会话 cookies 也适用于存储凭证,因为它们缩短的生命周期也缩短了它们被劫持的时间间隔。 3
确认身份的过程称为认证,包括对照服务器数据库检查用户提供的凭证。最基本的身份验证类型需要用户 id 和密码,它依赖于单一的身份验证因素。它是一种的单因素认证。用户通过认证后,通常会经历一个授权的过程。需要这个过程来确定用户应该能够访问的应用部分。例如,书店应用的普通用户不应该被允许编辑或删除书籍。用户可以访问的应用部分通常由角色描述。
Spring Security 框架提供了独立配置认证和授权的可能性。由于是松散耦合的,其中一个或两个都可以被这些服务的外部提供者替换。Spring Framework 还支持 web 请求级别、服务方法和单个域对象上的授权。有了如此多的可能性,难怪 Spring Security 几乎成了保护 Spring 应用的默认选择。
最低限度的 Spring Web 安全性
Spring MVC 是一个非常强大和通用的框架,通过实现org.springframework.web.servlet.HandlerInterceptor和在 HTTP 用户会话中存储凭证,可以实现最小的限制。
可以在 Spring MVC 配置中注册org.springframework.web.servlet.HandlerInterceptor的定制实现,以便在org.springframework.web.servlet.HandlerAdapter调用处理程序之前和之后执行代码。这意味着HandlerInterceptor中的代码会阻止处理程序的正常执行。因此,使用HandlerInterceptor实现最低限度的安全性是可能的。当用户登录时,其信息和凭证存储在 HTTP 会话中。可以编写一个与清单 12-1 非常相似的HandlerInterceptor实现来寻找这个属性。
package com.apress.prospringmvc.bookstore.web.interceptor;
import org.springframework.web.servlet.HandlerInterceptor;
// Other imports omitted
public class SecurityHandlerInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
var account = (Account) WebUtils.getSessionAttribute(request, "ACCOUNT_ATTRIBUTE");
if (account == null) {
//Retrieve and store the original URL.
var url = request.getRequestURL().toString();
WebUtils.setSessionAttribute(request, "REQUESTED_URL", url);
throw new AuthenticationException("Authentication required.", "authentication.required");
}
return true;
}
}
Listing 12-1SecurityHandlerInterceptor to Control Access to Pages Requiring Authentication
当用户试图访问需要认证的页面时,SecurityHandlerInterceptor会设置REQUESTED_URL属性。这确保了在成功认证后,用户被重定向到他在认证前试图访问的页面。这种配置对于登录过程很重要,因为登录页面必须从这种行为中排除,这就是清单 12-2 中的最后一个if语句在handleLogin(..)方法中负责的事情。
package com.apress.prospringmvc.bookstore.web.controller;
// Other imports omitted
@Controller
@RequestMapping(value = "/login")
public class LoginController {
public static final String ACCOUNT_ATTRIBUTE = "account";
public static final String REQUESTED_URL = "REQUESTED_URL";
@Autowired
private AccountService accountService;
@RequestMapping(method = RequestMethod.GET)
public void login() {
}
@RequestMapping(method = RequestMethod.POST)
public String handleLogin(@RequestParam String username, @RequestParam String password, HttpSession session)
throws AuthenticationException {
var account = this.accountService.login(username, password);
session.setAttribute(ACCOUNT_ATTRIBUTE, account);
var url = (String) session.getAttribute(REQUESTED_URL);
// Remove the attribute
session.removeAttribute(REQUESTED_URL);
// Prevent loops for the login page.
if (StringUtils.hasText(url) && !url.contains("login")) {
return "redirect:" + url;
} else {
return "redirect:/index.htm";
}
}
}
Listing 12-2The LoginController Code
Snippet That Ensure No Endless Loop Is Caused When User Logs In
由于处理程序方法有多种多样的签名,当访问这些 URL 中的任何一个时,可以使用一个HttpSession object作为参数,并且可以从中提取并使用Account实例。
必须将一个SecurityHandlerInterceptor实例添加到为 Spring 应用配置的拦截器的注册表中。这是通过确保 web 配置类中被覆盖的addInterceptors(..)方法包含对它的引用来实现的。清单 12-3 描述了这个配置片段。
package com.apress.prospringmvc.bookstore.web.config;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
// Other imports omitted
@Configuration
@EnableWebMvc
@ComponentScan(basePackages = { "com.apress.prospringmvc.bookstore" })
public class WebMvcContextConfiguration implements WebMvcConfigurer {
// code omitted
@Override
public void addInterceptors(InterceptorRegistry registry) {
// other interceptors omitted
registry.addInterceptor(new SecurityHandlerInterceptor())
.addPathPatterns("/customer/account", "/cart/checkout");
}
}
Listing 12-3Adding a SecurityHandlerInterceptor Instance
to the List of Interceptors
这是在 Spring 应用中保护对几个页面的访问的最简单的方法。对于为教学目的构建的简单应用,这很好,因为它清楚地表明了 Spring 是多么强大,但是在实际的生产应用中很少使用这样的实现。此外,还需要编写更多的代码来支持身份验证过程。支持授权需要编写更多的代码,这就是 Spring Security 存在的原因。
使用 Spring Security 性
Spring Security 最大的一个优势就是可移植性。它不需要一个特殊的容器来运行。可以通过向应用添加所需的依赖项并配置一些 beans 来设置 Spring Security 性。另一个很大的优势是它的可扩展性:开发人员可以决定如何定义主体、凭证存储在哪里、以什么格式存储、如何做出授权决定等等。此外,由于保护资源是使用代理机制完成的,Spring Security 使得将安全逻辑从应用逻辑中分离出来变得容易,避免了代码混乱和分散。
当为 Spring web 应用配置了 Spring Security 时,在到达DispatcherServlet,之前,请求会通过安全过滤器链进行过滤。这些过滤器都是javax.servlet.Filter接口的实现。过滤器的顺序很重要,任何过滤器都可以修改请求,然后调用链中的下一个过滤器。Spring 的ApplicationContext通过其核心实现Filter : org.springframework.web.filter.DelegatingFilterProxy与 Servlet 容器的生命周期集成在一起。
通过标准的 servlet 容器机制注册它,所有的工作都可以委托给实现Filter的 Spring bean。
当在应用中配置了 Spring Security 时,DelegatingFilterProxy将过滤请求的工作委托给一个名为springSecurityFilterChain的org.springframework.security.web.FilterChainProxy类型的特殊 bean。这个 bean 允许使用它的org.springframework.security.web.SecurityFilterChain实例列表委托给许多Filter实例。每个SecurityFilterChain匹配一个 URL 片配置的应用的一部分。所有 Spring Security Filter实现的完整列表及其确切顺序可以在官方参考文档中找到。 4
因此,总的来说,这个过滤器链提供了对身份验证的支持,确保授权,在 HTTP 会话中维护org.springframework.security.core.context.SecurityContext,并在注销时管理用户会话的有效结束。如果您还记得,ApplicationContext是为应用提供配置的中央界面。同样,SecurityContext是为应用提供安全配置的中央界面。
表 12-1 按照它们在链中出现的顺序列出了最重要的网络过滤器,并简单解释了它们的职责。
表 12-1
最重要的安全过滤器springSecurityFilterChain委托给
过滤器
|
描述
|
| --- | --- |
| org.springframework.security.web.access.channel.ChannelProcessingFilter | 为使用适当的通道传递请求提供支持。安全请求通过安全通道传递。 |
| org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter | 提供异步安全请求处理支持。 |
| org.springframework.security.web.header.HeaderWriterFilter | 支持在响应中添加安全头,如X-Frame-Options、X-XSS-Protection,和X-Content-Type-Options。 |
| org.springframework.security.web.context.SecurityContextPersistenceFilter | 用特定于用户会话的信息填充SecurityContextHolder,并在请求之间维护SecurityContext。 |
| org.springframework.security.web.csrf.CsrfFilter | 为包含 CSRF 令牌的请求提供支持。 5 |
| org.springframework.security.web.authentication.logout.LogoutFilter | 通过调用一组处理程序从安全上下文中清除Authentication对象来结束用户会话。在其执行结束时,用户不再被验证;因此,它无法访问安全页面。 |
| org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter | 处理身份验证表单提交。登录表单为此过滤器提供了两个参数:用户名和密码。 |
| org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter | 如果应用不需要登录页面,这个过滤器可以为您生成一个。 |
| org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter | 如果应用不需要注销页面,这个过滤器可以为您生成一个。大多数应用并不真的需要注销页面。 |
| org.springframework.security.web.authentication. www.BasicAuthenticationFilter | 为基本身份验证提供支持。 6 这种类型的认证涉及到具有带 Base-64 编码值username:password的Authorization报头的请求。由于身份验证令牌是以明文形式传输的,因此请使用摘要式身份验证而不是基本身份验证。 |
| org.springframework.security.web.authentication. www.DigestAuthenticationFilter | 为摘要式身份验证提供支持。 7 查看前一个表格行。 |
| org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter | SecurityContextHolder是春安的核心组件。该过滤器提供了实现安全 API 方法的请求包装器。调用这些方法就是用配置、认证和授权信息填充SecurityContextHolder,从而配置应用的安全上下文。 |
| org.springframework.security.web.authentication.AnonymousAuthenticationFilter | 为匿名用户提供支持。 |
| org.springframework.security.web.session.SessionManagementFilter | 为各种与 HTTP 会话相关的功能提供支持,如会话固定保护攻击防御。 8 |
| org.springframework.security.web.access.ExceptionTranslationFilter | 为链中引发的安全异常提供支持。它表示 Java 异常和 HTTP 状态代码之间的链接,需要这些代码来维护用户界面并向用户显示适当的消息。 |
| org.springframework.security.web.access.intercept.FilterSecurityInterceptor | 为授权 HTTP 请求提供支持,并引发AccessDeniedException s。 |
| org.springframework.security.web.authentication.switchuser.SwitchUserFilter | 为用户上下文切换提供支持。具有较高权限的用户可以切换到较低权限的用户。 |
图 12-1 展示了在 web 应用中配置 Spring Security 性时请求处理工作流的高级概述。
图 12-1
Spring Security 的认证和授权过程
图 12-1 描述了 Spring web 应用如何通过 Spring Security 来处理请求。
用户试图访问应用的安全页面。由于页面是安全的,应用将用户重定向到登录页面。用户提供其用户名和密码,并提交登录请求。
登录请求被过滤,并且springSecurityFilterChain和处理认证的合适过滤器被识别。在这种情况下,它就是UsernamePasswordAuthenticationFilter。这个过滤器使用AuthenticationManager来验证凭证。最常用的AuthenticationManager实现是ProviderManager,,它声明了可用于支持认证过程的已配置AuthenticationProvider实例列表。
如果凭证得到验证,就会创建一个Authentication实例,并存储在应用的security context中。SecurityContext包含在SecurityContextHolder,中,?? 是 Spring Security 的核心。
配置身份验证
Authentication实例包含以下信息。
-
用于识别用户的主体。在基本的用户/密码认证中,它通常是一个
UserDetails实例。 -
凭证包含密码。为了避免密码被泄露,通常会在用户通过身份验证后清除密码。
-
权限代表授予用户的应用权限,也称为角色或范围。
一旦用户通过身份验证,返回的响应要么是为登录过程配置的默认页面,要么是用户在通过身份验证之前试图访问的页面(如果用户有权访问该页面)。包含在Authentication对象中的权限由AccessDecisionManager使用投票者列表进行分析,以决定用户是否可以访问所请求的资源。
Spring Security 提供了一种非常实用的方式来配置和定制大多数进程。在 Spring MVC 应用中,配置 Spring Security 配置需要三样东西:
-
将
springSecurityFilterChain与容器 servlet 环境集成。 -
为认证和授权提供安全配置,它定义了应用的
securityContext。 -
将安全上下文与 web 应用上下文集成。
为了将springSecurityFilterChain与容器 servlet 环境集成,必须在配置中添加一个扩展org.springframework.security.web.context.AbstractSecurityWebApplicationInitializer的类。清单 12-4 描述了这样一个类。AbstractSecurityWebApplicationInitializer的子类注册DelegatingFilterProxy以在任何其他注册filter.之前使用springSecurityFilterChain这是必要的,因为应用上的每个请求都必须被拦截并分析权限。
package com.apress.prospringmvc.bookstore.web.config.sec;
import org.springframework.security.web.context.
AbstractSecurityWebApplicationInitializer;
class SecurityInitializer extends AbstractSecurityWebApplicationInitializer {
}
Listing 12-4Subclass of AbstractSecurityWebApplicationInitializer
必须提供用@EnableWebSecurity标注的配置类来配置securityContext。为了帮助开发人员轻松创建这个类,Spring Security 提供了org.springframework.security.config.annotation.SecurityConfigurer接口和多个实现,这些实现已经包含了开箱即用的默认实现,因此所需的定制是最少的。
为 web 应用配置 Spring Security 性最简单的方法是扩展WebSecurityConfigurerAdapter并为configure(AuthenticationManagerBuilder auth)方法提供一个实现来配置AuthenticationManagerBuilder。Spring Security 使用这个构建器创建一个对用户进行认证的org.springframework.security.authentication.AuthenticationManager。
Spring 支持多种认证机制: 9 Basic、Form、OAuth、X.509、SAML,但是对于本章,重点在于使用用户名和密码设置表单认证。
对于简单的小型应用,内存数据库足以存储用户名和凭证。即使数据库在内存中,密码也不应该以明文存储,这就是为什么 Spring Security PasswordEncoder实现之一应该对密码应用散列函数。在清单 12-5 中,org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder在将密码文本存储到数据库之前对其应用 BCrypt 强散列函数。
出于测试目的,Spring Security 仍然支持org.springframework.security.crypto.password.NoOpPasswordEncoder,这是一个不做任何事情并以明文形式保存密码的实现。但是,该类被标记为 deprecated,因此将来可能会被移除。
package com.apress.prospringmvc.bookstore.web.config.sec;
import org.springframework.security.config.annotation.web.configuration.
EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.
WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.authentication.
builders.AuthenticationManagerBuilder;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import com.apress.prospringmvc.bookstore.util.ConfigurationException;
// Other imports omitted
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) {
try {
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
auth.inMemoryAuthentication()
.passwordEncoder(passwordEncoder)
.withUser("john")
.password(passwordEncoder.encode("doe"))
.roles("USER")
.and().withUser("jane")
.password(passwordEncoder.encode("doe"))
.roles("USER", "ADMIN")
.and().withUser("admin")
.password(passwordEncoder.encode("admin"))
.roles("ADMIN");
} catch (Exception e) {
throw new ConfigurationException(
"In-Memory authentication was not configured.", e);
}
}
// code omitted
}
Listing 12-5Configuration of an AuthenticationManager by Extending WebSecurityConfigurerAdapter
每个用户在应用中都被分配了一个角色。角色封装了用户可以在应用中执行的操作。大多数应用至少有两个角色。对于书店应用,ADMIN角色被分配给可以添加、编辑和删除图书条目的用户。可以查看和订购书籍的用户拥有USER角色。
一个GrantedAuthority实例代表授予一个Authentication对象的权限。角色和权限之间没有显著的区别,除了语义和它们的使用方式。角色存储在配置了 Spring Security 的安全位置,通常是数据库。当声明一个内存数据库时,如清单 12-5 所示,可以使用roles(..)方法。这个方法由 Spring Security 提供的一个名为UserDetailsManagerConfigurer(位于一个包中,这个包的名字很长,与此无关)的构建器类提供,用来创建UserDetails实例,封装允许访问应用的用户的认证数据。角色不能为空,角色名不能以ROLE_开头,因为roles(..)方法是UserDetailsManagerConfigurer类中声明的authorities(..)方法的快捷方式,在角色名前面加上ROLE_。因此,清单 12-5 中的代码也可以按照清单 12-6 中的方式编写。有异曲同工之妙。
package com.apress.prospringmvc.bookstore.web.config.sec;
// Imports omitted (view Listing 12-3)
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) {
try {
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
auth.inMemoryAuthentication()
.passwordEncoder(passwordEncoder)
.withUser("john")
.password(passwordEncoder.encode("doe"))
.authorities("ROLE_USER")
.and().withUser("jane")
.password(passwordEncoder.encode("doe"))
.authorities("ROLE_USER", "ROLE_ADMIN")
.and().withUser("admin")
.password(passwordEncoder.encode("admin"))
.authorities("ROLE_ADMIN");
} catch (Exception e) {
throw new ConfigurationException(
"In-Memory authentication was not configured.", e);
}
}
// code omitted
}
Listing 12-6Configuration of an AuthenticationManager by Setting Up Authorities
角色或权限用在授权过程中,这将在本章后面解释。
当 H2 内存数据库用于存储凭据时,可以通过访问数据库的 H2 web 客户端来检查其内容。要将此 web 客户端添加到您的应用,您必须将org.h2.server.web.WebServlet添加到应用,并将其映射到一个安全的 URL。即使密码被加密,保护对数据库的访问仍然是一件必须做的事情。这将在本章后面解释。
auth.inMemoryAuthentication()行向AuthenticationManagerBuilder添加了内存认证。withUser(String username)方法构建一个包含用户核心信息的org.springframework.security.core.userdetails.UserDetails实例。出于安全目的,Spring Security 不使用这些实例。它们保存着加载到Authentication对象中的信息。UserDetails实例由UserDetailsService管理.开发人员可以提供自己的实现来声明存储和检索认证信息的不同方式。
Spring Security 提供了基于 JDBC(数据库)的UserDetailsService。这种方法的限制是数据库必须具有 Spring Security 所要求的结构, 10 并且必须是 SQL 数据库。但是由于书店应用有自己的保存用户信息的数据库表——ACCOUNT表,所以使用该表进行认证也更合适。有两种方法可以做到这一点。第一个是实现一个定制的UserDetailsService,它从ACCOUNT表中加载用户详细信息。另一种方式是实现定制的AuthenticationProvider。
对于大多数应用来说,这种方法是首选的,因为身份验证提供者也可以从外部提供者(例如,像 Google 这样的单点登录提供者这样的外部系统)检索身份验证信息。这允许身份验证过程的外部化。
清单 12-7 描述了在SecurityConfiguration类中设置自定义authentication provider认证所需的变化。
package com.apress.prospringmvc.bookstore.web.config.sec;
import org.springframework.security.authentication.AuthenticationProvider;
// Other imports omitted
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Autowired
public void configure(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(bookstoreAuthenticationProvider());
}
@Bean
public AuthenticationProvider bookstoreAuthenticationProvider(){
return new BookstoreAuthenticationProvider();
}
// code omitted
}
Listing 12-7Custom Implementation of AuthenticationManagerBuilder Using a Database Table
bookstoreAuthenticationProvider bean 类型是BookstoreAuthenticationProvider,,它实现了AuthenticationProvider。AuthenticationProvider是一个简单的接口,有两个方法。清单 12-8 描述了这个接口的代码。 11
package org.springframework.security.authentication;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
// some comments omitted
public interface AuthenticationProvider {
/**
* @param authentication the authentication request object.
* @return a fully authenticated object including credentials.
* @throws AuthenticationException if authentication fails.
*/
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
/**
* @param authentication
* @return <code>true</code> if the implementation can more closely evaluate the
* <code>Authentication</code> class presented
*/
boolean supports(Class<?> authentication);
}
Listing 12-8AuthenticationProvider Interface Code
authenticate(..)方法处理认证请求。在BookstoreAuthenticationProvider实现中,代码从ACCOUNT表中提取用户,验证密码,并创建Authentication对象。
supports(..)方法声明了支持的Authentication实现的类型。由于使用了使用用户名和密码的简单认证,声明支持的类型是UsernamePasswordAuthenticationToken。
清单 12-9 描述了BookstoreAuthenticationProvider的完整代码。
package com.apress.prospringmvc.bookstore.web.config.sec;
import com.apress.prospringmvc.bookstore.domain.Account;
import com.apress.prospringmvc.bookstore.service.AccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
// Other imports omitted
public class BookstoreAuthenticationProvider implements AuthenticationProvider {
private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
@Autowired
private AccountService accountService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
Account account = accountService.getAccount(username);
if(account == null) {
throw new BadCredentialsException(
"Authentication failed for " + username);
}
if(!passwordEncoder.matches(password, account.getPassword())) {
throw new BadCredentialsException(
"Authentication failed for " + username);
}
List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
account.getRoles().forEach(role ->
grantedAuthorities.add(new SimpleGrantedAuthority(role.getRole())));
return new
UsernamePasswordAuthenticationToken(
username, password, grantedAuthorities);
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(
UsernamePasswordAuthenticationToken.class);
}
}
Listing 12-9BookstoreAuthenticationProvider Code
配置授权
需要在安全配置类中实现的另一个重要方法是configure(HttpSecurity security)。这个方法定制了 Spring Security 用来为 HTTP 请求启用 web 安全性的org.springframework.security.config.annotation.web.builders.HttpSecurity实例。
HttpSecurity安全对象是通用的,可以配置规则来拦截某些 URL,配置登录和注销表单生成,在成功或失败的身份验证请求后重定向,CSRF 保护,等等。决定用户可以发出哪些请求是基于它的权限,这就是授权的意义所在。
默认情况下,Spring Security 要求对所有请求进行身份验证。但是 Spring Security 可以通过按照优先级添加更多的规则来配置不同的规则。列出的截取的 URL 的顺序很重要。模式按照定义的顺序进行评估。这意味着,更具体的模式比不太具体的模式在列表中定义得更高。您可以为需要某种程度安全性的 URL 定义模式,并以一个模式结束列表,该模式允许访问与列表中先前条目不匹配的任何内容。或者,您可以为首先需要公共访问的 URL 定义模式,并以禁止访问与列表中先前条目不匹配的任何内容的模式结束列表。这取决于应用的目的。
清单 12-10 描述了一个简单的 Spring Security 授权,它允许未经认证的用户访问书店应用的所有页面,除了具有ADMIN角色的经过认证的用户可以访问的页面。
还有一个额外的configure(WebSecurity web)方法。这个方法接受一个类型为WebSecurity的参数,并告诉 Spring Security 忽略静态 web 请求(比如*。css,*js,图片等。).没有理由让这些请求通过安全过滤器,因为这只会减慢视图的呈现过程。
package com.apress.prospringmvc.bookstore.web.config.sec;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
// Other imports omitted
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/customer/account", "/cart/checkout").authenticated()
.antMatchers("/book/edit", "/book/delete").hasRole("ADMIN")
.antMatchers("/customer/delete").hasRole("ADMIN")
.antMatchers("/**").permitAll();
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/resources/**",img/**", "/styles/**");
}
// some code omitted
}
Listing 12-10Spring Security Authorization Configuration for Intercepting URLs Using AntPathRequestMatchers
使用清单 12-10 中的配置片段,一个未经认证的用户可以看到图 12-2 中描述的书店应用视图。
图 12-2
非验证用户的书店应用视图
authorizeRequests()返回一个HttpSecurity的实例,该实例被配置为允许使用RequestMatcher实现来限制对请求的访问。清单 12-10 描述了使用AntPathRequestMatcher实例来声明一个 URL 模板,请求 URL 应该与该模板匹配。这个类的名字清楚地表明支持 ant 风格的模式。对于每个匹配用antMatchers(..)方法声明的模式的请求 URL,应用与返回的匹配器实例相关联的限制。
只有经过身份验证的用户才能访问他们自己的帐户和结帐页面,所以下一行
.antMatchers("/customer/account", "/cart/checkout").authenticated()
只允许经过身份验证的用户根据资源的 URL 访问这些资源。
下面一行声明,只有经过身份验证的用户拥有ADMIN角色时,才应该处理带有/book/edit和/book/deleteURL 的请求。
.antMatchers("/book/edit", "/book/delete").hasRole("ADMIN")
如果没有该角色的用户试图访问这些 URL 中的一个,则会引发异常,并且用户会被重定向到错误页面。
匹配器的顺序很重要。匹配 URL 的第一个声明的模板决定应用于请求的访问规则。
这就是为什么这个配置行.antMatchers("/**").permitAll()排在最后。这表明所有剩余的请求都将被解析,而不管用户是否经过身份验证。这些也被称为公共资源。
在生产应用中通常不会这样做,在生产应用中最好使用.antMatchers("/**").denyAll()来避免暴露任何不应该暴露的内容。
在 Security 4.1.1 中,引入了MvcRequestMatcher实现。它通过支持声明模板的扩展和添加对变量的支持,扩展了对 ant 样式模式的支持。
.antMatchers("/customer/account")声明请求 URL 与“/客户/账户”字符串的精确匹配。
.mvcMatchers("/customer/account")声明更广泛的匹配。URL“/customer/account/”、“/customer/account.pdf”和“/customer/account.html”被视为与此模板匹配。
此外,正如您可能已经注意到的,在
antMatchers和mvcMatchers中声明 URL 模板时支持通配符。
当 URL 模式以“*”结尾时,该模式匹配前缀加一个单个术语(例如,/h2-console/*匹配/h2-console/a和/h2-console/20)。
当以**结束一个 URL 模式时,该模式匹配前缀,以及任意数量的术语,整个目录树。所以,/h2-console/**匹配/h2-console/a,/h2-console/20,/h2-console/a/20,/h2-console/1/2/3/4,还有很多。
可以说,MvcRequestMatchers提供了更多的安全性,因为它们包含了使用相同模板的更广泛的 URL 集,并且避免了错误地公开内容。
还有一个参数化版本的authorizeRequests (Cutomizer )可以配置认证。使用 lambda,我们可以创建并配置一个专用于声明 URL 拦截规则的 org.springframework.security.config.Customizer<T>实例,该实例用作它的参数。这提供了更具可读性的配置。前面的配置可以进一步简化,因为所有包含 edit 和 delete 的 URL 都是为具有ADMIN角色的用户保留的,所以我们可以使用一个更通用的配置。
清单 12-10 中的配置因此可以升级到清单 12-11 中描述的配置。
package com.apress.prospringmvc.bookstore.web.config.sec;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.security.web.util.matcher.MvcRequestMatcher;
// Other imports omitted
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(
authorize -> authorize.mvcMatchers("/*/edit/*", "/*/delete/*").hasRole("ADMIN")
.mvcMatchers("/customer/account", "/cart/checkout").authenticated()
.anyRequest().permitAll()
);
}
// some code omitted
}
Listing 12-11Spring Security authorization Configuration for Intercepting URLs Using MvcRequestMatchers
与匹配器相关的规则也是通用的。从 Spring Security 版本开始,Spring EL 表达式可以用作授权机制。像下面这样的构造是正确的,它将对/h2-console URL 的访问限制为具有ADMIN和DBADMIN角色的用户。
.mvcMatchers("/h2-console").access("hasRole('ADMIN') and hasRole('DBADMIN')")
前一个配置并不等同于下一个配置,后者将对/h2-console URL 的访问限制为ADMIN或DBADMIN角色的用户。
.mvcMatchers("/h2-console").hasAnyRole("ADMIN", "DBADMIN")
使用hasRole(..)方法可以限制一个角色。
.mvcMatchers("/h2-console").hasAnyRole("DBADMIN")
安全表达式在 Spring Security 标签 12 中得到支持,它们在根据角色动态地重新调整返回给用户的视图时非常有用。在 JSP 模板中使用 Spring Security 标记需要在 JSP 页面中声明以下标记库。
<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %>
例如,Logout菜单项不应该是视图的一部分,除非用户已经过身份验证。注销表单也不应该是视图的一部分。通过将它们封装到一个<sec:authorize access="isAuthenticated()"> .. </sec:authorize>元素中,可以对它们进行调整。
还有像Register和Login这样的菜单项,只有在没有认证用户的情况下才应该呈现。这可以通过将特定的 HTML 元素封装在类似的结构中,但是使用表达式的否定来实现。
使用清单 12-12 中描述的结构可以很容易地做到这一点。
<!-- other taglibs declarations omitted -->
<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %>
<div class="header">
<!-- other JSP elements omitted -->
<div class="nav">
<ul style="float: left;">
<li class="selected">
<a href="${homeUrl}">
<spring:message code="nav.home"/>
</a>
</li>
<!-- other unsecured JSP elements omitted -->
<sec:authorize access="!isAuthenticated()">
<li>
<a href="<c:url value="/customer/register"/>">
<spring:message code="nav.register"/>
</a>
</li>
<li>
<a href="<c:url value="/login"/>">
<spring:message code="nav.login"/>
</a>
</li>
</sec:authorize>
<sec:authorize access="hasRole('ADMIN')">
<li>
<a href="<c:url value="/customer/list"/>">
<spring:message code="nav.admin"/>
</a>
</li>
</sec:authorize>
<sec:authorize access="isAuthenticated()">
<li>
<a href="#" onclick="document.getElementById('logout').submit();">
<spring:message code="nav.logout"/>
</a>
</li>
<spring:url value="/logout" var="logoutUrl"/>
<form action="${logoutUrl}" id="logout" method="post">
<input type="hidden" name="${_csrf.parameterName}"
value="${_csrf.token}"/>
</form>
</sec:authorize>
</ul>
<ul style="float: right;">
<sec:authorize access="isAuthenticated()">
<li>(<em><sec:authentication property="principal" /></em>)</li>
</sec:authorize>
<!-- other unsecured JSP elements omitted -->
</ul>
</div>
</div>
Listing 12-12header.jsp Navigation Menu with Security Configurations
Spring Security 标签库还提供了访问认证数据的可能性。在清单 12-12 中,最后一个元素由经过认证的用户名填充。
此外,如果您不喜欢在视图模板中使用安全表达式,请在安全配置中为 URL 配置规则,并使用对 URL 的引用。与其写下面的内容,
<sec:authorize access="hasRole('ADMIN')">
<a href="<c:url value="/book/edit/${book.id}"/>">
<spring:message code="label.edit"/>
你可以写这个。
<sec:authorize url="/*/edit/*">
<a href="<c:url value="/book/edit/${book.id}"/>">
<spring:message code="label.edit"/>
在本章开始时,您已经了解到 Spring Security 提供了保护单个对象的方法。这很容易实现,因为安全表达式非常强大,可以访问 beans 和路径变量。因此,假设我们的书店有一本专门为 VIP 用户保留的书,可以配置一个 bean 来限制只有他们才能访问这本书。
http
.authorizeRequests(
authorize -> authorize.mvcMatchers("/book/{bookId}")
.access("bookSecurity.checkVIP(authentication,#bookId)")
...
);
access(String)方法将表示 SpEL 表达式的文本作为参数,该表达式定制类型为ExpressionUrlAuthorizationConfigurer,的安全基础设施 bean,该 bean 将基于 SpEL 表达式的基于 URL 的授权添加到应用中。
Spring Security 提供了多种方法来保护对资源的访问,但是建议注意一些细节,因为很容易暴露太多或太少。
清单 12-12 展示了 Spring Security 标签库如何使用 Apache Tiles 动态生成考虑安全设置的视图。这个标记库可以和其他基于 JSP 的模板引擎一起使用。
为了与 Spring 完全集成而设计,百里香提供了一个包含 Spring Security 方言的扩展库。Spring Security 集成模块是 Spring Security 标签库的替代品。它确实有优点,其中之一是没有 CSRF 隐藏元素可以显式地添加到表单中。CSRF 将在本章稍后讨论,但是现在,请看清单 12-13 ,它是清单 12-12 的百里香版本。
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<!-- other HTML elements omitted -->
<div class="header">
<!-- other unsecured HTML elements omitted -->
<div class="nav">
<ul style="float: left;">
<!-- other HTML elements omitted -->
<li sec:authorize="! isAuthenticated()">
<a th:href="@{/customer/register}"
th:text="#{nav.register}">REGISTER</a>
</li>
<li sec:authorize="! isAuthenticated()">
<a th:href="@{/login}"
th:text="#{nav.login}">LOGIN</a>
</li>
<li sec:authorize="isAuthenticated()">
<a th:href="@{/logout}"
th:text="#{nav.logout}">LOGOUT</a>
</li>
</ul>
<ul style="float: right;">
<li sec:authorize="isAuthenticated()">
(<em sec:authentication="name"></em>)
</li>
<!-- other unsecured HTML elements omitted -->
</ul>
</div>
</div>
Listing 12-13Thymeleaf layout.html Navigation Menu with Security Configurations
语法稍微好一点,不是吗?
配置登录和注销
为了支持简单的开发,Spring Security 自带了默认的登录表单和注销支持。当后端开发人员不想浪费时间设置一个 HTML 页面来测试他的安全设置时,这很有用。一个HttpSecurity对象中的大多数方法返回一个对它们自身的引用。这提供了通过将方法链接在一起来配置安全性的机会。类似于authorizeRequests(..),登录表单,注销支持可以使用特定于每个目的的Customizer实例来配置。清单 12-14 给出了一个非常简单的配置默认表单生成和注销支持的例子。
package com.apress.prospringmvc.bookstore.web.config.sec;
import org.springframework.security.config.Customizer;
// Other imports omitted
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(
authorize -> authorize.mvcMatchers("/*/edit/*", "/*/delete/*")
.hasRole("ADMIN")
.anyRequest().permitAll()
).formLogin(Customizer.withDefaults())
.logout(Customizer.withDefaults());
}
// other code omitted
}
Listing 12-14Default Form Generation and Logout Support Configuration Using Spring Security
当书店应用以这种配置启动时,单击Login菜单项会打开一个页面,其中有一个非常简单的登录表单。图 12-3 是 HTML 生成的登录页面截图。
图 12-3
Spring Security 生成的注销页面
如果您使用“查看页面源代码”浏览器选项,您还可以查看 HTML 代码。以下是您应该了解/注意的关于此页面的事项列表。
-
生成的登录视图被映射到
/login。这可以通过调用带有定制实现Customizer的formLogin(..)来改变,以构建带有不同登录页面映射的FormLoginConfigurer实例。 -
生成的表单有两个名为
username和password的字段。这些是默认值。可以通过调用定制实现Customizer的formLogin(..)方法来改变它们,为username和password字段构建一个不同名称的FormLoginConfigurer实例。 -
该表单包含一个名为“CSRF”的隐藏字段。在 Spring Security 4.3 中,CSRF 保护配置成为防止跨站点请求伪造攻击的默认选项。 13 对于测试场景可以将其禁用。
-
提交用户名和密码值的 POST 请求被发送到
/login,这也是一个默认值。这可以通过使用带有不同登录处理 URL 的FormLoginConfigurer实例来定制。 -
登录后,用户被自动重定向到
/。这可以通过用不同的默认成功 URL 定制一个FormLoginConfigurer实例来改变。 -
如果用户无法通过身份验证,则失败 URL 的默认值是
/login?error,,可以通过使用不同的默认身份验证失败 URL 定制一个FormLoginConfigurer实例来更改该值。 -
至于注销支持,它也有自己的默认值。
-
注销用户的 POST 请求被映射到
/logout。这个默认值可以通过调用带有自定义实现Customizer的logout(..)方法来更改,以构建带有不同注销 URL 的LogoutConfigurer。 -
当用户成功注销后,会被重定向到
/login?logout。这个值可以通过使用不同的成功注销 URL 定制一个LogoutConfigurer实例来更改。
两种配置器类型(FormLoginConfigurer and LogoutConfigurer)上有更多的方法,允许为登录和注销配置失败或成功的身份验证处理程序、使会话无效的特殊选项、为注销清除缓存,以及更多您可能会在需要时读到的内容。在此之前,请看清单 12-15 中的代码示例。Spring Security 配置已经过修改,可以定制所有提到的属性,并增加了对 CSRF 保护的支持。
package com.apress.prospringmvc.bookstore.web.config.sec;
import org.springframework.security.config.Customizer;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.security.config.annotation.web.configurers.
FormLoginConfigurer;
import org.springframework.security.config.annotation.web.configurers.
LogoutConfigurer;
// Other imports omitted
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(
authorize -> authorize.mvcMatchers("/*/edit/*", "/*/delete/*")
.hasRole("ADMIN")
.anyRequest().permitAll()
).formLogin(
formLogin -> formLogin.loginPage("/auth")
.usernameParameter("user")
.passwordParameter("secret")
.loginProcessingUrl("/auth")
.failureUrl("/auth?auth_error=1")
.defaultSuccessUrl("/home")
)
.logout(
logout -> logout.logoutUrl("/custom-logout")
.logoutSuccessUrl("/home")
.invalidateHttpSession(true)
.clearAuthentication(true)
).csrf().csrfTokenRepository(repo());
}
@Bean
public CsrfTokenRepository repo() {
HttpSessionCsrfTokenRepository
repo = new HttpSessionCsrfTokenRepository();
repo.setParameterName("_csrf");
repo.setHeaderName("X-CSRF-TOKEN");
return repo;
}
// other code omitted
}
Listing 12-15Customized Login Form and Logout Support Configuration Using Spring Security
在 Spring Security 中,使用缺省值来命名组件是很实用的。任何定制都需要组件来支持——视图、控制器、异常处理程序等等。
清单 12-16 展示了书店应用的HttpSecurity对象的完整配置。它的简单性证明了它被设计成使用 Spring 默认配置,同时仍然能够使用与应用整体主题相匹配的定制登录表单。
package com.apress.prospringmvc.bookstore.web.config.sec;
import org.springframework.security.config.Customizer;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.security.config.annotation.web.configurers.
FormLoginConfigurer;
import org.springframework.security.config.annotation.web.configurers.
LogoutConfigurer;
// Other imports omitted
@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(
authorize -> authorize.mvcMatchers("/*/edit/*", "/*/delete/*")
.hasRole("ADMIN")
.anyRequest().permitAll()
).formLogin(
formLogin -> formLogin.loginPage("/login")
.failureUrl("/login?auth_error=1")
).logout(Customizer.withDefaults())
.csrf().csrfTokenRepository(repo());
}
// other code omitted
}
Listing 12-16Customized Login Form and Logout Support Configuration Using Spring Security
即使登录页面被映射到默认的 URL,登录页面也在这里被显式地配置,以告知 Spring Security 提供了登录页面。如果不指定,Spring Security 将生成缺省页面并使用它。
同样,作为定制登录页面一部分的登录表单必须声明一个隐藏的 CSRF 字段;否则,请求将无法通过 Spring Security 过滤器链,而是返回一个带有 HTTP 状态代码 403(禁止)的错误页面。
拥有自定义登录表单的好处是,它看起来像是网站的一部分(可以包含公司徽标),但也可以是国际化的。例如,书店登录定制表单代码如清单 12-17 所示。
<form action="<c:url value="/login"/>" method="post">
<input type="hidden"
name="${_csrf.parameterName}"
value="${_csrf.token}"/>
<fieldset>
<legend>
<spring:message code="login.title" />
</legend>
<table>
<tr>
<td>
<spring:message code="account.username"/>
</td>
<td>
<input type="text" id="username"
name="username"
placeholder="<spring:message code="account.username"/>"/>
</td>
</tr>
<tr>
<td>
<spring:message code="account.password"/>
</td>
<td>
<input type="password" id="password"
name="password"
placeholder="<spring:message code="account.password"/>"/>
</td>
</tr>
<tr>
<td colspan="2" align="center">
<button id="login">
<spring:message code="button.login"/>
</button>
</td>
</tr>
</table>
</fieldset>
</form>
Listing 12-17login.jsp Custom Login Form
当使用百里香叶作为模板引擎时,不需要为登录表单声明隐藏的 CSRF 字段,百里香叶集成模块负责将该字段注入到用th:action属性声明的每个表单中。为此,我们只需要配置模板引擎来识别和支持用sec:前缀声明的元素。这是通过调用templateEngine.addDialect(new SpringSecurityDialect())来配置对 Spring Security 方言的支持来实现的。
CSRF 参数和头名的默认名称在org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository类中声明。这种类型的 bean 可以配置为支持不同的 CSRF 参数和头名称。为本章创建的 Spring Security 配置类中配置的名称是默认的:_csrf和X-CSRF-TOKEN。以下示例使用了不同的头名称。
如果您使用 JavaScript,那么就不可能在 HTTP 参数中提交 CSRF 令牌。由于百里香没有一个表单来添加隐藏字段到页面的头部,必须用两个元条目来丰富,这两个元条目由 CSRF 头部名称和 CSRF 令牌填充。这两个元条目声明了 CSRF 参数和头名称,它们必须与 Spring Security 配置类中声明的相匹配。然后在 JavaScript 代码中提取这些值,并添加到提交的请求的 JSON 主体中。在前面的章节中,“搜索书籍”请求是使用 JavaScript 发送的。因此,必须修改“search.html”页面内容,如清单 12-18 所示,以便在安全的 web 应用中工作。
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org"
th:with="lang=${#locale.language}"
th:lang="${lang}"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
<head th:fragment="~{template/layout :: head('Search books')}">
<meta id="_csrf" name="_csrf" th:content="${_csrf.token}"/>
<meta id="_csrf_header" name="_csrf_header" th:content="${_csrf.headerName}"/>
</head>
<!-- most of HTML and Javascript code omitted-->
<script th:inline="javascript">
/*<![CDATA[*/
$('#bookSearchForm').submit(function(evt){
evt.preventDefault();
var title = $('#title').val();
var category = $('#category').val();
var json = { "title" : title, "category" : { "id" : category}};
var token = $('#_csrf').attr('content');
var header = $('#_csrf_header').attr('content');
$.ajax({
url: $('#bookSearchForm').action,
beforeSend: function(xhr) {
xhr.setRequestHeader(header, token);
},
type: 'POST',
dataType: 'json',
contentType: 'application/json',
data: JSON.stringify(json),
success: function(responseData) {
console.log(responseData); // debugging purposes
renderBooks(responseData);
}
});
});
/*]]>*/
</script>
Listing 12-18search.html with CSRF Protection
到目前为止,在本章中,Spring Security 上下文被配置并与容器 servlet 环境集成,但是安全上下文没有与应用上下文正确集成。Spring 是智能的,它会在被配置为要扫描的包中挑选出所有标有@Configuration的文件。但是如果您有一个多层应用,那么 Spring Security beans 应该放在哪里呢?在根 web 应用上下文中还是在 dispatcher servlet 上下文中?
这个问题的答案取决于应用的请求和架构。
-
如果不需要应用上下文层次结构,则不需要特殊配置;但是要确保扫描了声明安全配置类的包。
-
如果需要应用上下文层次结构,有两种情况。
-
假设有服务层,可以直接访问吗?它需要被保护吗?那么安全配置类应该是根应用上下文的一个组件,这样服务组件也可以得到保护。
-
如果只能通过 web 应用访问安全配置,那么应该将安全配置类设置为 servlet 应用上下文的一个组件。
-
让我们假设我们需要前面列表中最后一个场景的配置。这是通过将安全配置类添加到WebApplicationInitializer实现中来实现的。对于不专门通过 web 层接收请求的复杂应用,可以将安全配置类添加为根上下文类,但是对于我们简单的书店应用,将其添加为 servlet 上下文类是合理的。清单 12-19 描述了将安全性集成到 servlet 应用上下文中的配置片段。
package com.apress.prospringmvc.bookstore.web.config;
import org.springframework.security.config.Customizer;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.security.config.annotation.web.configurers.
FormLoginConfigurer;
import org.springframework.security.config.annotation.web.configurers.
LogoutConfigurer;
// Other imports omitted
public class BookstoreWebApplicationInitializer
extends AbstractAnnotationConfigDispatcherServletInitializer {
@Override
protected Class<?>[] getRootConfigClasses() {
return new Class<?>[]{
DbConfiguration.class,
JpaConfiguration.class};
}
@Override
protected Class<?>[] getServletConfigClasses() {
return new Class<?>[]{
WebMvcContextConfiguration.class,
SecurityConfiguration.class};
}
@Override
protected String[] getServletMappings() {
return new String[]{"/"};
}
@Override
protected Filter[] getServletFilters() {
CharacterEncodingFilter cef = new CharacterEncodingFilter();
cef.setEncoding("UTF-8");
cef.setForceEncoding(true);
return new Filter[]{new HiddenHttpMethodFilter(), cef};
}
@Override
public void onStartup(ServletContext servletContext) throws ServletException {
super.onStartup(servletContext);
ServletRegistration.Dynamic servlet = servletContext
.addServlet("h2-console", new WebServlet());
servlet.setLoadOnStartup(2);
servlet.addMapping("/h2-console/*");
}
}
Listing 12-19WebApplicationInitializer Implementation Class with Spring Security
在清单 12-19 中,有一个额外的方法需要解释。onStartup(ServletContext)将org.h2.server.web.WebServlet添加到 Spring 应用上下文,并将其映射到/h2-console/* URL。这个 servlet 是 H2 库的一部分,为应用中使用的内存数据库提供了一个 web 客户端。本章的“配置身份验证”一节中首先提到了这个 servlet。您可以使用这个 web 客户端来查询应用的所有表中的信息,但是如果您想看看加密的密码值是什么样子,尤其是ACCOUNT表。
servlet.setLoadOnStartup(2);方法设置加载这个 servlet 的优先级。任何大于或等于零的值都意味着这个 servlet 必须在容器调用了为ServletContext在其ServletContextListener.contextInitialized(… )方法中配置的所有ServletContextListener对象之后进行初始化。这意味着这个 servlet 是在加载了完整的 Spring 应用上下文之后加载的。
因为它提供了对敏感用户数据的访问,所以这个 servlet 的 URL 应该是安全的;最好只有ADMIN用户能够访问它。这里要提到的另一件事是,这个 servlet 使用表单来发送和检索数据,它不是应用的一部分。这意味着这些表单中没有 CSRF 令牌,也没有办法修改它们。这意味着必须对所有以/h2-console/开头的 URL 禁用 CSRF 保护。这是通过调用configure(HttpSecurity)方法中CsrFConfigurer的另一个方法来完成的。
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests(..)
.formLogin(..).logout(Customizer.withDefaults())
.csrf()
.csrfTokenRepository(repo())
//don't apply CSRF protection to /h2-console
.ignoringAntMatchers("/h2-console/**");
}
您可能已经注意到,在清单 12-16 中,URL“/customer/account”不在被配置为安全的特定 URL 模式中,尽管它应该只对经过身份验证的用户可用。这是为了展示如何使用 Spring Security 性在方法级别应用安全性。
安全方法
URL 安全规则应用于端点,因此它们应用于匹配端点的所有控制器方法,而无需在控制器中添加任何额外的配置。保护方法需要用特殊的安全注释来注释控制器方法。在一个地方声明所有的安全性(security configuration 类)有一定的吸引力,因为它与代码的其余部分是分离的,但是它适合于小型、简单的应用。当应用增长时,将授权规则放在受保护的代码附近可能更有意义。在过去,安全方法的一个额外优势是可以使用 SpEL,但是由于 SpEL 现在可以与MvcMatchers一起使用,所以只保留了接近安全代码的优势。
保护方法是通过使用 AOP 代理在幕后完成的。标记需要由安全代理调用的方法是通过一些注释来完成的。
-
@Secured(来自org.springframework.security.access.annotation包)是一个 Spring Security 注释,它定义了方法的安全配置属性列表,比如角色。当安全配置类用@EnableGlobalMethodSecurity(secured = true)注释时,这个注释被选中。这个注释不支持 SpEL 表达式,所以现在很少使用。 -
@RolesAllowed(来自javax.annotation.security)是 JSR-250??【14】相当于@Secured的注解。当安全配置类用@EnableGlobalMethodSecurity(jsr250Enabled = true)和项目类路径中的 JSR-250 库进行注释时,这个注释被选中。 -
@PreAuthorize(从org.springframework.security.access.prepost package)指定一个方法访问控制 SpEL 表达式,对其进行评估以决定是否执行该方法。当安全配置类用@EnableGlobalMethodSecurity(prePostEnabled = true)注释时,这个注释被选中。 -
@PostAuthorize(来自org.springframework.security.access.prepost包)指定一个方法访问控制 SpEL 表达式,该表达式在方法执行后被评估。当安全配置类用@EnableGlobalMethodSecurity(prePostEnabled = true)注释时,这个注释被选中。这在实现域级安全性时非常有用。使用@PostAuthorize声明的规则测试返回对象的所有权,如果检查失败,则不返回对象。 -
@PostFilter(来自org.springframework.security.access.prepost包)指定一个方法访问控制 SpEL 表达式,该表达式在方法执行后被求值,结果是一个集合。SpEL 表达式可以修改集合,并在返回集合之前删除用户无权访问的对象。当安全配置类用@EnableGlobalMethodSecurity(prePostEnabled = true)注释时,这个注释被选中。(还有一个@PreFilter注释,它在被注释的方法操作集合之前,根据授权表达式过滤集合。)
安全注释可以放在类和方法级别,方法级别的表达式覆盖类中的表达式。当您在单个控制器中声明了所有管理功能时,在类级别使用安全性是非常有用的。
JSR-250 库包含一小组安全注释(例如,@DenyAll、@PermitAll),这些注释在 Spring Security 中没有对应项,因为使用 Spring Security 表达式可以获得相同的效果。提到 JSR-250 注释是因为你可能在混合使用 JEE 和 Spring 的项目中找到它们,所以你应该知道 Spring Security 也可以被配置为支持它们,但是本章的重点是 Spring Security 注释。
使用带有 URL“/customer/Account”的 HTTP GET 请求来检索 Account 页面的内容。清单 12-20 中描述了处理这个请求的方法。
package com.apress.prospringmvc.bookstore.web.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import java.security.Principal;
// other imports omitted
@PreAuthorize(“isAuthenticated()”)
@Controller
@RequestMapping("/customer/account")
public class AccountController {
@GetMapping
public String index(Model model, Principal activeUser) {
Account account = accountRepository.findByUsername(activeUser.getName());
model.addAttribute("account", account);
model.addAttribute("orders", this.orderRepository.findByAccount(account));
model.addAttribute("fileOrders", getAsWebFiles());
return "customer/account";
}
// other code omitted
}
Listing 12-20Handler Method for "/customer/account"
该方法声明了一个类型为Principal的参数,Spring 用当前经过身份验证的用户主体注入该参数。这是必需的,因为从数据库中提取帐户详细信息需要用户名。具有讽刺意味的是,该方法不是为未经身份验证的用户执行的。如果这个请求是由未经验证的用户发出的,Spring 就找不到主体;所以参数是null,并抛出一个NullPointerException。尽管如此,将内部异常公开并不是一个好的做法,所以保护该方法是必须的。
现在有趣的是,这种方法可以通过多种方式来保护。在接下来的例子中,假设安全配置类被配置为支持提到的注释。
-
@PreAuthorize("isAuthenticated()")只有经过认证的用户才能访问该方法。如果一个未经身份验证的用户试图访问帐户页面,就会发出请求,但是由于这个注释,它会被一个安全的代理截获,该代理检查是否有一个经过身份验证的用户,如果没有,就会抛出AccessDeniedException。配置这类异常处理的常见方式是声明将AccessDeniedException映射到login视图的SimpleMappingExceptionResolver,,以及一条用户友好的错误消息,最好是国际化的消息。这个 bean 将异常添加到视图的模型中,JSP taglib 元素的组合可以显示当前地区的消息。在清单 12-21 中,显示了一个代码片段,描述了 Spring web configuration 类中这个 bean 的声明。在清单 12-22 中,您可以看到在 HTML 登录页面中显示错误消息的 JSP 代码。 -
由于我们知道 Account 表中的所有用户都有其中一个角色,所以这个表达式也适用。有趣的事实:与 JSR-250 相当的是
@RolesAllowed({"USER", "ADMIN"})。 -
@PreAuthorize("hasRole('USER') or hasRole('ADMIN')") -
@PreAuthorize("hasAnyRole('USER','ADMIN')")
<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@taglib prefix="spring" uri="http://www.springframework.org/tags" %>
<c:if test="${exception ne null}">
<div class="error">
<spring:message code="authentication.required"
text="${e.getMessage}" htmlEscape="true"/>
</div>
</c:if>
<!-- rest of this template omitted -->
Listing 12-22JSP Code to Show an Error Requiring User Authentication in the login.jsp Page
package com.apress.prospringmvc.bookstore.web.config;
import
org.springframework.web.servlet.handler.SimpleMappingExceptionResolver;
// other imports omitted
@Configuration
@EnableWebMvc
public class WebMvcContextConfiguration implements WebMvcConfigurer {
Bean
public SimpleMappingExceptionResolver simpleMappingExceptionResolver() {
SimpleMappingExceptionResolver exceptionResolver = new SimpleMappingExceptionResolver();
Properties mappings = new Properties();
mappings.setProperty("AccessDeniedException", "login");
Properties statusCodes = new Properties();
mappings.setProperty("login", String.valueOf(HttpServletResponse.SC_UNAUTHORIZED));
exceptionResolver.setExceptionMappings(mappings);
exceptionResolver.setStatusCodes(statusCodes);
return exceptionResolver;
}
// other code omitted
}
Listing 12-21SimpleMappingExceptionResolver to Handle AccessDeniedException
@Secured是初始安全注释,不支持 SpEL 表达式。@PreAuthorize是 Spring Security 3 中新增的,功能强大。@PreAuthorize支持使用 SpEL 声明访问规则,因此您可以尽情发挥创造力。
“/客户/列表”也不安全。该页面显示 ACCOUNT 表中的所有用户,应该只有具有 ADMIN 角色的用户可以访问。这可以通过用@Secured({"ROLE_ADMIN" })或@ pre authorize(" has ROLE(' ADMIN '))".注释该方法来轻松设置
但是让我们假设我们想要确保只有名为“admin”的管理员用户可以访问该页面。唯一适合这项工作的注释是@PreAuthorize,因为它可以解释这样的表达式。
@PreAuthorize("hasRole('ADMIN') and principal == 'admin'").
保护方法为 web 应用增加了一层额外的安全性。因为支持使用 SpEL 表达安全规则,所以可以用非常具体和精细的方式控制访问。
所以,好好享受保护东西的乐趣吧!
保护 Spring Boot Web 应用
保护 Spring Boot web 应用与保护 Spring MVC 应用没有什么不同。但是有一些事情要记住。一旦 security starter 库作为依赖项被添加,对应用的公共访问就不存在了,任何 URL 都会重定向到默认的 Spring Security 登录生成的表单。如果您想允许访问任何内容,您必须提供一个配置。
没有必要将springSecurityFilterChain与容器 servlet 环境集成,因为您使用的是 Spring Boot 支持的嵌入式服务器之一。因此,不需要类扩展AbstractSecurityWebApplicationInitializer。
没有必要将您的配置类与 web 应用上下文显式集成。
这就是全部了。在 Spring Boot Web 应用中配置 Spring Security 恢复如下。
-
将
spring-boot-starter-security依赖项添加到您的配置中 -
编写一个扩展
WebSecurityConfigurerAdapter的 Spring Security 配置类(与带有经典设置的 Spring MVC 应用的方式相同;添加用于启用 web 安全性、方法安全性、配置 URL 匹配等的注释。) -
添加您的定制
AuthenticationProvider实现(如果您有) -
修改您的视图以保护敏感内容
如果您使用百里香,不要忘记向模板引擎注册 Spring Security 方言。
摘要
本章介绍了 Spring web 应用环境中 Spring Security 性的基本部分。我们研究了 Spring Security 如何与经典的 Spring MVC 应用集成。介绍了 cookies、会话、身份验证、授权、主体和权限等核心概念,并解释了它们在保护 web 应用中的作用。
我们展示了如何修改 Apache Tiles 和 Thymeleaf 视图模板来考虑安全性设置。这意味着为用户提供的服务取决于他们在应用中的角色。
简单介绍了常见类型的攻击,如会话劫持和 CSRF,以及 Spring Security 提供的防止这些攻击的方法。
还讨论了 Spring Boot web 应用上下文中的 Spring Security 性,并明确了开发人员需要提供的保护应用的最小操作集。
我们使用了一个带有密码散列的内存数据库来保存身份验证数据,然后展示了如何实现一个定制的身份验证提供程序来使用应用中的一个现有表。
Footnotes 1https://spring.io/blog/2007/01/25/why-the-name-acegi
2
https://www.allaboutcookies.org/
3
https://owasp.org/www-community/attacks/Session_hijacking_attack
4
https://docs.spring.io/spring-security/site/docs/current/reference/html5/#servlet-security-filters
5
https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)
6
https://tools.ietf.org/html/rfc2617
7
https://tools.ietf.org/html/rfc2617
8
https://owasp.org/www-community/attacks/Session_fixation
9
https://docs.spring.io/spring-security/site/docs/current/reference/html5/#servlet-authentication
10
https://docs.spring.io/spring-security/site/docs/current/reference/html5/#appendix-schema
11
12
https://docs.spring.io/spring-security/site/docs/3.0.x/reference/taglibs.html
13
https://owasp.org/www-community/attacks/csrf
14
https://jcp.org/en/jsr/detail?id=250
十三、云中的 Spring 应用
很少有技术大学在教学开发时触及云的主题。除非大学很大,有亚马逊,微软,或者谷歌这样的云提供商,作为合作伙伴,作为学生,你很可能在没有为云编写应用或在云中部署应用的情况下毕业。这一章简单介绍了云的开发,并展示了 Spring 如何开发微服务。
如今,大多数应用都托管在云中,任何新编写的应用都可能被设计为托管在云中,因此了解这如何影响应用的架构非常重要。
让我们回到软件开发中提到云这个词之前,回顾一下应用架构。
应用架构
应用架构指的是决定应用应该具有哪些部分以及它们应该如何相互连接的过程。
介绍独石
想象一下你想创业在网上卖书(很有创意,我知道!).你可以雇佣一家公司来创建你的网站,或者如果你已经对软件开发感兴趣,你可以自己创建。您需要一个数据库来存储有关书籍、订单和用户的信息。您需要一个管理这些信息的应用。应用由更多的部分或层组成。
-
用户界面(也称为表示层):为此,你需要 HTML、CSS、JavaScript 等等。如果要从多个设备(电脑、平板电脑、手机)访问应用,您可能需要针对每个设备的特定技术(例如,针对 Android 设备的 Android SDK)。
-
前端(也称为应用层):接收来自用户界面的请求并将数据转换成链中下一层可以处理的东西的部分。对于 Spring web 应用,这由控制器和功能端点来表示。
-
服务(也称为业务层):接收来自前端的数据并对其进行处理,准备将其存储在数据库中。
-
数据访问层(也称为持久层):它从数据库中检索数据,并将其转换为服务层可以处理的数据。它还从服务层获取数据,并将其保存到数据库中。包含 Dao(数据访问对象)和管理实体的特殊类。对于 Spring 应用,这由实体/文档、类和存储库来表示。
像这样设计你的应用意味着你正在构建一个多层或多层架构或单片架构。
在开发过程中,所有层都位于一台计算机上。但是,当应用必须进入生产环境时,数据库通常被放在另一台计算机上,该计算机比其他计算机备份得更频繁,因为它包含对应用最重要的数据。如果您使用 Java,其余的层被打包到部署在应用服务器上的单个 WAR 或 EAR 中,比如 Apache Tomcat。
如果这听起来很熟悉,那是因为这是在本书中完成的。Spring Boot 应用甚至不需要应用服务器,因为它嵌入在它们之中。即使在反应性章节中,我们也经常提到它们适合构建微服务,但是架构仍然是单一的。在图 13-1 中,您可以看到这样一个应用是如何部署给公众使用的。
图 13-1
为公共使用而部署的多层应用
单体式应用易于开发、测试、部署和扩展——至少在最初是这样,因为随着越来越多的用户访问您的应用,缺点就会变得明显。
-
如果数据库的大小增加了,你用一个更大的硬盘替换它,问题就解决了——至少在一段时间内。
-
如果有太多用户访问您的应用,请增加您的计算机或 VM 的内存以及 Apache 服务器上的线程数量。这样应该可以暂时解决问题—。
** 但是,维护一个完整的代码库是困难的。随着代码库的增长,知识必须在开发人员之间分配,因为很少有一个开发人员能够很好地了解所有的部分;当人们离开公司时,知识就是这样流失的。
* 当代码库很大时,改变和升级技术变得非常危险,这就是技术债务蔓延的方式。
* 连续部署是不可能的。
* 缩放是有限且昂贵的。*
*如果您有自己的数据中心,升级硬件是可能的,但成本很高,想象一下维护数据中心的成本吧!这就是亚马逊、谷歌和其他公司看到了巨大商业潜力的地方——提供一个基础设施,在这个基础设施上,开发人员可以以更少的麻烦和成本来部署和管理应用。云就是这样诞生的。云是世界各地的许多计算机和运行在这些计算机上的软件。在作为云的一部分的远程计算机上部署和管理应用被称为云计算。通过使用云计算,用户和公司不必自己管理物理服务器或在自己的机器上运行软件应用。
现在世界上有一些云提供商。他们中的大多数都提供广泛的服务,如自动备份、自动缩放、负载平衡和数据存储,亚马逊可能在服务数量上领先。
将计算机使用的资源(CPU、RAM)重新调整为一个整体所需的大小称为垂直缩放。即使您的应用部署在云上,并且计算机被替换为虚拟机,按照 monolith 所需的方式调整它们的资源(CPU、RAM)也是麻烦和有限的。还有另一种类型的扩展,涉及到将更多的机器添加到您的资源池中,这被称为水平扩展。不幸的是,整体架构并不完全适合水平扩展,但微服务架构适合。
微服务简介
微服务架构,也叫微服务、1是面向服务架构(SOA)的一种专门化和实现方式。它构建灵活的、可独立部署的服务。微服务架构不同于整体架构,因为它不是通过使用层而是作为服务的集合来定义应用。
微服务是一种范式,它要求将服务分解为高度专业化的功能实例,并通过不可知的通信协议(例如 REST)相互连接,共同完成一个共同的业务目标。每个微服务都是一个无状态功能的微小单元,一个不关心输入来自哪里,也不知道输出去哪里的进程;它不知道大局是什么。由于这种专门化和解耦,每个问题都可以被识别,原因可以被定位和修复,实现可以被重新部署,而不会影响其他微服务。这意味着微服务系统具有高的职责内聚性和低耦合性。这些品质允许单个服务的架构通过持续的重构来发展,减少大的预先设计的必要性,并允许软件更早和持续地发布。将一个大型复杂的应用分解成更小的独立应用,可以快速、频繁、可靠地交付功能,并促进公司技术体系的发展。
由于低粒度和轻量级通信协议,微服务近年来越来越受欢迎。它们已经成为构建企业应用的首选方式。微服务模块化架构风格似乎特别适合基于云的环境。当必须支持多个平台和设备时,这种架构方法是可扩展的,并且被认为是理想的。想想现在网络上最大的玩家:脸书、Twitter、网飞、亚马逊、PayPal、SoundCloud 等等。他们拥有大型网站和应用,这些网站和应用已经从单一架构发展到微服务,因此可以从任何设备访问它们。亚马逊和谷歌目前是该行业中最大的两家,它们提供了一套云计算服务,非常适合构建由众多微服务协同工作组成的复杂应用。为银行、零售商、餐馆、小企业以及电信和技术提供软件和服务的企业别无选择,只能依靠 AWS 或 GCP 来保持其服务的随时可用。这是通过使用 Apigee 2 或亚马逊 API 网关构建他们的微服务来实现的。 3 亚马逊和谷歌为微服务提供了沟通的基础设施和无数构建它们的工具。最终,为这些服务编写代码仍然是开发人员的责任。
Spring Boot 是一个很好的工具,可以用来构建一个小型的利基应用,在一个更复杂的应用中代表一个微服务。响应式服务可以事半功倍,降低云基础架构的成本。
使用微服务的主要优势是什么?以下是 IT 界的好评。
-
粒度增加
-
增强的可扩展性
-
易于自动化的部署和测试(当然取决于上下文,因为如果涉及到事务,事情就开始变得困难了),因为微服务的特点是定义良好的接口,便于通信(JSON/ WSDL/JMS/AMQP)
-
增强解耦,因为微服务不共享服务的状态
-
增强凝聚力
-
适合持续的重构、集成和交付
-
增强模块独立性
-
专业化—围绕能力组织;每个微服务都针对一种特定的功能而设计
-
提高敏捷性和速度,因为当系统被正确分解为微服务时,每个服务都可以独立开发和部署,并与其他服务并行。
-
每个服务都是有弹性的、可复原的、可组合的、最小的和完整的
-
故障隔离的改进
-
消除对单一技术堆栈的长期承诺,因为微服务可以用不同的编程语言编写
-
更容易的知识共享,因为新开发人员可以在几个微服务上工作,而不需要了解整个系统
既然这个世界上没有十全十美,特别是在软件开发方面,就有几个缺点。
-
微服务引入了额外的复杂性,以及小心处理服务间请求的必要性。
-
像处理实体类一样处理共享资源会导致问题。
-
处理多个数据库和事务(分布式事务)可能会很痛苦。
-
测试微服务可能很麻烦,因为在测试服务之前,必须确认每个微服务依赖项都是有效的。
-
部署可能会变得复杂,需要服务之间的协调。
向云迁移:是还是不是?
在本章中,我们将书店转换为更适合部署到云的微服务。但是,在此之前,我们应该列出迁移到云的一些优势。
-
一个明显的优势—降低基础架构成本
-
由于开发人员学习配置云服务,人员成本可能会更低,因为你不再需要一个大的基础设施部门
-
灵活性—配置云服务,如负载平衡器和自动扩展组,以确保您的应用始终运行并自我修复
-
云基础架构会自动维护,因此无需担心软件更新
-
一切都在云端,所以你可以在任何地方工作
-
和许多其他人
迁移到云有什么缺点吗?当然可以。
-
主要缺点是你完全依赖你的云提供商。
-
供应商锁定—转换您的云提供商是一件痛苦的事情;对服务的控制是通过自定义 API 提供的,它们之间没有桥梁。
-
如果你的云提供商因为自然灾害遇到了技术问题,你可能会被系统淘汰。
-
如果管理资源的软件发生故障,自动伸缩和自我修复可能会受到影响。即使您正确配置了您的云基础架构,并考虑到了所有因素,本应扩展并保持云基础架构的服务仍然托管在同一个云中。停机仍然是一种可能。
-
尽管云服务提供商实施了最佳的安全标准和行业认证,但在外部服务提供商上存储数据和重要文件仍然存在一定程度的风险。黑客对云提供商来说尤其危险;例如,如果亚马逊遭到黑客攻击,其所有客户的数据都将面临风险。
-
有限的控制——大多数云提供商允许您使用 web 控制台和 SSH 连接来管理您的基础设施,但这种控制最终是最小的。
-
成本—尽管与建立和管理自己的数据中心相比,使用云计算可以降低您的成本,但在某些情况下,云服务的成本可能会增加。大多数云提供商宣传按需付费模式,为您提供灵活性和更低的硬件成本,但它们仍然不适合小规模的短期项目。此外,云资源的错误配置可能会增加您的账单。
尽管存在缺点,但许多组织受益于云服务提供的敏捷性、规模和按需付费模式。然而,与任何基础设施服务一样,云计算对于您的特定用例的适用性应该在基于风险的评估中进行评估。例如,亚马逊鼓励公司明智地规划,只使用他们需要的东西。他们有一些合作伙伴公司,以最低的成本提供设计和维护云基础架构的咨询服务。因为一个企业要有较长的寿命和利润,他们的客户也必须这样做。
尽管如此,如果你是一名职业生涯刚起步的开发人员,想要了解云,你可能没有能力支付云访问的费用。无需担心;大多数云提供商向他们的用户提供免费的受限访问账户。亚马逊为新用户提供 12 个月的免费等级访问 4 来学习使用他们的服务。GCP 提供三个月的免费试用 5 ,外加 300 美元的信用额度,可用于任何谷歌云服务。但是,如果你仍然不愿意尝试它们中的任何一个,不要担心,Spring 已经为你准备好了。Spring Cloud project collection 是根据分布式系统中最常见的模式构建微服务的工具宝库,您可以在本地运行这些服务,就像它们在云中运行一样。
介绍 SpringCloud
要开发由一组带有 Spring 组件的微服务组成的应用,需要对以下 Spring 技术有很好的了解。
-
服务注册和发现技术,如网飞的 OSS Eureka
-
像 Eureka 或 Consul 这样的 Spring 云项目
-
REST 概念(因为微服务之间的通信是使用 REST 完成的)
Spring Boot 是为开发人员设计的,通过使常见的概念——如 RESTful HTTP 和嵌入式 web 应用运行时——易于连接和使用,提高了工作效率。它很灵活,允许开发人员只挑选他们想要使用的模块,消除了大量或庞大的配置和运行时依赖。
Spring Cloud 6 是一个项目集合,旨在简化分布式应用的开发。
-
配置管理(Spring Cloud Config 提供由 Git 存储库支持的集中式外部配置)
-
服务发现(Eureka 是一个用于弹性中间层负载平衡和故障转移的服务注册中心,由 Spring Cloud 支持)
-
断路器(Spring Cloud 支持网飞的 Hystrix,这是一个库,提供了当在预定义的阈值内没有收到响应时停止调用服务的组件)
-
智能路由(Zuul 将呼叫转发和分配给服务)
-
微代理(中间层服务的客户端代理)
-
控制总线(消息传递系统可用于监控和管理框架内的组件,就像用于应用级消息传递一样)
-
一次性令牌(使用 Spring Vault 7 仅用于一次数据访问)
-
全局锁(协调、区分优先级或限制对资源的访问)
-
领导选举(指定单个流程作为分配给几个节点的某些任务的组织者的过程)
-
分布式消息传递(Spring Cloud Bus 可用于通过轻量级消息代理链接分布式系统的节点)
-
集群状态(集群状态请求被路由到主节点,以确保返回最新的集群状态)
-
客户端负载平衡
如果您对使用 Spring Cloud 构建微服务应用感兴趣,Spring 官方文档涵盖了所有基础知识。协调分布式系统并不容易,可能会导致样板代码。Spring Cloud 让开发人员更容易编写这种类型的管理代码。其结果适用于任何分布式环境,包括开发站、数据中心或托管平台,如 Cloud Foundry。
Spring Cloud 构建于 Spring Boot 之上,它具有典型的 Spring Boot 优势:开箱即用的预配置基础架构 beans,可以进一步配置或扩展以创建自定义解决方案。它遵循相同的 Spring 声明性方法,依赖于注释和属性(YAML)文件。
SpringCloud 网飞提供与网飞 OSS(网飞开源软件)的集成。GitHub 官方页面在 https://netflix.github.io/ 。它是一个开源库的集合,是开发者为解决大规模分布式系统问题而编写的。它是用 Java 编写的,在用 Java 编写微服务应用时,它几乎成了最常用的软件。
面向云的重新设计
我们在整本书中编写的书店应用是一个整体。它的各个层,尽管被分离在不同的模块中,但都被组合成了部署在 Apache Tomcat 上的一个 war,或者打包成可执行 jar 的一个 Spring Boot 应用。即使在使其具有反应性时,monolith 架构也被保留下来,因为这是一个小应用,还没有理由改变它。将它作为一个整体部署到云中是可能的,但是由于前面几节中列出的所有原因,效率很低。它需要重新设计。
在前面的章节中,应用的每个模块都包含(或多或少)特定于应用处理的所有对象的整体层的功能。例如,Book、Account和Orders的所有存储库类都负责数据库操作,因此属于 DAO 层。
微服务要求根据业务功能进行分离,因此每个服务应该有一个与单一类型的对象相关的单一角色。因此,我们可能需要一个微服务来处理对Book对象的所有操作,一个用于Account对象,以此类推。由于tech news service独立于应用,所以它可以被做成微服务。
提供随机图书发行的服务也是如此。图 13-2 描述了如何为云重新设计书店应用的建议。
图 13-2
书店整体架构和微服务架构的比较
图 13-2 有一个在所有微服务之间共享的数据库,但这不是必需的。任何提供服务的东西都可以成为微服务。您可以将安全性作为一项独立的服务,依赖于云提供商的认证服务。您可以决定为帐户建立一个单独的数据库。您也可以决定为订单建立一个单独的数据库。说到数据库,有三种模型。
-
Private-tables-per-service:每个服务拥有一组只能由该服务访问的表。
-
每服务模式:每个服务都有一个该服务专用的数据库模式。
-
每个服务一个数据库服务器:每个服务都有自己的数据库服务器。
这完全取决于您的设计要求。在本章的其余部分,您将学习如何使用 Spring Cloud 编写服务并确保它们之间的通信。
注册和发现服务器
微服务架构确保一组流程朝着一个共同的目标协同工作:为最终用户提供有能力且可靠的服务。要做到这一点,进程必须有效地通信。要相互交流,首先要找到对方。这就是网飞尤里卡注册服务器的用武之地。因为它是开源的,所以它被并入了 Spring Cloud,Spring 的简单原则现在也适用。
在本章中,书店应用分为六个项目;每个项目的名称都带有前缀chapter13,,如图 13-3 所示。
图 13-3
书店微服务项目
发现服务是核心组件。它是所有其他微服务用来注册和发现彼此的项目的中心。
项目的配置没什么特别的。这是一个简单的 Spring Boot Web 应用,在其类路径中有一个 Spring Cloud starter 项目:spring-cloud-starter-netflix-eureka-server starter。这个依赖关系为项目添加了构建网飞尤里卡服务注册中心所需的所有依赖关系。这是一种特殊类型的服务,它对其他现有服务进行编目,并支持客户端通信和负载平衡。每隔一个微服务就注册一次,这样 Eureka 就知道每个端口和 IP 地址上运行的所有应用。这意味着所有其他五个微服务都是发现服务的客户端,并且必须配置其位置以知道在哪里注册它们自己。
为了创建一个 Eureka 服务注册中心,项目的主类必须用@EnableEurekaServer进行注释(参见清单 13-1 )。
package com.apress.prospringmvc.bookstore;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;
// other imports omitted
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServerApplication {
private static Logger logger = LoggerFactory.getLogger(DiscoveryServerApplication.class);
public static void main(String... args) throws Exception {
System.setProperty("spring.config.name", "discovery-service");
var ctx = SpringApplication.run(DiscoveryServerApplication.class, args);
assert (ctx != null);
logger.info("Started ...");
System.in.read();
ctx.close();
}
}
Listing 13-1Spring Boot Main Class of a Discovery Microservice
使用了
System.in.read();调用,所以你可以通过按键优雅地停止应用。为了避免开发过程中的混乱,Spring Boot 配置文件被重命名为 discovery-service.yml,并且spring.config.name环境变量被设置为让 Spring Boot 知道从中获取配置的文件的名称。
@EnableEurekaServer注释非常重要,因为它激活了与 Eureka 服务器相关的配置。这个注释负责为项目提供一个 Eureka 服务器实例。该服务器带有一个非常好的 web 界面,在这里可以监控已注册的微服务。主页可通过 http://[ip]:[port]/访问。其中IP是您的计算机的 IP 或者是localhost, 127.0.0.1、0.0.0.0,中的任何一个或者全部,如果您在 Spring Boot 配置文件中这样配置的话。该端口也取自 Spring Boot 配置文件。
discovery-service.yml包含该服务器的设置,其内容在清单 13-2 中描述。
spring:
application:
name: discovery-service
# Configure the Server
eureka:
client:
registerWithEureka: false # do not auto-register as client
fetchRegistry: false
server:
waitTimeInMsWhenSyncEmpty: 0
server:
port: 3000 # where this discovery server is accessible
address: 0.0.0.0
Listing 13-2The Eureka Discovery Server Configuration (discovery-service.yml)
之前的配置在端口 3000 上启动服务器,如果访问 web 界面,可以看到此时没有微服务注册,如图 13-4 所示。
图 13-4
尤里卡发现服务器 web 界面
主页显示了一些关于发现服务器实例的指标。网飞的 Eureka 服务器的原始版本避免在可配置的时间内回答客户,如果它从一个空的注册表开始。eureka.server.waitTimeInMsWhenSyncEmpty属性控制这种行为,它被设计成在服务器有足够的时间来构建注册表之前,客户机不会得到部分/空的注册表信息。当某些微服务必须在其依赖项启动并准备就绪后才启动时,这很有用。在清单 13-2 中,值被设置为零以尽快开始回答客户。这种配置适合于开发环境,因为它加快了速度。
如果未设置,
eureka.server.waitTimeInMsWhenSyncEmpty的默认值为 5 分钟。
eureka.client.registerWithEureka属性用于注册 Eureka 客户端,通常在 Eureka discovery 服务器上设置为false。它告诉这个实例不要向它找到的 Eureka 服务器注册它自己,因为它是它自己。
如果发现服务器公开任何具有与服务的注册和发现无关的功能的端点(例如公开用于监控微服务的指标),它必须将自己注册为客户端。
现在我们有了服务器,我们可以开始编写我们的微服务了。
开发微服务
使用 Spring Boot 创建微服务非常简单。您必须选择功能,编写实现行为所需的代码,公开可以被其他微服务访问的端点,并将其配置为注册为 Eureka 客户端。
微服务是处理明确定义的需求的独立流程。在创建基于微服务的分布式应用时,每个微服务组件都应该根据其用途包装在包中。整个实现应该是非常松散耦合的,但是非常紧密。让我们从我们可以为书店应用编写的最小、最简单的微服务开始:科技新闻服务。该服务应该公开一个可以访问无限技术新闻流的单一端点。
因为它是一个没有 web 接口的反应式服务,所以这个项目唯一的依赖项是spring-boot-starter-webflux和spring-cloud-starter-netflix-eureka-client。
spring-cloud-starter-netflix-eureka-client为项目添加了所有必要的依赖项,允许您构建一个网飞尤里卡客户端。
除了这两个之外,添加spring-boot-starter-actuator. 8 也是可行的。这个依赖项将 Spring Actuator 添加到您的项目中,这将为我们的应用添加生产就绪的特性。通过几个端点,它公开了关于正在运行的应用的操作信息。
科技新闻服务非常简单。它有一个用@EnableEurekaClient注释的 Spring Boot 主配置类。这是将该应用转变为微服务的关键组件,因为它支持 Eureka 客户端发现配置。
在这个类中,声明了一个路由器 bean 来配置端点和处理程序函数之间的映射。
清单 13-3 描绘了TechNewsApplication的内容。
package com.apress.prospringmvc.technews;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
// other imports omitted
@SpringBootApplication
@EnableEurekaClient
public class TechNewsApplication {
public static void main(String... args) {
System.setProperty("spring.config.name", "technews-service");
SpringApplication springApplication = new SpringApplication(TechNewsApplication.class);
springApplication.run(args);
}
@Bean
public RouterFunction<ServerResponse> router(TechNewsHandler handler){
return RouterFunctions
.route(GET("/"), handler.main)
.andRoute(GET("/index.htm"), handler.main)
.andRoute(GET("/tech/news"), handler.data);
}
}
Listing 13-3Spring Boot Tech News Microservice Configuration Class
TechNewsHandler类包含两个处理函数实现。其内容列于清单 13-4 中。
package com.apress.prospringmvc.technews;
// other imports omitted
@Component
class TechNewsHandler {
private static final Random RANDOM = new Random(System.currentTimeMillis());
public static final List<String> TECH_NEWS = List.of(
"Apress merged with Springer."
// other values omitted
);
public static String randomNews() {
return TECH_NEWS.get(RANDOM.nextInt(TECH_NEWS.size()));
}
final HandlerFunction<ServerResponse> main = serverRequest -> ok()
.contentType(MediaType.TEXT_HTML)
.bodyValue("Tech News service up and running!");
final HandlerFunction<ServerResponse> data = serverRequest -> ok()
.contentType(MediaType.TEXT_EVENT_STREAM)
.body(Flux.interval(Duration.ofSeconds(5))
.map(delay -> randomNews()), String.class);
}
Listing 13-4TechNewsHandler Handler Functions
这个应用的 Spring Boot 配置文件被命名为technews-service.yml,并在清单 13-5 中描述。
# Spring Properties
spring:
application:
name: technews-service # Service registers under this name
# HTTP Server
server:
port: 4000 # HTTP (Netty) port
address: 0.0.0.0
# Discovery Server Access
eureka:
client:
registerWithEureka: true
fetchRegistry: false
serviceUrl:
defaultZone: http://localhost:3000/eureka/
healthcheck:
enabled: true
instance:
leaseRenewalIntervalInSeconds: 5
preferIpAddress: false
# Actuator endpoint configuration
info:
app:
name: technews-service
description: Spring Cloud Random Tech News Generator
version: 2.0.0-SNAPSHOT
Listing 13-5Spring Boot Configuration File for the Tech News Microservice
前面的配置包含三个部分。
-
Spring 段定义应用名为
technews-service。微服务使用这个名称向 Eureka 服务器注册。 -
服务器部分定义了监听请求的端口。在本例中,它被设置为 4000。作为一个使用嵌入式 Netty 实例的反应式应用,如果不指定,它会尝试使用 8080。因为一次只有一个进程可以监听一个端口,所以每个微服务都有一个通过配置分配的不同端口。对于这个微服务,
server.address被设置为0.0.0.0,这意味着在安装它的计算机的所有地址上都可以访问它的端点。 -
Eureka 部分使用
eureka.client.serviceUrl.defaultZone属性定义了要注册的服务器所在的 URI。eureka.client.registerWithEureka默认为true;它用于配置 Eureka 客户端的注册。在此配置中明确设置它是为了避免混淆此微服务的类型。Eureka 客户端从服务器获取注册表信息并在本地缓存。之后,客户端使用这些信息来查找其他服务。因为
technews-service不依赖于另一个已经注册的微服务,所以不需要获取注册信息。将eureka.client.fetchRegistry设置为false以防止它这样做。注册成功后,Eureka 服务器总是报告一个客户机应用处于运行状态。这种行为可以通过启用 Eureka 健康检查来改变,这会导致将应用状态传播到 Eureka。这通过将
eureka.client.healthcheck.enabled属性设置为true来完成。尤里卡的客户端需要通过发送一个名为心跳的信号来告诉服务器它们仍然活跃。默认情况下,时间间隔为 30 秒。通过自定义
eureka.instance.leaseRenewalIntervalInSeconds属性的值,可以将其设置为更小的间隔。在开发过程中,可以将其设置为较小的值,这样可以加快注册速度,但是在生产中,这会产生与服务器的额外通信,这可能会导致服务延迟。对于生产,不应修改默认值。eureka.instance.preferIpAddress告知 Eureka 服务器是否应该使用域名或注册客户端的 IP。在我们的例子中,因为所有东西都在同一台机器上工作,所以这个属性的值是不相关的。 -
在执行器部分中,在浏览器中访问
/actuator/info时显示的信息由information属性块定制。健康信息由/actuator/healthURI 访问。
这些信息和更多信息可以在网飞 GitHub 页面上找到。 9 这里只解释与我们的实现相关的部分。
现在我们有了一个作为 Eureka 客户端的微服务,下一步是启动它并检查它是否注册到发现服务器。当您在 discovery-service 应用的日志中看到以下输出时,注册应该完成。
DEBUG o.s.c.n.e.server.InstanceRegistry - register TECHNEWS-SERVICE, vip technews-service, leaseDuration 90, isReplication false
INFO c.n.e.r.AbstractInstanceRegistry - Registered instance TECHNEWS-SERVICE/192.168.0.14:technews-service:4000 with status UP (replication=false)
注册后,当访问 Eureka 服务器的 web 界面 http://localhost:3000/时,您应该看到在`Instances currently registered with Eureka部分,添加了一个针对technews-service`微服务的条目,如图 13-5 所示。
图 13-5
尤里卡发现服务器 web 界面
如果您点击Status栏中的链接,您会注意到它会将您带到科技新闻应用中的/actuator/info,在那里您会看到驱动部分中的信息(参见图 13-6 )。
图 13-6
TechNews 执行器信息
newreleases-service也是一个非常简单的服务,它几乎与technews-service相同,但是它返回无限的Book实例流。这是一个 Spring Boot 应用,可以通过执行它的主类来启动,就像 tech news 微服务一样,所以没有必要在这里添加更多的代码或任何文本,因为它对本章没有任何实际价值。这两个微服务提供了书店应用中图书页面的新闻部分中描述的数据。
一旦启动了technews-service和newreleases-service微服务,就可以在浏览器中打开下面的网址http://localhost:3000/eureka/apps。该端点公开了所有已注册微服务的注册表元数据,例如它们的注册时间、运行状况检查、发送心跳的时间等等,这些都是微服务的标准信息。该信息发布在服务注册表中,并且可供所有客户端使用。您的 Eureka 服务器的目的是生成和管理信息,并与所有需要它的微服务共享。数据的格式是 XML。清单 13-6 中描述了一小段代码。
<applications>
<versions__delta>1</versions__delta>
<apps__hashcode>UP_2_</apps__hashcode>
<application>
<name>TECHNEWS-SERVICE</name>
<instance>
<instanceId>192.168.0.14:technews-service:4000</instanceId>
<hostName>192.168.0.14</hostName>
<app>TECHNEWS-SERVICE</app>
<ipAddr>192.168.0.14</ipAddr>
<status>UP</status>
<homePageUrl>http://192.168.0.14:4000/</homePageUrl>
<statusPageUrl>http://192.168.0.14:4000/actuator/info</statusPageUrl>
<healthCheckUrl>http://192.168.0.14:4000/actuator/health</healthCheckUrl>
<lastUpdatedTimestamp>1598830791842</lastUpdatedTimestamp>
<lastDirtyTimestamp>1598830791711</lastDirtyTimestamp>
<actionType>ADDED</actionType>
</instance>
</application>
<!-- other output omitted -->
</applications>
Listing 13-6Eureka Server Registered Microservices Information
如果您只想查看特定服务的元数据信息,请将服务名称添加到前面提到的 URI 中。因此,要仅查看关于technews-microservice,的信息,您必须访问http://localhost:3000/eureka/apps/TECHNEWS-SERVICE。
通过定制eureka.instance.metadataMap元数据,可以将附加元数据添加到实例注册中。通常,添加额外的元数据不会以任何方式修改远程客户端的行为,除非客户端被设计为知道其含义。要了解更多信息,请查阅官方 Spring Eureka 文档。 10
在注册时,每个微服务从服务器获得一个惟一的注册标识符,您可以在前面的输出片段的<instanceId>元素中看到。如果另一个进程使用相同的 ID 注册,服务器会将其视为重新启动,因此第一个进程会被丢弃。
为了运行同一个流程的多个实例,出于负载平衡和弹性的原因,我们必须确保服务器生成不同的注册 ID。在本地,这可以通过为微服务使用不同的端口来实现。这是最简单的方法,无需对代码库或配置进行侵入性的更改。
到目前为止使用的配置的注册 ID——<instanceId>元素中的 ID——是使用下面的默认模式构建的。
${ipAddress}:${spring.application.name:${server.port}}
technews-service微服务实例的注册 ID 是
192.168.0.14:technews-service:4000
微服务名称和端口在格式模式中耦合在一起,因为它们提供了一种唯一的方法来标识微服务及其侦听请求的端口。
可以通过在 Spring Boot 配置文件中为 Eureka eureka.instance.metadataMap.instanceId属性添加不同的值来修改注册 ID 模板。清单 13-7 描述了一个修改注册 ID 模板的配置示例。
eureka:
instance:
metadataMap:
instanceId: ${spring.application.name}:${spring.application.instance_id:${server.port}}
Listing 13-7Registration ID Is Configured to Use a Different Naming Template
如果没有定义spring.application.instance_id,就退回到这个默认模板(如果对是哪个有疑问的话)。
${ipAddress}:${spring.application.name:${server.port}}
当在本地运行一个微服务应用时(我想你正在浏览这本书),微服务的 main 方法可以被参数化,将端口作为一个参数。这允许您通过提供一个不同的端口作为参数来启动任意数量的服务实例。在清单 13-8 中,端口值被读取并注入到 Spring Boot server.port环境变量中。
package com.apress.prospringmvc.newreleases;
// other imports omitted
@SpringBootApplication
@EnableEurekaClient
public class NewReleasesApplication {
public static void main(String... args) {
if (args.length == 1) {
System.setProperty("server.port", args[0]);
}
System.setProperty("spring.config.name", "newreleases-service");
SpringApplication springApplication = new SpringApplication(NewReleasesApplication.class);
springApplication.run(args);
}
}
Listing 13-8NewReleasesApplication
That Takes Port As an Argument
在图 13-7 中,您可以看到启动了三个newreleases-service实例:默认在端口 5000 上,另外两个在端口 5001 和 5002 上。
图 13-7
注册了多个新版本微服务实例
每个实例都是通过创建一个新的 IntelliJ IDEA 启动器并将端口配置为程序参数来启动的,如图 13-8 所示。
图 13-8
用于newreleases-service应用的 IntelliJ IDEA 启动器,端口作为程序参数提供
科技新闻和新发布微服务是基本的,它们不需要使用数据库,并且它们之间没有通信。下一步是开发使用数据库的微服务。
使用数据库的微服务
考虑到我们的服务是反应式的,选择的数据库是 MongoDB。由于前面章节中介绍的文档映射类Book和Account是相互分离的,book-service和account-service是独立的。每个都使用自己的 MongoDB 集合。
微服务公开了一个 REST API,用于各种图书操作:列表、创建、更新、删除、搜索、获取随机图书。在反应章节中已经描述了该服务的实现。
微服务为各种账户操作公开了一个 REST API:列表、创建、删除、更新。在反应章节中已经描述了该服务的实现。
当在微服务之间传输时,默认情况下,数据被序列化到 JSON,使用在 Spring Boot 应用中自动配置的默认org.springframework.http.codec.json.Jackson2JsonEncoder<T>。当到达目的地时,自动配置的org.springframework.http.codec.json.Jackson2JsonDecoder<T>将发出的数据转换回 Java 对象。
这两个微服务都不需要 web 接口,因为它们的 REST APIs 是由唯一一个具有 web 控制台的服务presentation-service调用的。
表 13-1 列出了account-service微服务公开的所有端点。
表 13-1
由account-service微服务公开的端点
上呼吸道感染
|
方法
|
影响
|
| --- | --- | --- |
| /,/ index.htm | 得到 | 返回“帐户服务启动并运行!”。如果不想使用执行机构,检查应用状态很有用。 |
| /account | 得到 | 返回包含对Flux<Account>的引用的响应。 |
| /account/{username} | 得到 | 返回一个响应,其主体表示对与作为路径变量提供的用户名相对应的Mono<Account>的引用。 |
| /account/{username} | 放 | 更新对应于作为路径变量提供的用户名的Account实例,并返回一个响应,其主体表示发出更新实例的Mono<Account>。 |
| /account | 邮政 | 使用来自请求主体的数据创建Account实例,并返回一个响应,其主体表示发出所创建实例的Mono<Account>。该响应的 location 标头中填充了用于访问新实例的 URI。 |
| /account | 删除 | 删除对应于作为路径变量提供的用户名的Account实例,并返回一个空响应。 |
表 13-2 列出了book-service微服务公开的所有端点。
表 13-2
由book-service微服务公开的端点
上呼吸道感染
|
方法
|
影响
|
| --- | --- | --- |
| /,/ index.htm | 得到 | 返回“预订服务启动并运行!”。 |
| /book/random | 得到 | 返回一个响应,其主体表示对包含两本书的 Flux 的引用。 |
| /book/search | 得到 | 返回一个响应,其主体表示对发出与请求主体中提供的BookCriteria细节相匹配的Book实例的Flux<Book>的引用。 |
| /book/by/{isbn} | 得到 | 返回一个响应,其主体表示对与作为路径变量提供的 ISBN 相对应的Mono<Book>的引用。 |
| /book/{isbn} | 放 | 更新对应于作为路径变量提供的 ISBN 的Book实例,并返回一个响应,其主体表示发出更新实例的Mono<Book>。 |
| /book/create | 邮政 | 使用来自请求主体的数据创建Book实例,并返回一个响应,其主体表示发出所创建实例的Mono<Book>。该响应的 location 标头中填充了用于访问新实例的 URI。 |
| /book/delete/{isbn} | 删除 | 删除对应于作为路径变量提供的 ISBN 的Book实例,并返回一个空响应。 |
我们使用
/book/by/{isbn}作为 URI 来通过 ISBN 检索图书实例,因为 ISBN 的类型是String。如果我们用 URI 模板/book/{isbn}声明一个 GET 处理程序方法,用于通过 ISBN 检索书籍,这个模板将匹配/book/random和/book/search上的 GET 请求,而两个 URI 模板的处理程序方法永远不会被调用。另一个解决方案是为{isbn}路径变量声明一个正则表达式,但是采用了最简单的方法。
必须修改每个服务的配置文件来添加一个 MongoDB 部分,因为每个服务都需要访问自己的集合。但是总体来说,实现与书中构建的任何反应式服务没有什么不同。因此,也可以使用WebTestClient来测试它们,以确保它们按预期工作。还可以使用curl测试它们,以确保它们发出由presentation service渲染的元素。
带有 Web 控制台的微服务
已经说过,微服务使用 REST 之类的不可知协议进行通信。account-service、book-service、tech、news-service和newreleases-service通过 HTTP 公开 RESTful APIs(尽管可以使用不同的通信通道,如 JMS 或 AMQP)。
presentation-service更有趣,因为它使用其他四个发出的数据(见图 13-2 )并通过 REST API 调用获取数据。该服务公开了一个最终用户可以访问数据的 web 界面。为了使用由反应式服务产生的数据,Spring 提供了我们在前面章节中已经使用过的WebClient接口。WebClient发送 HTTP 请求并获取多种格式的数据,如 XML、JSON 或数据流。
presentation-service微服务客户端使用一个平衡的WebClient来连接和请求来自其他注册的微服务的数据。平衡的WebClient不知道他们的位置和确切的 URI,因为 SpringCloud 在引擎盖下照顾这个。
presentation-service的实现略有不同,因为它配置了一个 web 接口。默认情况下,Eureka 服务器使用 FreeMarker 模板,因此如果需要不同的实现,必须通过将spring.application.freemarker.enabled属性设置为false来忽略这些模板。配置文件被命名为presentation-service.yml,,其内容如清单 13-9 所示。
spring:
application:
name: presentation-service # Service registers under this name
freemarker:
enabled: false # Ignore Eureka dashboard FreeMarker templates
thymeleaf:
cache: false
prefix: classpath:/templates/
# HTTP Server
server:
port: 7000 # HTTP (Netty) port
address: 0.0.0.0
context-path: /
compression:
enabled: true
# Discovery Server Access
eureka:
client:
registerWithEureka: true
fetchRegistry: true
serviceUrl:
defaultZone: http://localhost:3000/eureka/
instance:
leaseRenewalIntervalInSeconds: 10
preferIpAddress: false
info:
app:
name: presentation-service
description: Spring Cloud Bookstore Service accessing data from all other services
version: 2.0.0-SNAPSHOT
Listing 13-9presentation-service.yml Configuration File
由于presentation-service是一个带有国际化百里香模板的 Spring Boot WebFlux 应用,因此必须添加额外的配置,如前面的反应章节所述。这个项目使用一个用@EnableWebFlux标注的配置类和用@EnableEurekaClient标注的主 Spring Boot 类来配置,使这个应用成为一个 Eureka 客户机。
除了将freemarker.enabled属性设置为false之外,还允许我们的应用使用百里香模板;在之前的配置中,最重要的属性是为该服务设置为true的eureka.client.fetchRegistry。对于其他服务,该属性被设置为false,因为它们不关心其他哪些微服务注册到了 Eureka 服务器。它们被设计成独立的。他们不需要其他微服务提供的数据来完成工作。他们不需要向 Eureka 服务器注册来询问其他注册的服务。presentation-service微服务需要这些微服务来完成它的工作。在向 Eureka 服务器注册了自己之后,它需要知道这些服务器是否也注册了,将这个属性设置为true就可以做到这一点。
与其他微服务的通信由一个名为 Ribbon 的负载平衡器来实现。 11 自 2015 年 Spring Cloud 首次亮相以来,默认的网飞 Ribbon 支持的负载平衡策略就已经到位。Ribbon 是一个客户端负载平衡器,它提供对 HTTP 和 TCP 客户端行为的控制。Ribbon 的客户机组件提供了一组很好的配置选项,比如连接超时、重试、重试算法(指数、有界后退)等等。Ribbon 内置了一个可插拔和可定制的负载平衡组件。当然,因为我们使用的是 Spring Boot,所以没有必要对默认配置做太多调整。默认情况下,当spring-cloud-starter-netflix-eureka-client位于类路径中时,Ribbon 作为依赖项添加到项目中,并且是spring-cloud-netflix-ribbon模块的一部分。Ribbon 由一个用@LoadBalanced标注的平衡WebClient实例使用,以识别现有的微服务和直接调用。
2019 年,SpringCloud 改用自己的负载均衡器解决方案,SpringCloud 网飞 OSS 项目转入维护模式。为了避免默认使用 Ribbon,spring.cloud.loadbalancer.ribbon.enabled属性必须设置为false
由于presentation-service需要从其他四个微服务中访问数据,并且WebClient一旦创建就不可变,我们需要为每个微服务创建一个WebClient bean。我们可以这样做,在任何地方注入四个单独的WebClientbean,或者我们可以使用一个构建器 bean。构建器 bean 在需要时创建平衡的WebClient实例,然后丢弃它们,让垃圾收集器完成它的工作。
@LoadBalanced也可以放在WebClient.Builder上。在清单 13-10 中,您可以看到主 Spring Boot 配置类,其中声明了WebClient.Builder平衡 bean,以及该项目的路由函数。
package com.apress.prospringmvc.presentation;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.web.reactive.function.client.WebClient;
// other imports omitted
@EnableEurekaClient
@SpringBootApplication
public class PresentationServiceApplication {
private static Logger logger = LoggerFactory
.getLogger(PresentationServiceApplication.class);
public static void main(String... args) throws IOException {
System.setProperty("spring.config.name", "presentation-service");
var ctx = SpringApplication.run(PresentationServiceApplication.class, args);
assert (ctx != null);
logger.info("Started ...");
System.in.read();
ctx.close();
}
@LoadBalanced @Bean
WebClient.Builder webClientBuilder() {
return WebClient.builder();
}
@Bean
public RouterFunction<ServerResponse> router(PresentationHandler handler){
return RouterFunctions
.route(GET("/"), handler.main)
.andRoute(GET("/index.htm"), handler.main)
.andRoute(GET("/book/search"), handler.searchPage)
.andRoute(POST("/book/search"), handler::retrieveResults)
.andRoute(GET("/cart/checkout"), handler.checkoutPage)
.andRoute(GET("/customer/register"), handler::registerPage)
.andRoute(GET("/customer/login"), handler.loginPage)
.andRoute(GET("/book/random"), handler::randomFragment)
.andRoute(GET("/tech/news"), handler::newsFragment)
.andRoute(GET("/book/releases"), handler::releasesFragment);
}
}
Listing 13-10PresentationServiceApplication
Configuration File
PresentationHandler类是一个简单的定制类,包含许多用于处理应用请求的HandlerFunction<ServerResponse>。它使用WebClient.Builder将请求转发给其他微服务。Spring Cloud 拦截请求,并使用一个定制的org.springframework.http.client.reactive.ClientHttpRequest implementation,该定制的org.springframework.http.client.reactive.ClientHttpRequest implementation使用org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer<ServiceInstance>进行微服务查找,并促进云中的进程间通信(或者像本场景中那样在一台机器上)。
在编写代码时,如果你需要直接引用
ClientHttpRequest,注意不要把它和org.springframework.http.client包中的非反应性对应物混淆了。
Spring Cloud 为ReactiveLoadBalancer<T>接口提供了现成的实现:配置循环负载平衡的org.springframework.cloud.loadbalancer.core.RoundRobinLoadBalancer类。请求随机分布在任意数量的已配置实例中。如果你想插入你自己的负载平衡器,你可以通过提供你自己的类实现ReactiveLoadBalancer<T>来实现。
现在我们已经介绍了如何识别微服务,让我们通过在控制台中打印微服务 URIs 来检查它是否工作,其中的数据来自于presentation-service。因为我们有四个微服务,所以我们需要四个ReactiveLoadBalancer<ServiceInstance>,因为你知道——不变性。解决方案是使用ReactiveLoadBalancer.Factory<ServiceInstance>来创建这些反应式负载平衡器实例。为此,我们创建一个具有init方法的 bean,该方法为我们的每个服务使用工厂实例创建一个负载平衡器,获取 URI,并在控制台中打印出来。清单 13-11 中描述了ServiceUriBuilder类的代码。
package com.apress.prospringmvc.presentation;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.Response;
import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer;
//other imports committed
@Component
public class ServiceUriBuilder {
private static Logger logger = LoggerFactory.getLogger(ServiceUriBuilder.class);
final ReactiveLoadBalancer.Factory<ServiceInstance> loadBalancerfactory;
public ServiceUriBuilder(ReactiveLoadBalancer.Factory<ServiceInstance> loadBalancerfactory) {
this.loadBalancerfactory = loadBalancerfactory;
}
@PostConstruct
public void getServiceURIs(){
Flux.just("technews-service","newreleases-service","book-service","account-service")
.map(serviceId -> {
ReactiveLoadBalancer<ServiceInstance> loadBalancer = loadBalancerfactory.getInstance(serviceId);
Flux<Response<ServiceInstance>> chosen = Flux.from(loadBalancer.choose());
chosen.map(responseServiceInstance -> {
ServiceInstance server = responseServiceInstance.getServer();
var url = "http://" + server.getHost() + ':' + server.getPort();
logger.debug("--->> {} : {}", serviceId, url);
return url;
}).subscribe();
return serviceId;
}).subscribe();
}
}
Listing 13-11ServiceUriBuilder Class
如您所见,使用 Spring Boot 配置通过spring.application.name属性赋予服务的名称被用作返回ReactiveLoadBalancer<ServiceInstance>实例的loadBalancerfactory.getInstance(String)方法的参数。因为它是一个反应性组件,调用loadBalancer.choose()会返回一个Publisher<Response<ServiceInstance>>(通过将它包装在Flux.from(..)中而转换成一个Flux<T>),它会根据负载平衡算法发出所选择的服务器。ServiceInstance是从Response<T>对象中提取出来的,现在微服务 URI 才可以使用它的元数据放在一起。
如果一切正常,当通过运行 Spring Boot 可执行类来启动presentation-service应用时,你可能已经做了几百次了,你应该会看到一些 URIs 打印在控制台上,类似于清单 13-12 中所示的。
DEBUG c.a.p.presentation.ServiceUriBuilder - --->> technews-service : http://192.168.0.14:4000
DEBUG c.a.p.presentation.ServiceUriBuilder - --->> newreleases-service : http://192.168.0.14:5000
DEBUG c.a.p.presentation.ServiceUriBuilder - --->> newreleases-service : http://192.168.0.14:5001
DEBUG c.a.p.presentation.ServiceUriBuilder - --->> newreleases-service : http://192.168.0.14:5002
DEBUG c.a.p.presentation.ServiceUriBuilder - --->> book-service : http://192.168.0.14:6001
DEBUG c.a.p.presentation.ServiceUriBuilder - --->> account-service : http://192.168.0.14:6002
Listing 13-12Output Generated by the ServiceUriBuilder Bean Containing the Microservices URIs
不要期望所有的 URL 都被打印在一起,或者按照前面清单中的顺序打印。结果是通过调用反应函数获得的,因此 URIs 可能分散在其他日志语句中。
现在我们已经确认了presentation-service应用知道其他微服务的位置,让我们看看WebClient如何访问它们的数据。PresentationHandler类包含项目的所有处理函数,清单 13-13 中描述了它的一些内容。
package com.apress.prospringmvc.presentation;
import org.apache.commons.lang3.tuple.Pair;
// other imports omitted
@Component
public class PresentationHandler {
private final PresentationService presentationService;
public PresentationHandler(PresentationService presentationService) {
this.presentationService = presentationService;
}
final HandlerFunction<ServerResponse> main = serverRequest -> ok()
.contentType(MediaType.TEXT_HTML)
.render("index");
final HandlerFunction<ServerResponse> searchPage = serverRequest -> ok()
.contentType(MediaType.TEXT_HTML)
.render("book/search", Map.of(
"categories", List.of(WEB, SPRING, JAVA),
"bookSearchCriteria", new BookSearchCriteria()));
public Mono<ServerResponse> newsFragment(ServerRequest request) {
final IReactiveSSEDataDriverContextVariable dataDriver =
new ReactiveDataDriverContextVariable(presentationService.techNews(),
1, "techNews");
return ok().contentType(MediaType.TEXT_EVENT_STREAM)
.render("book/search :: #techNews", Map.of("techNews", dataDriver));
}
// other code omitted
}
Listing 13-13The PresentationHandler Class
当调用main处理函数时,它返回index.html视图模板的实现。
调用searchPage处理函数时,它返回需要两个模型属性的search.html视图模板的实现:categories和一个BookSearchCriteria实例。
newsFragment方法返回一个反应视图片段,其中填充了由presentationService.techNews()调用返回的Flux<String>发出的数据。
PresentationService bean 是使用平衡的WebClient.Builder来调用微服务presentation-service的 bean。清单 13-14 显示了techNews()方法的代码,该方法返回显示在search.html页面上的Flux<String>发布的随机科技新闻。
在同一个清单中,newReleases()方法检索由newreleases-service作为Flux<Book>返回的新书发布。
package com.apress.prospringmvc.presentation;
import org.springframework.web.reactive.function.client.WebClient;
// other imports omitted
@Service
public class PresentationService {
private static final String TECHNEWS_SERVICE_URI = "http://technews-service";
private static final String NEWRELEASES_SERVICE_URI = "http://newreleases-service";
private WebClient.Builder webClientBuilder;
public PresentationService(WebClient.Builder webClientBuilder) {
this.webClientBuilder = webClientBuilder;
}
public Flux<Book> newReleases() {
return webClientBuilder.baseUrl(NEWRELEASES_SERVICE_URI).build()
.get().uri("/book/releases")
.retrieve()
.bodyToFlux(Book.class).map(book -> {
logger.debug("Retrieved book: {}", book);
return book;
});
}
public Flux<String> techNews() {
return webClientBuilder.baseUrl(TECHNEWS_SERVICE_URI).build()
.get().uri("/tech/news")
.retrieve()
.bodyToFlux(String.class).map(val -> {
logger.debug("Retrieved val : {}", val);
return val;
});
}
// other code omitted
}
Listing 13-14The PresentationService Class
Spring 使用构造函数注入了WebClient.Builder。
这个类中的大多数方法看起来都相似,只要微服务启动,负载平衡器就知道将请求发送到哪里。
你可能已经注意到TECHNEWS_SERVICE_URI和NEWRELEASES_SERVICE_URI不是真正的 URIs,没有端口。它们是通过在服务名前面加上“http://”来创建的。负载均衡器拦截对这些 URL 的WebClient请求,并使用来自org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools类的reconstructURI()方法重新构建发送请求的 URI。 十二
共享课程
presentation-service处理Book和Account对象,由newreleases-service、books-service,和account-service通过反应流提供。
books-service项目包含一个名为Book的 MongoDB 文档类来表示 book 对象。
account-service项目包含一个名为Account的 MongoDB 文档类来表示 account 对象。
newreleases-service项目包含一个非常简单的Book类,有三个属性,对即将出版的书很重要:标题、作者和年份。随机的Book实例是使用无限的反应流创建和发射的。presentation-service向“/book/releases”发出 GET 请求。这些Book实例是使用一个反应流发送给它的。
presentation-service项目包含一个Book和一个Account类,这两个简单的 POJOs 专用于保存 web 界面中显示的数据。
这是一种多余的,也许是懒惰的方法,以保持项目尽可能的分离。可能有一个包含所有公共类的项目被添加到它们的类路径中。但是借助 JSON 的魔力,在不同的项目中拥有不同的类是可能的,并且序列化和反序列化仍然有效。
在所有微服务启动后,您应该能够在 Eureka web 应用中看到它们,并访问书店应用的 web 界面(参见图 13-9 )。
图 13-9
在尤里卡注册的多个微服务和书店微服务应用显示从technews-service和newreleases-service接收的数据
摘要
本章简要介绍了云开发的世界。
我们向您展示了单片和微服务架构之间的区别,并解释了为什么由多个微服务组成的应用更适合云环境。
Spring Cloud 使得在本地环境中实践云开发变得容易。通过将 Spring Eureka libs 添加到项目中,可以很容易地将 Spring Boot 应用转换为微服务。
您了解了如何将一个整体分割成多个独立的微服务,并使用发现服务器来注册它们,确保它们可以相互通信。每个 Spring Boot 微服务都可以部署到 Kubernetes 集群的私有云或容器中自己的 VM 上。只要发现服务器可以访问,服务仍然可以找到彼此。
Footnotes 12
https://cloud.google.com/apigee/
3
https://aws.amazon.com/api-gateway/
4
5
6
https://spring.io/projects/spring-cloud
7
https://spring.io/projects/spring-vault
8
https://docs.spring.io/spring-boot/docs/current/reference/html/production-ready-features.html
9
https://github.com/Netflix/eureka/wiki/Understanding-eureka-client-server-communication
10
11
https://cloud.spring.io/spring-cloud-netflix/multi/multi_spring-cloud-ribbon.html
12
*