基于Spring Boot自建分布式基础应用

  • 2019 年 10 月 3 日
  • 笔记

  目前刚入职了一家公司,要求替换当前系统(单体应用)以满足每日十万单量和一定系统用户负载以及保证开发质量和效率。由我来设计一套基础架构和建设基础开发测试运维环境,github地址

  出于本公司开发现状及成本考虑,我摒弃了市面上流行的Spring Cloud以及Dubbo分布式基础架构,舍弃了集群的设计,以Spring Boot和Netty为基础自建了一套RPC分布式应用架构。可能这里各位会有疑问,为什么要舍弃应用的高可用呢?其实这也是跟公司的产品发展有关的,避免过度设计是非常有必要的。下面是整个系统的架构设计图。

  这里简单介绍一下,这里ELK或许并非最好的选择,可以另外采用zabbix或者prometheus,我只是考虑了后续可能的扩展。数据库采用了两种存储引擎,便是为了因对上面所说的每天十万单的大数据量,可以采用定时脚本的形式完成数据的转移。

  权限的设计主要是基于JWT+Filter+Redis来做的。Common工程中的com.imspa.web.auth.Permissions定义了所有需要的permissions:

 1 package com.imspa.web.auth;   2   3 /**   4  * @author Pann   5  * @description TODO   6  * @date 2019-08-12 15:09   7  */   8 public enum Permissions {   9     ALL("/all", "所有权限"),  10     ROLE_GET("/role/get/**", "权限获取"),  11     USER("/user", "用户列表"),  12     USER_GET("/user/get", "用户查询"),  13     RESOURCE("/resource", "资源获取"),  14     ORDER_GET("/order/get/**","订单查询");  15  16     private String url;  17     private String desc;  18  19     Permissions(String url, String desc) {  20         this.url = url;  21         this.desc = desc;  22     }  23  24     public String getUrl() {  25         return this.url;  26     }  27  28     public String getDesc() {  29         return this.desc;  30     }  31 }

  如果你的没有为你的接口在这里定义权限,那么系统是不会对该接口进行权限的校验的。在数据库中User与Role的设计如下:

 1 CREATE TABLE IF NOT EXISTS `t_user` (   2   `id`                   VARCHAR(36)  NOT NULL,   3   `name`                 VARCHAR(20)  NOT NULL UNIQUE,   4   `password_hash`        VARCHAR(255) NOT NULL,   5   `role_id`              VARCHAR(36)  NOT NULL,   6   `role_name`            VARCHAR(20)  NOT NULL,   7   `last_login_time`      TIMESTAMP(6) NULL,   8   `last_login_client_ip` VARCHAR(15)  NULL,   9   `created_time`         TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),  10   `created_by`           VARCHAR(36)  NOT NULL,  11   `updated_time`         TIMESTAMP(6) NULL,  12   `updated_by`           VARCHAR(36)  NULL,  13   PRIMARY KEY (`id`)  14 );  15  16 CREATE TABLE IF NOT EXISTS `t_role` (  17   `id`           VARCHAR(36)  NOT NULL,  18   `role_name`    VARCHAR(20)  NOT NULL UNIQUE,  19   `description`  VARCHAR(90)  NULL,  20   `permissions`  TEXT         NOT NULL, #其数据格式类似于"/role/get,/user"或者"/all"  21   `created_time` TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),  22   `created_by`   VARCHAR(36)  NOT NULL,  23   `updated_time` TIMESTAMP(6) NULL,  24   `updated_by`   VARCHAR(36)  NULL,  25   PRIMARY KEY (`id`)  26 );

  需要注意的是”/all”代表了所有权限,表示root权限。我们通过postman调用登陆接口可以获取相应的token:

  这个token是半个小时失效的,如果你需要更长一些的话,可以通过com.imspa.web.auth.TokenAuthenticationService进行修改:

 1 package com.imspa.web.auth;   2   3 import com.imspa.web.util.WebConstant;   4 import io.jsonwebtoken.Jwts;   5 import io.jsonwebtoken.SignatureAlgorithm;   6   7 import java.util.Date;   8 import java.util.Map;   9  10 /**  11  * @author Pann  12  * @description TODO  13  * @date 2019-08-14 23:24  14  */  15 public class TokenAuthenticationService {  16     static final long EXPIRATIONTIME = 30 * 60 * 1000; //TODO  17  18     public static String getAuthenticationToken(Map<String, Object> claims) {  19         return "Bearer " + Jwts.builder()  20                 .setClaims(claims)  21                 .setExpiration(new Date(System.currentTimeMillis() + EXPIRATIONTIME))  22                 .signWith(SignatureAlgorithm.HS512, WebConstant.WEB_SECRET)  23                 .compact();  24     }  25 }

   Refresh Token目前还没有实现,后续我会更新,请关注我的github。如果你跟踪登陆逻辑代码,你可以看到我把role和user都缓存到了Redis:

 1     public User login(String userName, String password) {   2         UserExample example = new UserExample();   3         example.createCriteria().andNameEqualTo(userName);   4   5         User user = userMapper.selectByExample(example).get(0);   6         if (null == user)   7             throw new UnauthorizedException("user name not exist");   8   9         if (!StringUtils.equals(password, user.getPasswordHash()))  10             throw new UnauthorizedException("user name or password wrong");  11  12         roleService.get(user.getRoleId()); //for role cache  13  14         hashOperations.putAll(RedisConstant.USER_SESSION_INFO_ + user.getName(), hashMapper.toHash(user));  15         hashOperations.getOperations().expire(RedisConstant.USER_SESSION_INFO_ + user.getName(), 30, TimeUnit.MINUTES);  16  17         return user;  18     }

  在Filter中,你可以看到过滤器的一系列逻辑,注意返回http状态码401,403和404的区别:

  1 package com.imspa.web.auth;    2    3 import com.imspa.web.Exception.ForbiddenException;    4 import com.imspa.web.Exception.UnauthorizedException;    5 import com.imspa.web.pojo.Role;    6 import com.imspa.web.pojo.User;    7 import com.imspa.web.util.RedisConstant;    8 import com.imspa.web.util.WebConstant;    9 import io.jsonwebtoken.Claims;   10 import io.jsonwebtoken.Jwts;   11 import org.apache.commons.lang3.StringUtils;   12 import org.apache.logging.log4j.LogManager;   13 import org.apache.logging.log4j.Logger;   14 import org.springframework.data.redis.core.HashOperations;   15 import org.springframework.data.redis.hash.HashMapper;   16 import org.springframework.util.AntPathMatcher;   17   18 import javax.servlet.Filter;   19 import javax.servlet.FilterChain;   20 import javax.servlet.FilterConfig;   21 import javax.servlet.ServletException;   22 import javax.servlet.ServletOutputStream;   23 import javax.servlet.ServletRequest;   24 import javax.servlet.ServletResponse;   25 import javax.servlet.http.HttpServletRequest;   26 import javax.servlet.http.HttpServletResponse;   27 import java.io.IOException;   28 import java.util.Date;   29 import java.util.HashMap;   30 import java.util.Map;   31 import java.util.Optional;   32 import java.util.concurrent.TimeUnit;   33   34 /**   35  * @author Pann   36  * @description TODO   37  * @date 2019-08-16 14:39   38  */   39 public class SecurityFilter implements Filter {   40     private static final Logger logger = LogManager.getLogger(SecurityFilter.class);   41     private AntPathMatcher matcher = new AntPathMatcher();   42     private HashOperations<String, byte[], byte[]> hashOperations;   43     private HashMapper<Object, byte[], byte[]> hashMapper;   44   45     public SecurityFilter(HashOperations<String, byte[], byte[]> hashOperations, HashMapper<Object, byte[], byte[]> hashMapper) {   46         this.hashOperations = hashOperations;   47         this.hashMapper = hashMapper;   48     }   49   50     @Override   51     public void init(FilterConfig filterConfig) throws ServletException {   52   53     }   54   55     @Override   56     public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {   57         HttpServletRequest request = (HttpServletRequest) servletRequest;   58         HttpServletResponse response = (HttpServletResponse) servletResponse;   59   60         Optional<String> optional = PermissionUtil.getAllPermissionUrlItem().stream()   61                 .filter(permissionItem -> matcher.match(permissionItem, request.getRequestURI())).findFirst();   62         if (!optional.isPresent()) { //TODO some api not config permission will direct do   63             chain.doFilter(servletRequest, servletResponse);   64             return;   65         }   66   67         try {   68             validateAuthentication(request, optional.get());   69             flushSessionAndToken(((User) request.getAttribute("userInfo")), response);   70             chain.doFilter(servletRequest, servletResponse);   71         } catch (ForbiddenException e) {   72             logger.debug("occur forbidden exception:{}", e.getMessage());   73             response.setStatus(403);   74             ServletOutputStream output = response.getOutputStream();   75             output.print(e.getMessage());   76             output.flush();   77         } catch (UnauthorizedException e) {   78             logger.debug("occur unauthorized exception:{}", e.getMessage());   79             response.setStatus(401);   80             ServletOutputStream output = response.getOutputStream();   81             output.print(e.getMessage());   82             output.flush();   83         }   84     }   85   86     @Override   87     public void destroy() {   88   89     }   90   91     private void validateAuthentication(HttpServletRequest request, String permission) {   92         String authHeader = request.getHeader("Authorization");   93         if (StringUtils.isEmpty(authHeader))   94             throw new UnauthorizedException("no auth header");   95   96         Claims claims;   97         try {   98             claims = Jwts.parser().setSigningKey(WebConstant.WEB_SECRET)   99                     .parseClaimsJws(authHeader.replace("Bearer ", ""))  100                     .getBody();  101         } catch (Exception e) {  102             throw new UnauthorizedException(e.getMessage());  103         }  104  105         String userName = (String) claims.get("user");  106         String roleId = (String) claims.get("role");  107  108         if (StringUtils.isEmpty(userName) || StringUtils.isEmpty(roleId))  109             throw new UnauthorizedException("token error,user:" + userName);  110  111         if (new Date().getTime() > claims.getExpiration().getTime())  112             throw new UnauthorizedException("token expired,user:" + userName);  113  114  115         User user = (User) hashMapper.fromHash(hashOperations.entries(RedisConstant.USER_SESSION_INFO_ + userName));  116         if (user == null)  117             throw new UnauthorizedException("session expired,user:" + userName);  118  119  120         if (validateRolePermission(permission, user))  121             request.setAttribute("userInfo", user);  122     }  123  124     private Boolean validateRolePermission(String permission, User user) {  125         Role role = (Role) hashMapper.fromHash(hashOperations.entries(RedisConstant.ROLE_PERMISSION_MAPPING_ + user.getRoleId()));  126         if (role.getPermissions().contains(Permissions.ALL.getUrl()))  127             return Boolean.TRUE;  128  129         if (role.getPermissions().contains(permission))  130             return Boolean.TRUE;  131  132         throw new ForbiddenException("do not have permission for this request");  133     }  134  135     private void flushSessionAndToken(User user, HttpServletResponse response) {  136         hashOperations.getOperations().expire(RedisConstant.USER_SESSION_INFO_ + user.getName(), 30, TimeUnit.MINUTES);  137  138         Map<String, Object> claimsMap = new HashMap<>();  139         claimsMap.put("user", user.getName());  140         claimsMap.put("role", user.getRoleId());  141         response.setHeader("Authorization",TokenAuthenticationService.getAuthenticationToken(claimsMap));  142     }  143  144 }

  下面是RPC的内容,我是用Netty来实现整个RPC的调用的,其中包含了心跳检测,自动重连的过程,基于Spring Boot的实现,配置和使用都还是很方便的。

  我们先看一下service端的写法,我们需要先定义好对外服务的接口,这里我们在application.yml中定义:

1 service:  2   addr: localhost:8091  3   interfaces:  4     - 'com.imspa.api.OrderRemoteService'

  其中service.addr是对外发布的地址,service.interfaces是对外发布的接口的定义。然后便不需要你再定义其他内容了,是不是很方便?其实现你可以根据它的配置类com.imspa.config.RPCServiceConfig来看:

 1 package com.imspa.config;   2   3 import com.imspa.rpc.core.RPCRecvExecutor;   4 import com.imspa.rpc.model.RPCInterfacesWrapper;   5 import org.springframework.beans.factory.annotation.Value;   6 import org.springframework.boot.context.properties.ConfigurationProperties;   7 import org.springframework.boot.context.properties.EnableConfigurationProperties;   8 import org.springframework.context.annotation.Bean;   9 import org.springframework.context.annotation.Configuration;  10  11 /**  12  * @author Pann  13  * @description config order server's RPC service method  14  * @date 2019-08-08 14:51  15  */  16 @Configuration  17 @EnableConfigurationProperties  18 public class RPCServiceConfig {  19     @Value("${service.addr}")  20     private String addr;  21  22     @Bean  23     @ConfigurationProperties(prefix = "service")  24     public RPCInterfacesWrapper serviceContainer() {  25         return new RPCInterfacesWrapper();  26     }  27  28     @Bean  29     public RPCRecvExecutor recvExecutor() {  30         return new RPCRecvExecutor(addr);  31     }  32  33 }

  在client端,我们也仅仅只需要在com.imspa.config.RPCReferenceConfig中配置一下我们这个工程所需要调用的service 接口(注意所需要配置的内容哦):

 1 package com.imspa.config;   2   3 import com.imspa.api.OrderRemoteService;   4 import com.imspa.rpc.core.RPCSendExecutor;   5 import org.springframework.context.annotation.Bean;   6 import org.springframework.context.annotation.Configuration;   7   8 /**   9  * @author Pann  10  * @Description config this server need's reference bean  11  * @Date 2019-08-08 16:55  12  */  13 @Configuration  14 public class RPCReferenceConfig {  15     @Bean  16     public RPCSendExecutor orderService() {  17         return new RPCSendExecutor<OrderRemoteService>(OrderRemoteService.class,"localhost:8091");  18     }  19  20 }

  然后你就可以在代码里面正常的使用了

 1 package com.imspa.resource.web;   2   3 import com.imspa.api.OrderRemoteService;   4 import com.imspa.api.order.OrderDTO;   5 import com.imspa.api.order.OrderVO;   6 import org.springframework.beans.factory.annotation.Autowired;   7 import org.springframework.web.bind.annotation.GetMapping;   8 import org.springframework.web.bind.annotation.PathVariable;   9 import org.springframework.web.bind.annotation.RequestMapping;  10 import org.springframework.web.bind.annotation.RestController;  11  12 import java.math.BigDecimal;  13 import java.util.Arrays;  14 import java.util.List;  15  16 /**  17  * @author Pann  18  * @Description TODO  19  * @Date 2019-08-08 16:51  20  */  21 @RestController  22 @RequestMapping("/resource")  23 public class ResourceController {  24     @Autowired  25     private OrderRemoteService orderRemoteService;  26  27     @GetMapping("/get/{id}")  28     public OrderVO get(@PathVariable("id")String id) {  29         OrderDTO orderDTO = orderRemoteService.get(id);  30         return new OrderVO().setOrderId(orderDTO.getOrderId()).setOrderPrice(orderDTO.getOrderPrice())  31                 .setProductId(orderDTO.getProductId()).setProductName(orderDTO.getProductName())  32                 .setStatus(orderDTO.getStatus()).setUserId(orderDTO.getUserId());  33     }  34  35     @GetMapping()  36     public List<OrderVO> list() {  37         return Arrays.asList(new OrderVO().setOrderId("1").setOrderPrice(new BigDecimal(2.3)).setProductName("西瓜"));  38     }  39 }

  以上是本基础架构的大概内容,还有很多其他的内容和后续更新请关注我的github,笔芯。