目录

前言

技术栈

架构设计

前端统一门户

后端用户中心

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,就会自动处理用户、权限这些信息。非常的方便,而且对于实际开发的同学来说,无需关心这些细节,只需要关心业务逻辑代码处理即可。