目录
前言
技术栈
架构设计
前端统一门户
后端用户中心
UserAuthority公用依赖
过滤器
application/json
form-data
后记
前言
在多个子工程的微服务开发的时候,后端通常情况下都是不止一个工程,前端深知也会不止一个工程,开发的团队也许也不止一个团队。 这时候,在用户校验、权限控制、功能集成方面就会需要有一套架构方案来管控。在整体的架构方面有几个要求:
(1)根据业务需要独立拆分新建的子工程,只需要关注业务功能的代码开发即可,不需要再关注用户、角色、权限以及集成的问题;
(2)子系统开发的时候,只需要引入pom依赖就可以非常方便获取用户信息以及对接口服务进行鉴权处理;
(3)前端子工程同样只需要关注实际的业务功能页面开发,不需要考虑登录、登出、用户信息获取这些问题;
(4)集成的时候,只需要提供微服务的上层负载接口地址和前端页面的路由地址即可。
如何做到这样松耦合?但是又能紧密依赖和协调一致呢?
对于后端来说,重点在于统一的规范设计、Redis分布式缓存、Filter过滤器、Maven依赖;对于前端来说,重点在于统一的axios过滤请求、路由处理、sessionStorage、自定义npm依赖包等等。
技术栈
前端:vue/axios/vueRouter 等等
后端:Springboot/SpringCloud/Dubbo 等等
中间件:Redis/Nginx 等等
架构设计
在软件的系统架构上,主要有以下几点:
(1)完全的前后端分离项目
(2)统一前端门户中心+统一后端用户中心
(3)多个前端工程打包后放到Nginx中进行静态代理的访问
(4)系统入口是统一门户中入口路由
(5)通过router.beforeEach校验sessionStorage中是否有用户信息,对前端路由进行鉴权控制
(6)通过axios的interceptors统一将sessionStorage中的token信息放入header中,并统一过滤非法请求
(7)通过Reids存储用户信息
(8)通过Nginx做请求转发,代替传统意义的网关
(9)通过开发UserAuthority依赖包,让每个子工程引入该依赖来完成用户信息传递以及权限校验
重点介绍和业务无关的两个工程,前端统一门户和后端用户中心。
前端统一门户
前端统一门户主要包括登录页面、门户首页框架两部分。 其中门户首页框架是典型的标题栏加左右布局结构,左边菜单栏、右边是内容区域。
标题栏可以根据项目规模大小再扩展下,支持logo自定义、横向一级菜单、消息中心、用户个人中心等功能。
左侧菜单区域是标准的菜单menu组件,菜单信息通过接口获取。 右侧内容区域正常是一个ifream,通过点击菜单中的url地址,更新ifream的src来实现。
统一门户登录成功过之后,前端会将用户信息、token信息保存到sessionStorage中。其他前端工程中直接从storage中获取这些信息,在路由跳转和axios请求的时候,直接使用。
如下是axios和router的全局通用处理参考示意代码
// axios请求前置处理,请求之前,将token放到header中
axios.interceptors.request.use(request => {
request.headers["token"] = localStorage.getItem("token");
return request;
});
// axios请求后置处理
axios.interceptors.response.use(
function(response) {
let data = response.data;
return new Promise(resolve => {
if (data.code === "401") {
if(localStorage.getItem("token")!=null && localStorage.getItem("token")!==""){
Modal.error({
title: "提示",
content: data.message
? data.message
: "您的账号已在其他地方登录,点击确定重新登录!",
onOk: () => {
sessionStorage.clear();
localStorage.clear();
window.parent.postMessage("refresh", "*");
}
});
}else{
window.parent.postMessage("refresh", "*");
}
} else {
resolve(response);
}
}).catch(error => {
console.log(error);
});
},
function(error) {
// 对响应错误做点什么
console.log(error);
const res = error.response;
if (res && res.status === 401) {
//token失效状态码
// Message.warning("登陆失效,请您重新登陆!");
message.warn("登陆失效,请您重新登陆!");
//刷新当前页面
window.parent.postMessage("refresh", "*");
return new Promise(() => {});
} else {
return Promise.reject(error);
}
}
);
//router中的全局处理
router.beforeEach(async (to, from, next) => {
let token = localStorage.getItem("token");
if (to.name === "login") {
if (token === "" || token == null) {
next();
} else {
next({ name: "index" });
}
} else {
if (token === "" || token == null) {
//登录失效,跳转到登录
next({ name: "login" });
} else {
if (to.matched.length === 0) {
//没有匹配的路由,跳转到404
next({ name: "404" });
} else {
next();
}
}
}
});
这里有个细节,就是如下这一行代码:
window.parent.postMessage("refresh", "*");
它的主要作用是子框架和父框架进行通信,如果子框集中出现鉴权失败了,要通知父框架进行页面路由跳转,直接跳转到登录页面。 在整个架构中,子框架就是ifream中业务前端独立工程,父框架是统一门户的前端,在统一门户中,关于这块消息通信的处理如下:
window.addEventListener("message", function(e) {
if (e.data === "refresh") {
localStorage.removeItem("token");
localStorage.clear();
rootApp.$router.push({ name: "login" });
}
});
主要作用就是清空storage,然后跳转页面到登录页面。
后端用户中心
用户端用户中心主要提供登录、登出、查询用户菜单权限这些接口功能。重点是是登录登出接口,登录成功之后,会将用户信息、token返回给前端,同时保存一份到redis中。
关于redis中存储用户信息,建议将key设置为用户的userId,将value设置为一个对象,包含有token和用户基本信息,同时设置有效期。 然后token的生成规则可以通过AES可逆的加密加密解密方式来实现,解密token后,可以从token中直接split出userId,然后再拿userId到redis中获取用户信息,再校验传递过来的token和从token解析出来的用户Id从redis查到的token是否一致,进而判断请求是否合法。
UserAuthority公用依赖
主要作用是继承一个filter,集合白名单机制对接口进行过滤校验。 如果在白名单中,则直接放行,如果没有在白名单中,则判断header中是否有token,如果没有token或者token校验不通过则返回401鉴权不通过。 前端在统一个axios.interceptors.response中进行页面的统一跳转。 如果token校验通过了,可以在filter中通过token获取从redis中获取用户信息,然后将用户信息作为入参信息向后传递,即:我们给入参数据新增一些用户信息字段,向后传递,在后面的业务工程接口中,直接从reqBo中就能获取到用户信息了。
这也要求所有的入参对象都要继承统一的入参Bo。
过滤器
前面说了那么多,都是在整个软件的集成架构上来讨论的,我们回到本篇文章的重点,如何在Filter中对请求的入参信息进行增加。在Filter中获取入参信息包括两种方式,一种是获取InputStream,一种是getParameterMap。前者对应content-type是application/json,后者对应content-type是form-data或者application/x-www-form-urlencoded或者是get请求。 content-type不同,处理方式是不同的。
注:我们暂时先不考虑文件类型。
application/json
正常情况下,request的getInputStream和getParameterMap返回的对象都是受保护,不允许修改的,所以就需要我们进行一些特殊处理,关于stream这种,我们需要自定义个集成HttpServletRequestWrapper的类,然后重载它的一些方法,并将这个对象向后传递,这样在后哦面流程中就可以使用我们放进去的数据的了。 先看下重写的类,参考代码如下:
public class BodyReaderHttpServletRequestWrapper extends HttpServletRequestWrapper {
private String bodyJsonStr;
public BodyReaderHttpServletRequestWrapper(HttpServletRequest request,String bodyJsonStr) throws IOException {
super(request);
this.bodyJsonStr = bodyJsonStr;
}
@Override
public ServletInputStream getInputStream() throws IOException {
final ByteArrayInputStream bais = new ByteArrayInputStream(bodyJsonStr.getBytes("utf-8"));
return new ServletInputStream() {
@Override
public int read() throws IOException {
return bais.read();
}
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener listener) {
}
};
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(this.getInputStream()));
}
public String getBodyJsonStr() {
return bodyJsonStr;
}
public void setBodyJsonStr(String bodyJsonStr) {
this.bodyJsonStr = bodyJsonStr;
}
}
可以看到,我们上是重写了getInputStream,将原来的stream和新的参数合并到一块,返回出去。 实际上我们并没有真的修改原始的request参数,只不过是重新生成了一个request。
下面贴一段具体使用时候的代码:
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse rep = (HttpServletResponse) response;
BufferedReader streamReader = new BufferedReader(new InputStreamReader(req.getInputStream(), "UTF-8"));
StringBuilder responseStrBuilder = new StringBuilder();
String inputStr;
while ((inputStr = streamReader.readLine()) != null)
responseStrBuilder.append(inputStr);
if(responseStrBuilder!=null && responseStrBuilder.length()>0){
JSONObject jsonObject = JSONObject.parseObject(responseStrBuilder.toString());
jsonObject.put("userId","123");
jsonObject.put("name","test");
try
{
chain.doFilter(new BodyReaderHttpServletRequestWrapper(req,jsonObject.toJSONString()),rep);
return;
}catch (Exception e){
e.printStackTrace();
log.error("包装参数失败,失败原因:{}",e.getMessage());
}
}
chain.doFilter(req, rep);
}
form-data
form-data的处理方式实际上和application/json是差不多的,原理都一样,区别在于重构的类不同,需要继承HttpServletRequestWrapper重写一个类。参考代码如下:
public class ParameterRequestWrapper extends HttpServletRequestWrapper
{
private Map<String, String[]> params = new HashMap<String, String[]>();
@SuppressWarnings("unchecked")
public ParameterRequestWrapper(HttpServletRequest request)
{
// 将request交给父类,以便于调用对应方法的时候,将其输出,其实父亲类的实现方式和第一种new的方式类似
super(request);
//将参数表,赋予给当前的Map以便于持有request中的参数
this.params.putAll(request.getParameterMap());
}
//重载一个构造方法
public ParameterRequestWrapper(HttpServletRequest request, Map<String, Object> extendParams)
{
this(request);
addAllParameters(extendParams);//这里将扩展参数写入参数表
}
@Override
public String getParameter(String name)
{//重写getParameter,代表参数从当前类中的map获取
String[] values = params.get(name);
if (values == null || values.length == 0)
{
return null;
}
return values[0];
}
@Override public Map<String, String[]> getParameterMap()
{
return params;
}
@Override public Enumeration<String> getParameterNames()
{
return new Vector<String>(params.keySet()).elements();
}
@Override
public String[] getParameterValues(String name)
{
return params.get(name);
}
public void addAllParameters(Map<String, Object> otherParams)
{//增加多个参数
for (Map.Entry<String, Object> entry : otherParams.entrySet())
{
addParameter(entry.getKey(), entry.getValue());
}
}
public void addParameter(String name, Object value)
{//增加参数
if (value != null)
{
if (value instanceof String[])
{
params.put(name, (String[]) value);
}
else if (value instanceof String)
{
params.put(name, new String[]{(String) value});
}
else
{
params.put(name, new String[]{String.valueOf(value)});
}
}
}
}
仔细看看这个重写的类会发现,实际上就是重写了getParameter/getParameterMap等等这些方法,在这些方法内部返回的数据加上自己传递的参数。
具体使用的时候如下:
ParameterRequestWrapper requestWrapper = new ParameterRequestWrapper((HttpServletRequest)request);
Map<String,Object> rMap = new HashMap<>();
rMap.put("userId","1");
rMap.put("useName","zhangsan");
requestWrapper.addAllParameters(rMap);
chain.doFilter(requestWrapper, rep);
后记
我们在业务工程中定义所有的reqBo的时候,都将这个Bo的定义继承父类的BaseReqBo,在父类的BaseReqBo中我们可以定义那些公用的用户信息或者通用字段信息,这些信息可以在上面的filter中去赋值,这样只要业务工程依赖了UserAuthority.jar,就会自动处理用户、权限这些信息。非常的方便,而且对于实际开发的同学来说,无需关心这些细节,只需要关心业务逻辑代码处理即可。