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