第3章中台服务中间件开发 视频讲解 中台应用所提供的服务主要为对后台服务调用的组装和为前台提供安全、可靠、高效的接口服务。中台应用不直接访问数据,数据处理均为后台服务的接口所提供。因为前台应用与中台应用已经实现了完全分离的设计,中台接口服务将会暴露在外部网络环境之中,所以在中台应用的开发中,必须有合理的安全设计及策略,以保证数据访问的安全和合法性。 本章提供两个中台应用的实例,第一个是基于微服务Restful协议的服务接口的开发,第二个是使用高性能的gRPC协议的接口开发。第一个应用实例还提供了用户访问控制的安全管理设计。 本章实例代码统一放在中台应用项目cloudmiddle中,这个项目由以下三个模块组成。 ◇ middlegrpc: 使用gRPC协议通信的中台应用。 ◇ middleproto: 基于ProtoBuf协议定义gRPC服务的模块。 ◇ middlerest: 使用Restful协议通信的中台应用。 3.1基于Restful协议的接口调用设计 本章实例的中台应用开发仍使用Spring Cloud工具套件实现,所以项目对象模型的配置及其应用与注册中心的连接和配置与第2章中的后台应用微服务开发时的配置一样。 在第2章的后台应用开发中,已经为其所提供的接口服务同时开发了客户端的调用程序,所以在中台应用开发中,只要引用后台应用的客户端程序,就可以像调用本地方法一样调用后台服务所提供的接口。 在本书的实例代码中,打开中台应用项目cloudmiddle,然后打开模块middlerest。这是一个基于微服务Restful协议进行设计的应用项目,也可以把它看作是一个为特定的前台应用设计的服务中间件。为了方便演示和讲解,实例项目中把两个中台应用放在同一个项目工程的不同模块中。如果是实际生产中开发的应用项目,则不建议这样做,应该为每一个应用创建一个独立的项目工程。 确认在第2章的项目cloudbackend中,已经使用Maven项目工具执行过install,这样就可以在模块middlerest的项目对象模型pom.xml中,增加如下所示的依赖引用。 com.demo backend-client 1.0.0-SNAPSHOT 如果需要引用多个应用项目的客户端程序,可以使用类似的方法进行配置。 上述backendclient引用已经包含了后台应用中用户服务的客户端和商品服务的客户端的引用。下面将通过backendclient引用来实现中台应用与后台接口的对接,然后为前台应用提供服务。首先,在中台应用middlerest的主程序RestApplication中增加两个注解,启用FeignClient的功能并进行相关的配置,程序代码如下所示。 package com.demo.middle.rest; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.context.annotation.ComponentScan; /** * Rest Service * @author bill * @date 2020-09-29 */ @SpringBootApplication @EnableDiscoveryClient @ComponentScan(basePackages = "com.demo") @EnableFeignClients(basePackages = "com.demo") public class RestApplication { public static void main(String[] args) { SpringApplication.run(RestApplication.class, args); } } 这段程序中,通过注解@ComponentScan可以正常加载依赖配置中所引用的客户端程序,注解@EnableFeignClients启用了FeignClient客户端的功能。 接下来,创建控制器RestWebController,通过这个控制器可以实现对后台客户端程序的引用,并为前台提供接口服务设计,代码如下所示。 package com.demo.middle.rest.controller; import com.demo.backend.client.feign.GoodsClient; import com.demo.backend.client.feign.UserClient; import com.demo.backend.client.vo.GoodsVo; import com.demo.backend.client.vo.UserVo; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; /** * 中台微服务 * @author bill * @date 2020-09-29 */ @RequestMapping("/rest") @RestController @Api(value = "中台服务", tags = "中台微服务API") @Slf4j public class RestWebController { @Autowired private UserClient userClient; @Autowired private GoodsClient goodsClient; @RequestMapping(value = "/getUserInfo", method = RequestMethod.GET) @ApiOperation(value = "用户信息查询", response = UserVo.class) public Object getUserInfo(@RequestParam("userName") String userName) { log.info("调用后台查询用户,使用参数:{}", userName); return userClient.getUserInfo(userName); } @RequestMapping(value = "/getGoodsInfo", method = RequestMethod.GET) @ApiOperation(value = "商品信息查询", response = GoodsVo.class) public Object getGoodsInfo(@RequestParam("name") String name) { log.info("调用后台进行商品查询,使用参数:{}", name); return goodsClient.getGoodsInfo(name); } } 这段程序引用了后台用户服务的客户端UserClient和商品服务的客户端GoodsClient,这样在中台应用中就可以直接实现对后台应用接口的调用。这里使用方法getUserInfo()调用了后台客户端UserClient的同名方法,实现了用户信息查询的功能,然后将调用结果以Object的方式返回。这个返回对象将按后台原来的返回结果,即MessageSet的信息封装方式,返回给调用者。 有关商品信息查询的设计在方法getGoodsInfo()的定义中实现,即通过调用GoodsClient的同名方法实现商品信息查询的功能,然后按照后台接口原来返回的结果直接返回给调用者。 如果中台应用仅实现接口转接,上述程序就已经完成了设计。但在实际生产开发中,通常不会这么简单。根据前台的业务请求,可能会出现一次请求调用多个后台接口的情况以及分布式事物处理的情形,还可能涉及数据的裁剪和整合。 中台应用middlerest已经加入了Swagger文档工具的相关设计。从控制器RestWebController的代码中可以看到,已经加入了Swagger的相关注解配置。 如果这个中台应用没有其他方面的附加设计,就可以启动应用程序,通过Swagger的页面UI程序swaggerui.html来查看应用的接口文档,如图31所示。 图31中台应用服务接口文档 需要注意的是,如果需要进行相关的接口调试,还必须启动相关的后台应用服务。 中台服务调用后台微服务的接口是在内部网络中完成的,其数据访问是在安全环境中进行的。而中台应用给前台应用提供服务必须将接口服务暴露在外网环境中,所以为了提高接口调用的安全性,必须为其增加安全访问控制管理方面的设计。 3.2用户访问控制与安全设计 用户访问控制设计,即用户登录系统的身份确认设计,一般通过用户名和密码的方式进行用户身份确认,登录成功后即可授权用户访问服务接口。本章实例的用户访问控制的基本流程设计如图32所示。 图32用户访问控制时序图 对于图32所示的主要流程,简要说明如下。 ◇ 用户在客户端中使用用户名和密码进行登录。 ◇ 服务端接收到请求后对用户身份进行验证,验证成功,则发放令牌,否则拒绝用户访问。 ◇ 客户端取得令牌后,在本地中保存令牌,然后凭令牌进行业务接口调用。 ◇ 服务端收到接口调用请求时,先解析令牌,检查其合法性,合法则同意其业务请求,否则拒绝访问。 ◇ 用户退出登录状态时,清除本地令牌即可。服务端不保存相关令牌。 在实际生产设计中,还可以根据用户的身份进行用户的权限角色管理,针对不同用户可以授予不同的访问权限。在本章实例设计中,省略了角色管理的功能,赋予了所有用户相同的管理员角色。 用户访问控制将主要使用Spring Cloud工具套件中的安全组件Security,并结合JWT令牌来实现。在中台项目的应用模块middlerest的项目对象模型pom.xml中增加如下依赖引用。 org.springframework.cloud spring-cloud-starter-security io.jsonwebtoken jjwt 0.9.1 以上依赖引用在引用Security组件的同时,还引用了jjwt组件。这个组件主要用来为客户端生成JSON Web令牌(JSON Web Token,JWT)。 在传统的设计开发中,一般使用会话控制(Session)进行用户的登录认证管理。用户登录之后,必须在服务端保存用户的登录状态。而使用JWT的方式,用户登录之后取得令牌,服务端将不再需要保存用户的登录状态。所以JWT的方式更适合在分布式环境中使用,一方面,可以减轻服务端的压力,另一方面,如果服务端应用有多个运行的副本,也不需要进行用户登录状态的同步处理。 一个完整的JWT一般由头部(Header)、载荷(Payload)和签证(Signature)三部分内容组成。除此之外,还可以使用声明(Claims)方式附加其他内容。JWT以私钥方式进行加密,并且可以根据需要设定有效期限,或者根据安全级别规定令牌的使用次数,以保证在传输过程中提高令牌的安全性。 有关JWT的详细说明,有兴趣的读者可通过下列链接访问其官网进行查看。 https://jwt.io/introduction/ 下面将详细介绍使用Security组件和JWT令牌进行安全设计的步骤。 3.2.1Web安全策略配置 使用Web安全策略配置,可以通过创建一个配置类WebSecurityConfig实现。在配置类中,通过继承Spring Secutity组件中的WebSecurityConfigurerAdapter配置类实现自定义的安全策略配置。配置类WebSecurityConfig的代码如下所示。 package com.demo.middle.rest.configs.security; ... @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true, jsr250Enabled = true) public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private MyUnauthorizedHandler myUnauthorizedHandler; @Autowired private MyAccessDeniedHandler myAccessDeniedHandler; @Autowired private MyUserDetailsService myUserDetailsService; @Autowired private MyAuthenticationFilter myAuthenticationFilter; @Bean static BCryptPasswordEncoder getBCryptPasswordEncoder() { BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); return bCryptPasswordEncoder; } @Bean(name = BeanIds.AUTHENTICATION_MANAGER) @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Autowired public void configureAuthentication(AuthenticationManagerBuilder builder) throws Exception { builder.userDetailsService(myUserDetailsService).passwordEncoder(getBCryptPasswordEncoder()); } @Override protected void configure(HttpSecurity httpSecurity) throws Exception { httpSecurity.csrf().disable().headers().frameOptions().disable() .and().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and().authorizeRequests() .antMatchers(HttpMethod.OPTIONS, "/**").permitAll() .antMatchers("/swagger-ui.html", "/webjars/springfox-swagger-ui/**", "/swagger-resources/**", "/v2/api-docs/**").permitAll() .antMatchers("/static/**", "/authentication/**").permitAll() .anyRequest().authenticated() .and().headers().cacheControl(); httpSecurity.addFilterBefore(myAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); httpSecurity.exceptionHandling().authenticationEntryPoint(myUnauthorizedHandler).accessDeniedHandler(myAccessDeniedHandler); } } 这个配置类的主要实现功能说明如下。 ◇ 使用注解@EnableWebSecurity启用了Web安全管理功能。 ◇ 在程序中指定使用BCryptPasswordEncoder工具,进行密码加密和密码验证。 ◇ 在Web请求策略配置中,禁用了跨站伪造请求限制,并通过使用permitAll()方法,对一些资源文件、Swagger文档链接及其资源、登录请求的链接等设置访问许可。 ◇ 配置了安全用户服务MyUserDetailsService、安全检查过滤器MyAuthenticationFilter、用户鉴权处理器myUnauthorizedHandler和访问授权处理器myAccessDeniedHandler等服务。这些服务的设计将在后续章节进行介绍。 3.2.2实现安全用户管理 在用户访问控制管理之中需要调用应用实例的用户服务,所以需要实现Spring Security安全组件中的用户服务接口UserDetailsService和用户详细信息接口UserDetails。 首先,创建一个用户服务类MyUserDetailsService,通过这个服务类,可以从后台应用的用户信息查询接口中取得用户的详细信息,代码如下所示。 package com.demo.middle.rest.configs.security.userdetail; ... /** * 安全用户服务 * @author bill * @since 2020-10-12 */ @Component public class MyUserDetailsService implements UserDetailsService { @Autowired private UserClient userClient; @Override public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException { //根据用户名查询用户 MessageSet messageSet = userClient.getUserInfo(userName); if(messageSet != null && messageSet.getCode() != 200){ throw new UsernameNotFoundException("用户查询异常: "+messageSet.getMessage()); } Object object = messageSet.getResult(); Gson gson = new Gson(); UserVo user = gson.fromJson(gson.toJson(object), UserVo.class); //默认使用管理员角色 List authorities = new ArrayList<>(); authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); MyUserDetails myUserDetails = new MyUserDetails(userName, user.getPassword(),true, authorities, user); return myUserDetails; } } 这段代码中,通过创建MyUserDetailsService实现了Sping Security组件的UserDetailsService接口,该接口只有一个loadUserByUsername()方法。程序中实现了loadUserByUsername()方法: 通过后台应用的客户端UserClient调用了getUserInfo用户查询方法,取得用户信息的JSON结构数据,然后通过对象转换将JSON数据转换为对象模型UserVo。对于用户权限的分配,因为实例中省略了用户角色的管理,所以这里提供一个管理员角色为当前用户分配权限。程序最后返回包含用户权限的MyUserDetails类的用户详细信息。 上述程序中用到的用户明细类MyUserDetails的实现代码如下所示。 package com.demo.middle.rest.configs.security.userdetail; import com.demo.backend.client.vo.UserVo; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.Collection; public class MyUserDetails implements UserDetails { private String userName; private String password; private boolean isAccountNonLocked; private Collection authorities; private UserVo user; public MyUserDetails(String userName, String password, boolean isAccountNonLocked, Collection authorities, UserVo userVo) { this.userName = userName; this.password = password; this.isAccountNonLocked = isAccountNonLocked; this.authorities = authorities; this.user = userVo; } @Override public String getUsername() { return userName; } @Override public String getPassword() { return password; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return isAccountNonLocked; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } @Override public Collection getAuthorities() { return authorities; } public UserVo getUser(){ return user; } } 这段代码通过MyUserDetails用户明细类,实现了Spring Security组件的UserDetails接口,并新建了一个构造函数MyUserDetails(),这个函数可以用来生成一个包含用户名、密码、用户权限集合等参数的登录用户详细信息。在该类中还实现了UserDetails接口的所有方法。这段代码的最后增加一个自定义方法getUser()返回用户对象模型。 3.2.3用户登录验证 实现了Spring Security组件的用户服务的相关接口之后,就可以进行用户登录验证的设计。登录验证通过LoginController控制器实现,程序代码如下所示。 package com.demo.middle.rest.configs.security.controller; ... @RestController @Slf4j @Api(value = "用户登录", tags = "用户登录API") public class LoginController { @Autowired private AuthenticationManager authenticationManager; @RequestMapping(value = "/authentication/userLogin", method = RequestMethod.POST) @ApiOperation(value = "用户登录并生成Token", response = String.class) public void login(@RequestParam("userName") String userName, @RequestParam ("password") String password, HttpServletResponse response) throws IOException { try { Preconditions.checkArgument(StringUtils.isNotEmpty(userName), "用户名不能为空"); Preconditions.checkArgument(StringUtils.isNotEmpty(password), "密码不能为空"); //登录验证 UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userName, password); Authentication authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken); MyUserDetails myUserDetails = (MyUserDetails) authenticate.getPrincipal(); Collection authorities = myUserDetails.getAuthorities(); //角色列表 List authorityList = new ArrayList<>(); if (authorities != null && !authorities.isEmpty()) { authorities.forEach(authority -> authorityList.add(authority.getAuthority())); } //获取用户详细信息 UserVo user = myUserDetails.getUser(); UserVo userTemp = user; userTemp.setPassword(""); Map claims = new HashMap<>(16); claims.put(SecurityConstant.USER, JSON.toJSONString(userTemp)); claims.put(SecurityConstant.AUTHORITIES, JSON.toJSONString(authorityList)); //登录成功生成Token long currentTimeMillis = System.currentTimeMillis(); String token = SecurityConstant.TOKEN_PREFIX + Jwts.builder().setSubject(user.getUserName()) .addClaims(claims) .setExpiration(new Date(currentTimeMillis + SecurityConstant.TOKEN_EXPIRE_TIME * 60 * 1000)) .signWith(SignatureAlgorithm.HS512, SecurityConstant.TOKEN_SIGN_KEY) .compressWith(CompressionCodecs.GZIP).compact(); log.info("登录成功,返回Token"); //返回Token MessageSet messageSet = MessageMapper.ok(token); String tokenStr = JSON.toJSONString(messageSet); response.setHeader("Access-Control-Allow-Origin", "*"); response.setStatus(200); response.setHeader("Content-type", "application/json; charset=utf-8"); response.setCharacterEncoding("utf-8"); response.setContentType("application/json;charset=utf-8"); response.getOutputStream().write(tokenStr.getBytes("utf-8")); } catch (Exception e) { String message = e.getMessage(); if (e instanceof UsernameNotFoundException || e instanceof BadCredentialsException) { message = "用户名或者密码不正确"; } if (e instanceof LockedException) { message = "账户已被锁定"; } MessageSet messageSet = MessageMapper.mapper(401, message); String json = JSON.toJSONString(messageSet); response.setHeader("Access-Control-Allow-Origin", "*"); response.setStatus(200); response.setHeader("Content-type", "application/json; charset=utf-8"); response.setCharacterEncoding("utf-8"); response.setContentType("application/json;charset=utf-8"); response.getOutputStream().write(json.getBytes("utf-8")); } } } 这段代码实现的功能说明如下。 ◇ 使用Spring Security组件的UsernamePasswordAuthenticationToken()方法对登录用户进行身份验证。 ◇ 验证成功后,从用户详细信息中提取角色列表,为生成令牌做准备。 ◇ 从用户详细信息中提取用户基本信息,弃除密码后,为生成令牌做准备。 ◇ 使用用户名生成令牌,设定有效期,并附加用户基本信息和用户角色列表。 ◇ 将加密和压缩之后的令牌返回给客户端。 ◇ 在上述过程中,如果用户验证失败,则给出相关错误信息,如用户密码输入错误或用户已被锁定等,同时终止认证流程。 3.2.4访问控制过滤器设计 用户成功登录后,再次进行业务接口请求时,必须附加取得的令牌进行访问。 在本书实例设计中,约定客户端将取得的令牌放入接口访问的请求头(这里特指Request对象的Header参数)之中,然后由服务端对令牌进行验证。 在服务端中,使用MyAuthenticationFilter过滤器实现对客户端请求中的令牌进行验证的功能,代码如下所示。 package com.demo.middle.rest.configs.security.filter; ... /** * 访问控制过滤器 * @author bill * @since 2020-10-12 */ @Component @Slf4j public class MyAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { //Token分析 String authHeader = request.getHeader(SecurityConstant.TOKEN_REQUEST_HEADER); if (StringUtils.isEmpty(authHeader)) { authHeader = request.getParameter(SecurityConstant.TOKEN_REQUEST_PARAM); } if (authHeader != null && authHeader.startsWith(SecurityConstant.TOKEN_PREFIX)) { String auth = authHeader.substring(SecurityConstant.TOKEN_PREFIX.length()); try { Claims claims = Jwts.parser().setSigningKey(SecurityConstant.TOKEN_SIGN_KEY).parseClaimsJws(auth).getBody(); String username = claims.getSubject(); String userStr = (String) claims.get(SecurityConstant.USER); String authorityListStr = (String) claims.get(SecurityConstant. AUTHORITIES); SecurityContext securityContext = SecurityContextHolder.getContext(); if (securityContext != null) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null) { UserVo user = JSON.parseObject(userStr, UserVo.class); //角色列表 List authorityList = JSON.parseObject(authorityListStr, List.class); List authorities = new ArrayList<>(); if (authorityList != null && !authorityList.isEmpty()) { authorityList.forEach(authority -> authorities.add(new SimpleGrantedAuthority(authority))); } MyUserDetails myUserDetails = new MyUserDetails(username, "", true, authorities, user); //验证身份 UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(myUserDetails, null, authorities); usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); } } log.info("Token解析正常"); }catch (ExpiredJwtException e) { MessageSet messageSet = MessageMapper.mapper(401, "登录已过期"); String str = JSON.toJSONString(messageSet); response.setHeader("Access-Control-Allow-Origin", "*"); response.setStatus(200); response.setHeader("Content-type", "application/json; charset=utf-8"); response.setCharacterEncoding("utf-8"); response.setContentType("application/json;charset=utf-8"); response.getOutputStream().write(str.getBytes("utf-8")); return; }catch (Exception e) { MessageSet messageSet = MessageMapper.mapper(401, "Token解析错误"); String str = JSON.toJSONString(messageSet); response.setHeader("Access-Control-Allow-Origin", "*"); response.setStatus(200); response.setHeader("Content-type", "application/json; charset=utf-8"); response.setCharacterEncoding("utf-8"); response.setContentType("application/json;charset=utf-8"); response.getOutputStream().write(str.getBytes("utf-8")); return; } } else if (authHeader != null && authHeader.equals("USER_TEST")) { //swagger文档页面授权 UserVo user = new UserVo(); user.setUserName("admin"); user.setPassword(new BCryptPasswordEncoder().encode("123456")); List authorities = new ArrayList<>(); authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN")); MyUserDetails myUserDetails = new MyUserDetails(user.getUserName(), "", true, authorities, user); UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(myUserDetails, null, authorities); usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); } else if (authHeader != null && authHeader != "") { MessageSet messageSet = MessageMapper.mapper(401, "Token异常"); String messageStr = JSON.toJSONString(messageSet); response.setHeader("Access-Control-Allow-Origin", "*"); response.setStatus(200); response.setHeader("Content-type", "application/json; charset=utf-8"); response.setCharacterEncoding("utf-8"); response.setContentType("application/json;charset=utf-8"); response.getOutputStream().write(messageStr.getBytes("utf-8")); return; } filterChain.doFilter(request, response); } } 这段代码实现的功能说明如下。 ◇ 从客户端的请求头中取得令牌,分析令牌的合法性和时效,如果令牌合法并且还未过期,则通过请求。如果令牌解析不通过,则给出相关错误提示,并拒绝其接口访问请求。 ◇ 为了能够正常使用Swagger文档的功能,为Swagger的UI页面请求配置了一个测试令牌USER_TEST。通过使用这个令牌,可以正常打开swaggerui.html页面,并查看接口文档说明和进行相关的测试。 3.2.5用户鉴权处理器设计 通过安全管理设计之后,接口的访问将处于系统的安全保护之中,当用户进行接口调用时,会触发用户鉴权处理器MyUnauthorizedHandler,程序的设计如下所示。 package com.demo.middle.rest.configs.security.handler; import com.alibaba.fastjson.JSON; import com.demo.backend.client.utils.MessageMapper; import com.demo.backend.client.utils.MessageSet; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @Component public class MyUnauthorizedHandler implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException { MessageSet messageSet = MessageMapper.mapper(401,"用户未登录"); String json = JSON.toJSONString(messageSet); response.setHeader("Access-Control-Allow-Origin", "*"); response.setHeader("Content-type", "application/json; charset=utf-8"); response.setCharacterEncoding("utf-8"); response.setContentType("application/json;charset=utf-8"); response.getOutputStream().write(json.getBytes("utf-8")); } } 当用户在未登录而访问受保护的接口时,将收到401错误和“用户未登录”的提示信息。 3.2.6授权验证处理器设计 用户登录之后,对每一个接口的访问都可以进行权限设置。针对用户接口访问的授权验证,定义了一个授权验证处理器MyAccessDeniedHandler,程序的实现代码如下所示。 package com.demo.middle.rest.configs.security.handler; import com.alibaba.fastjson.JSON; import com.demo.backend.client.utils.MessageMapper; import com.demo.backend.client.utils.MessageSet; import lombok.extern.slf4j.Slf4j; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; @Component @Slf4j public class MyAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException exception) throws IOException { MessageSet messageSet = MessageMapper.mapper(403, "没有权限"); String json = JSON.toJSONString(messageSet); response.setHeader("Access-Control-Allow-Origin", "*"); response.setHeader("Content-type", "application/json; charset=utf-8"); response.setCharacterEncoding("utf-8"); response.setContentType("application/json;charset=utf-8"); response.getOutputStream().write(json.getBytes("utf-8")); } } 当用户访问未获得授权的链接时,将被拒绝访问,并返回403错误,提示“没有权限”。 实例代码中,因为省略了权限管理的设计,对所有登录用户都授予了管理员的角色,所以只要登录成功,对所有接口都具有访问权限。 3.2.7跨域访问配置 因为前台应用和中台应用使用了完全分离的设计,在应用发布时将使用不同的域名提供服务,所以对于来自前台应用的客户端请求,还存在跨域访问的问题。在实例代码中,为了简化跨域访问的设计,使用CorsConfig配置类开放所有跨域访问的限制,代码如下所示。 package com.demo.middle.rest.configs; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; @Configuration public class CorsConfig { private CorsConfiguration buildConfig() { CorsConfiguration corsConfiguration = new CorsConfiguration(); corsConfiguration.setAllowCredentials(true); corsConfiguration.addAllowedOrigin("*"); corsConfiguration.addAllowedHeader("*"); corsConfiguration.addAllowedMethod("*"); corsConfiguration.setMaxAge(36000L); return corsConfiguration; } @Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", buildConfig()); return new CorsFilter(source); } } 需要注意的是,开放跨域请求限制之后,虽然简化了程序设计,但将面临跨站请求伪造攻击的风险。所以为了保障系统的安全,必须妥善保护好令牌签名的密钥,或者提高令牌使用的安全级别。 3.2.8在安全管理环境中使用Swagger文档 3.2.4节已经为Swagger组件的页面访问增加了一个测试令牌。为了使用这个令牌,必须在Swagger的配置类Swagger2Config中增加相关的配置。经过修改之后的Swagger配置如下所示。 package com.demo.middle.rest.configs; ... import java.util.Arrays; import java.util.List; @Configuration @EnableSwagger2 public class Swagger2Config { @Bean public Docket buildDocket() { List list = Arrays.asList( new ParameterBuilder() .name("Authorization") .defaultValue("USER_TEST") .description("测试令牌") .modelRef(new ModelRef("string")) .parameterType("header") .build() ); return new Docket(DocumentationType.SWAGGER_2) .apiInfo(buildApiInfo()).globalOperationParameters(list).select() .apis(RequestHandlerSelectors.basePackage("com.demo")) .paths(PathSelectors.any()) .build(); } private ApiInfo buildApiInfo() { return new ApiInfoBuilder() .title("中台服务") .description("接口文档") .version("1.0") .build(); } } 这段程序将测试令牌USER_TEST放入文档打开的页面的请求头的Authorization参数中,这样每个打开的页面都会带上这个测试令牌。 下面启动程序,打开Swagger的UI页面swaggerui.html进行测试。注意,因为这里调用了后台的用户服务,所以必须同时启动后台的用户服务。通过如下链接可以打开中台应用的Swagger文档页面。 http://localhost:8011/swagger-ui.html 打开文档首页之后,单击“用户登录API”选项,打开登录接口的文档页面。在文档页面中,单击Try it out按钮,打开接口测试页面,如图33所示。 图33用户登录接口测试页面 在图33中,用户登录接口测试页面已经自动带上了测试令牌USER_TEST。下面可以使用登录接口进行用户登录测试。在用户登录测试中,如果输入了正确的用户名和密码,验证登录成功,则会返回一个令牌,具体细节如下所示。 { "code": 200, "message": "操作成功", "result": "TokenPrefix eyJhbGciOiJIUzUxMiIsInppcCI6IkdaSVAifQ.H4sIAAAAAAAAAKtWKi5NUrJSSkzJzcxT0lEqLU4tAnKrY4AiKUUxSlYxSk97pz_duPFpz64Xe9c_2b3t6Z6Gp_0Tn87f9XTOhhglnRilzBSwMkMjYxNTM7BIXmJuKljs-ZQVzzq2P53Q-3TtdLBMQWJxcXl-EUQHRCQjPw-i2NDY1AAIQObAZErywTJ6evpAVFySWJKZrJ-Zm5ieWqyfbKhXkJcOVlicWgG1bTuYD_KDH8wJYI_FKNUC_ZZYWpKRX5RZkplaDPRidIxSkL-Pa7yji6-nX4xSLFBBakWBkpWhmYGRmZmBubl5LQDWmiSRHQEAAA.FAx3Mfm-H2uUzoQgpdXB2glBtMOE2PjFGCSopzsIwI40_BuPoANLpJKLH3jhS7mS8wnx_5o3hSH3Gq-yxicuSQ" } 正确的用户名和密码为admin和123456,返回结果中的result为加密和压缩之后的令牌字符串。 如果用户名和密码验证不通过,则将返回如下所示的错误信息。 { "code": 401, "message": "用户名或者密码不正确" } 需要注意的是,在后台用户服务中,只提供了一条模拟数据,所以这里输入任何用户名都没有关系,但是密码必须输入正确。 通过上面的安全管理的设计,中台应用middlerest提供的接口服务已经得到有效的安全保护,针对前台应用的接口请求必须在通过登录之后,才能正常访问。 3.3基于gRPC协议的中台应用设计 Spring Cloud工具套件一般使用Restful协议进行接口通信。在中台应用设计中,有时需要处理一些对访问速度和性能有更高要求的请求,这时Restful协议不能表现出性能优势。在本书提供的中台应用的另一个实例中,将使用更加高效的通信协议gRPC进行接口服务的开发。gRPC是一个由Google公司提供的高性能的RPC开发框架,它使用基于HTTP/2的标准进行设计,并使用ProtoBuf协议定义服务。gRPC使用二进制编码方式传输数据,并且支持流式的传输方式。根据gRPC这些特性,可以开发出对资源和速率有更高要求的应用服务。 3.3.1使用ProtoBuf协议定义服务 ProtoBuf(Protocol Buffers)是一种与平台和语言无关的序列化结构数据的协议。ProtoBuf使用结构数据序列化方法,可类比XML,但比XML更小,所以使用起来更快、更高效。 下面通过在本书实例的中台应用项目cloudmiddle,来说明如何使用gRPC协议进行接口服务的开发。 首先在模块middleproto的项目对象模型pom.xml中,配置了如下依赖引用。 cloud-middle com.demo 1.0.0-SNAPSHOT 4.0.0 middle-proto jar 1.20.0 1.5.0.Final 0.5.0 3.3.0 4.1.15.Final io.grpc grpc-protobuf ${grpc.java.version} io.grpc grpc-stub ${grpc.java.version} io.grpc grpc-netty ${grpc.java.version} io.netty netty-common ${grpc.netty.version} kr.motd.maven os-maven-plugin ${os.plugin.version} org.xolstice.maven.plugins protobuf-maven-plugin ${protobuf.plugin.version} com.google.protobuf:protoc:${protoc.plugin.version}:exe:${os.detected.classifier} grpc-java io.grpc:protoc-gen-grpc-java: ${grpc.java.version}:exe:${os.detected.classifier} compile compile-custom 上面的依赖配置通过引用gRPC开发框架的一些组件和插件,可以使用基于ProtoBuf协议定义的服务,通过Maven项目工具生成相关的适合Spring Boot开发架构使用的Java类对象。 对应后台应用的商品服务,实例中使用ProtoBuf协议定义gRPC的商品服务,代码如下所示。 syntax = "proto3"; option java_multiple_files = true; option java_package = "com.demo.grpc.goods.service"; option java_outer_classname = "GoodsServiceProto"; package com.demo.goods.service; // 服务定义 service GoodsService { rpc GetGoodsInfo (GoodsRequest) returns (GoodsReply) { } rpc GetGoodsList (ListSizeRequest) returns (GoodsListResponse) { } } // 请求参数 message GoodsRequest { string goodsId = 1; } message ListSizeRequest{ int32 size = 1; } // 商品信息 message GoodsReply { string name = 1; string price = 2; int32 sums = 3; string image = 4; } //商品列表 message GoodsListResponse{ repeated GoodsReply goodsList = 1; } 这段代码说明如下。 ◇ 第一行代码为ProtoBuf的版本号,这里使用proto3。 ◇ option java_package指定Java类对象的包结构。 ◇ package是与非Java语言(如Node.js等)通信时使用的包结构。 ◇ 定义了两个商品查询服务,分别为GetGoodsInfo和GetGoodsList,用来查询商品信息和商品列表。 ◇ GoodsRequest和GoodsReply分别为请求参数和返回参数的定义。 定义了商品服务后,就可以在模块middleproto中使用Maven项目工具执行编译。通过编译之后,就生成可调用的类和各种数据对象,结果如图34所示。 图34基于ProtoBuf协议定义的商品服务的编译结果 3.3.2节将使用这里生成的商品服务类对象进行相关接口服务的开发。 3.3.2gRPC服务端开发 基于gRPC协议的服务端将使用Spring Cloud工具套件进行开发。打开中台项目cloudmiddle的模块middlegrpc,在模块根目录的项目对象模型pom.xml中,增加如下依赖引用。 net.devh grpc-server-spring-boot-starter 2.2.1.RELEASE com.demo middle-proto ${project.version} com.demo backend-client 1.0.0-SNAPSHOT grpcserverspringbootstarter是为Spring Boot框架提供的一些gRPC服务端的支持,middleproto引用了3.3.1节中通过ProtoBuf定义生成的商品服务的类对象,backendclient为后台应用的客户端程序的引用。 针对gRPC的商品服务,创建服务类GoodsService,代码如下所示。 package com.demo.middle.grpc.service; import com.demo.backend.client.feign.GoodsClient; import com.demo.backend.client.utils.MessageSet; import com.demo.backend.client.vo.GoodsVo; import com.demo.grpc.goods.service.*; import com.google.gson.Gson; import io.grpc.stub.StreamObserver; import lombok.extern.slf4j.Slf4j; import net.devh.boot.grpc.server.service.GrpcService; import org.springframework.beans.factory.annotation.Autowired; @GrpcService @Slf4j public class GoodsService extends GoodsServiceGrpc.GoodsServiceImplBase { @Autowired private GoodsClient goodsClient; @Override public void getGoodsInfo(GoodsRequest request, StreamObserver responseObserver) { log.info("GoodsService request param is {}", request.getGoodsId()); try { MessageSet messageSet = goodsClient.getGoodsInfo(request.getGoodsId()); if (messageSet.getCode() == 200) { Object object = messageSet.getResult(); Gson gson = new Gson(); GoodsVo goodsVo = gson.fromJson(gson.toJson(object), GoodsVo.class); GoodsReply reply = GoodsReply.newBuilder() .setName(goodsVo.getName()) .setPrice(goodsVo.getPrice()) .setSums(goodsVo.getSums()) .setImage(goodsVo.getImage()) .build(); responseObserver.onNext(reply); responseObserver.onCompleted(); } }catch (Exception e){ e.printStackTrace(); log.info("GoodsService error: {}", e.getMessage()); } } @Override public void getGoodsList(ListSizeRequest request, StreamObserver responseObserver) { log.info("GoodsService request param is {}", request.getSize()); try { //实际调用时使用列表查询,暂时demo列表返回一条数据 MessageSet messageSet = goodsClient.getGoodsInfo("1"); if (messageSet.getCode() == 200) { Object object = messageSet.getResult(); Gson gson = new Gson(); GoodsVo goodsVo = gson.fromJson(gson.toJson(object), GoodsVo.class); GoodsReply goods = GoodsReply.newBuilder() .setName(goodsVo.getName()) .setPrice(goodsVo.getPrice()) .setSums(goodsVo.getSums()) .setImage(goodsVo.getImage()) .build(); GoodsListResponse reply = GoodsListResponse.newBuilder() .setGoodsList(0, goods) .build(); responseObserver.onNext(reply); responseObserver.onCompleted(); } }catch (Exception e){ e.printStackTrace(); log.info("GoodsService error: {}", e.getMessage()); } } } 其中,注解@GrpcService标注该类为gRPC的服务端程序。getGoodsInfo()方法中实现了商品信息查询的设计,首先通过使用后台应用的客户端接口GoodsClient取得商品信息数据,然后将商品数据转化为GoodsReply对象返回给gRPC客户端。getGoodsList()方法实现了商品列表查询的设计。首先通过后台应用查出商品列表数据,然后将商品数据逐条转化为GoodsReply,再将GoodsReply组装成列表对象GoodsListResponse返回给客户端。因为后台应用的商品服务没有提供列表查询的功能,所以在列表查询设计中,只使用一条数据构造一个商品列表。 完成服务端程序设计之后,必须通过应用的配置文件application.xml设定gRPC服务端的端口配置,配置如下所示。 grpc: server: port: 0 端口设置为0,表示将由程序自动生成端口号。在本书实例的gRPC客户端程序设计中,将使用微服务的方式调用gRPC服务端,所以这里忽略了端口号的设置。如果直接调用gRPC服务端的接口,可以在上面配置中设定一个端口号,由客户端通过端口进行调用。 gRPC服务端的测试必须结合客户端的调用一起进行,所以将在第4章中结合前台应用的开发实例进行相关讲解。 3.4小结 本章通过介绍两个中台应用的实例开发,提供了使用不同通信协议的中台应用开发的方法,同时也说明使用相同的后台资源,通过中台应用设计,可以为不同业务类型的前台应用实现不同的服务方式。 中台应用是一个服务中间件,在提供接口服务的基础上,可以进行安全管理和分布式事务管理等方面的扩展设计。此外,必须关注并发性和接口调用的性能,所以在设计中必须遵守微服务设计中的轻量化原则。