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

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

 

posted @ 2019-08-20 12:08  地主AE86  阅读(660)  评论(2编辑  收藏  举报