本文讨论一个问题:

存储token时,token与对应用户id谁来作为key?

问题起源

问题起源于要给公司的后台管理系统添加权限管理,选用的是开源框架shiro,而原本系统上是采用token做了登录校验的。

我所采用的shiro验证方式是,每次接口请求,根据token来获取用户id,然后通过shiro中的登录验证机制来进行权限校验。

因此,“根据token获取用户id”就要求在存储用户token时,以token为键值key,以用户ID为value值。

然而此时面临一个问题是,系统原本的token存储方式如下,我们称之为第一种:用户ID为key。

1
cache.set(TOKEN_PREFIX + userid, token);

这就需要我做出判断,需不需要修改token的存储方式为下面的形式:我们称之为第二种:token为key。

1
cache.set(TOKEN_PREFIX + token, userid);

思考

第一个问题,两种方式是否都能够实现需求功能?

我们需要实现的功能包括:

  1. 登录验证
  2. shiro中的权限验证

登录验证

对于”用户ID为key”的方式,需要前端传递用户id+token两个值,验证登录状态需要我们根据前端传递的用户ID,获取数据库中存储的token,与前端传递的token进行校验,如果一致,则校验通过,否则返回错误信息,提示用户需要重新登录等等。

对于“token为key”的方式,前端至少需要传递token一个值,根据前端传递的token,获取数据库中存储的用户ID,如果能获取到,则校验通过,否则提示用户token已过期,需要用户重新登录等等。

shiro中的权限验证

shiro中的权限验证,涉及到具体的实现机制,以token为key的方式,就以我们的真实实现为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72

// shiro登录代码:
Subject s = SecurityUtils.getSubject();
JWTToken jwtToken = new JWTToken(token);
subject.login(jwtToken);


// 实现AuthenticationToken的类:
import org.apache.shiro.authc.AuthenticationToken;

public class JWTToken implements AuthenticationToken {
private static final long serialVersionUID = 1L;

// 密钥
private String token;

public JWTToken(String token) {
this.token = token;
}

@Override
public Object getPrincipal() {
return token;
}

@Override
public Object getCredentials() {
return token;
}
}

/**
* 自定义的登录验证类:
*/
public class ShiroDbRealm extends AuthorizingRealm{


/**
* 重写shiro的token
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JWTToken;
}

/**
* 角色,权限认证
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
//这里可以连接数据库根据用户账户进行查询用户角色权限等信息
return simpleAuthorizationInfo;
}

/**
* 自定义认证
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
String token = (String) auth.getCredentials();
// 解密获得userid,用于和数据库进行对比
// getUserId实际就是通过token,在数据库中取对应的userid
Integer userid = JwtUtils.getUserId(token);
if (tuserid == null) {
throw new AuthenticationException("token 校验失败");
}
return new SimpleAuthenticationInfo(token, token, getName());
}
}


如果采用userid为key的方式,不难实现,也修改其实现方式,

第二个问题,两种方式哪一种传输的数据量更少?

第一种方式需要前端每次请求都传递token+userid;而第二种实际上可以只传递token,后台根据token解密(或数据库查找)来获取用户信息。

第三个问题,两种方式哪种更安全?

两种方式的安全应该是一样的,核心是后台通过数据库保存token与userid的对应信息。

个人意见

个人比较细化第二种,以token为key的方式,首先,前端传递简单,只需要传递token即可;二是后端通过这种方式,可以统一当前登录人的获取方式,而不是每次在接口中获取header中的用户id。

问题起源

一种很常见的网站优化,就是在客户进行危险操作的时候,让客户进行二次确认,从而避免因为用户一时手误,如图所示:

image-20220512101912447

对话框的实现方式

这种方式无须多言,实现起来逻辑并不困难,难在这种方式代码量比较大,首先需要在HTML部分创建对话框代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<el-dialog
v-if="confirmDialog"
width="30vw"
:visible.sync="confirmDialog"
title="提示"
:destroy-on-close="true"
>
<div>
<div>
{{ confirmMsg }}
</div>
<div style="margin-top: 10px;font-weight: bold;">
是否继续操作
</div>
</div>
<div slot="footer">
<el-button @click="cancel">取消</el-button>
<el-button type="primary" @click="confirm">确定</el-button>
</div>
</el-dialog>

这种方式虽然工作量大,但是有一个比较好的优点是,可以比较方便的调整提示操作的格式,一种普遍存在的场景是,用户在修改信息的时候需要核对修改的数据与原数据的区别,那么使用这种方式就可以很好的实现。

JS确认框

该种方式实现起来非常简单,可以单纯的通过Javascript来达到提示语的目的,如本文的的第一张图就是使用该种方式的截图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
this.$confirm('确认删除数据吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
// 操作确认
// 执行删除操作以及其他业务
this.$message({
type: 'succuss',
message: '已取消删除'
})
}).catch(() => {
this.$message({
type: 'info',
message: '已取消删除'
})
})

el-popconfirm 气泡确认框

el-popconfirm气泡确认框,这是一个Element-UI的组件,实现完成的样式如下。

image-20220512143050848

一个简单的样例是:

1
2
3
4
5
6
7
8
9
10
11
<template>
<el-popconfirm
confirm-button-text='好的'
cancel-button-text='不用了'
icon="el-icon-info"
icon-color="red"
title="这是一段内容确定删除吗?"
>
<el-button slot="reference">删除</el-button>
</el-popconfirm>
</template>

el-popconfirm气泡确认框的详细文档见Element-UI官方文档

总结

如果运营经理或者产品经理或者客户要求,能够细致的展示提示信息,而不是仅仅是一个操作确认的话,我们似乎只能选用第一种自定义对话框的方式;

相反,如果仅仅是为了避免用户误操作或者避免因为种种原因误删除数据,我们可以采用第二种JS确认框或者第三种气泡确认框,第三种需要修改html样式,第二中则只需要修改js即可。

还是那句话,具体看业务需求吧!

祝大家早日脱离CRUD Developer!

经典问题:在测试环境好好的,怎么到正式环境就不行了?

——数据量变了。

问题来源

最近在开发公司的后台管理系统,很简单的一个部分,给部门设置领导,前端选用Element-UI的 el-select组件,后端返回的可选人员列表为当前操作人有权控制的每一个人。

在测试环境运行好好的,怎么到了正式环境就不行了呢?

问题分析

出问题的原始代码很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<el-select
v-model="orgForm.leader"
placeholder="请选择部门领导"
style="width: 15vw"
clearable
multiple
filterable
>
<el-option
v-for="(l, index) in leaderOptions"
:key="index"
:label="l.realname"
:value="l.id"
/>
</el-select>

问题出在测试环境时,el-select的可选数据 leaderOptions的量级顶多100多人,而到了正式生产环境中,leaderOptions达到了将近两万左右,从而造成了组件卡顿。

因此,解决问题的方式就是,如何降低el-select的可选项 leaderOptions的数量。

两种解决思路

我们认为有两种解决思路:

前后端配合

这种方式的优点是,思路清晰简单,修改后台接口的返回数据量,每次查询只返回100条数据,毕竟从使用上来说,不会真的有人一直下拉来选择。

这种方式的缺点是,出现问题的是前端,却需要后端来解决,增加了后端的工作量,或者说,将一个领域的问题扩展到另一个领域的人员去解决,会增加团队沟通成本。

因此,我们放弃了这种方式。(其实就是懒得沟通,就想自己解决问题,不麻烦别人!)

纯前端解决

为了减少 el-select的可选项数量,我们构造数据:leaderOptionsTop30, 每次只返回所有可选项的(大约)30条数据,那么这大约30条数据是根据什么来筛选获的呢?

我们设置el-select的 filter-method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<el-select
v-model="orgForm.leader"
placeholder="请选择部门领导"
style="width: 15vw"
clearable
multiple
filterable
:filter-method="filterCheckPerOptions"
>
<el-option
v-for="(l, index) in leaderOptionsTop30"
:key="index"
:label="l.realname"
:value="l.id"
/>
</el-select>

方法体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
filterCheckPerOptions(query = '') {
// query是输入框中的检索条件
var arr = this.leaderOptions.filter(item => {
return !this.$method.isEmpty(item.realname) && item.realname.includes(query)
})
// 根据检索条件筛选出来的选项,只取前30条
if (arr.length > 30) {
arr = arr.slice(0, 30)
}
// 清空之前的选项
this.leaderOptionsTop30.splice(0, this.leaderOptionsTop30.length)
// chosen表示已被选择的选项,添加这一部分主要是为了回显,避免选择框中直接出现用户id
const chosen = this.getChosenItemsArr()
// 检索项 + 已选项的并集
const result = [...arr, ...chosen.filter(item => !arr.includes(item))]
if (arr.length > 30) {
this.leaderOptionsTop30.push(...result)
} else {
this.leaderOptionsTop30.push(...result)
}
},
getChosenItemsArr() {
// 获取已被选中的人员
const items = []
for (let i = 0; i < this.leaderOptions.length; i++) {
if (this.orgForm.leader.indexOf(this.leaderOptions[i].id) >= 0 &&
items.indexOf(this.leaderOptions[i]) < 0) {
items.push(this.leaderOptions[i])
}
}
return items
},

打完,收工!

题外话

正式环境运行与测试环境运行结果不同,通常有很多原因,例如:

  1. 服务器:如服务器的时区,语言等配置不同,可能导致某些未指定特定语言或时区的代码在执行时出现不一致的情况。
  2. 数据配置:大多数功能上线后都需要进行后台的数据配置,这一点相信无须多言。
  3. 数据量:
    数据量会影响接口的响应速度,页面组件的响应速度等等。比如某个操作需要等待后台接口,而后台接口如果超过一定时长后,前端用户就会明显的感觉操作的卡顿与无响应。

1

学习起源

学习ThreadLocal起源于最近学习的两个框架:若依开源系统,以及权限验证的开源框架Shiro。

在若依开源系统中,其分页插件:PageHelper的部分核心代码中,有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.github.pagehelper.page;

public abstract class PageMethod {
protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal();

public PageMethod() {
}

protected static void setLocalPage(Page page) {
LOCAL_PAGE.set(page);
}

public static <T> Page<T> getLocalPage() {
return (Page)LOCAL_PAGE.get();
}

public static void clearPage() {
LOCAL_PAGE.remove();
}

// 省略的其他代码...
}

而shiro核心版块中有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public abstract class ThreadContext {
private static final ThreadLocal<Map<Object, Object>> resources = new ThreadContext.InheritableThreadLocalMap();

protected ThreadContext() {
}

public static Map<Object, Object> getResources() {
return (Map)(resources.get() == null ? Collections.emptyMap() : new HashMap((Map)resources.get()));
}

public static void setResources(Map<Object, Object> newResources) {
if (!CollectionUtils.isEmpty(newResources)) {
ensureResourcesInitialized();
((Map)resources.get()).clear();
((Map)resources.get()).putAll(newResources);
}
}
}

其中,都用到一个非常强大的类:ThreadLocal。

在PageHelper中,使用TheadLocal来保存分页参数Page,在Shiro中,使用ThreadLocal保存了一个map<Object, Object>对象Resources,根据上下文可以看出shiro中的ThreadLocal保存了当前登录账号,或者说当前登录对象的信息,即shiro中的核心对象subject。

ThreadLocal给予了我们为当前线程局部保存变量的能力,换一个角度,ThreadLocal可以做到线程的数据隔离。

ThreadLocal的原理

只有特定线程能取出特定线程的数据,这一点我们很容易联想到ThreadLocal的实现原理:构造一个Map映射对象,以Thread为键值key,以特定的值为存储的值,从而实现每次取值只能取当前线程保存的值。

ThreadLocal的两个重要的api为get,set,可以通过其源码看到这一原理的实现方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

其中构造了一个特定的映射对象ThreadLocalMap,我们不再赘述。

ThreadLocal可以存储线程执行上下文信息,简化一些线程中方法调用栈参数逐层传递的问题,比如我们在文章《拦截器中巧用ThreadLocal规避层层传值》中提到的,使用ThreadLocal保存请求Request,从而在接口内部可以直接获取当前请求信息,利用这一点可以做很多事情,例如我们可以抛开shiro做自己定制的权限验证系统。

ThreadLocal也解决了某些线程不安全问题,例如时间日期格式化类SimpleDateFormate是非线程安全的,我们通过ThreadLocal来设置则规避了这一点:

1
2
public static final ThreadLocal<DateFormat> df_yyyy_MM_dd = 
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

特别注意

当ThreadLocal与线程池一起使用时,便会有一个问题,当从线程池取出一个线程后并归还后,下次从线程池中取同样的线程,保存在ThreadLocal中的值是否会泄露?

不谈实验,根据我们查找的资料,对这个问题有两种看法,一种说法是ThreadLocal中使用的键值并非是Thread,而是线程Thread的弱引用,当线程回收时会触发垃圾回收机制,并不会造成数据泄露;更多的资料反应存在内存泄露问题以及生产环境发生过的惨案,如记一次Java线程池与ThreadLocal引发的血案以及An Introduction to ThreadLocal in Java

更准确地说,当ThreadLocal的修饰符有static时,即强引用的ThreadLocal需要我们手动使用remove方法来释放数据,为了养成良好的习惯,建议还是当ThreadLocal使用结束后,就调用其remove方法。

Web开发中的一点麻烦事

最近,准确地说,是一直都有的一点麻烦事:函数层层传递。什么意思呢?比如说有个很常见的需求描述是:记录用户的某次操作明细。

以在Java的开源框架jfinal中,操作添加一个用户的接口为例,有:

1
2
3
4
5
6
7
8
9
10
public class XXController() {
public void addUser() {
// 获取操作人
Integer opUserId = Integer.parseInt(getHeader("opUserId"));
// 获取其他参数...

service.addUser(...., opUserId);
renderAppMsg("添加用户成功");
}
}

为了记录用户添加的具体操作内容以及信息错误,这个记录用户操作的记录可能需要穿透好层方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class XXService() {
public void addUser(String tel, String name, String password, Integer opUserId) {
checkTel(tel, opUserId);

checkName(name, opUserId);

checkPassword(password, opUserId);
}

public void checkTel(String tel, Integer opUserId) {
check(tel, opUserId);
}

publc void check(..., Integer opUserId) {
// ...
}
}

举得这个例子或许不是很恰当,但是相信大家可以理解这个麻烦点在哪里。

函数调用链中的每个方法并不是需要这个参数,而可能仅仅是为了向下个被调用的函数传递这个参数。

那么是否有方法帮助我们来不需要这样逐层传递,从而获取接口请求参数的方法呢。

思考

这有点类似与一个全局变量,但是这个变量对每次请求来说是变化的,那么是否有一种方式能够让我们来保存这样一种:针对每次请求的全局变量呢?

在最近学习Shiro的过程中,以及学习若依开源框架的过程中,我们发现其均使用到了一个强大的Java类:ThreadLocal。

shiro使用ThreadLocal是用来保存当前登录对象,若依框架中,其中所使用的分页插件PageUtil使用ThreadLocal保存请求参数中的pageNum与pageSize等分页参数。因此我们是不是也可以使用ThreadLocal来达到同样的目的。

在拦截器中使用ThreadLocal暂存请求参数

为此,我们来尝试一下,通过ThreadLocal保存请求参数,通过拦截器我们可以拦截每次请求,如下是实现方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.holdoa.core.interceptor;

import com.jfinal.aop.Interceptor;
import com.jfinal.aop.Invocation;

import javax.servlet.http.HttpServletRequest;

public class RequestPool implements Interceptor {

public static ThreadLocal<HttpServletRequest> localRequest= new ThreadLocal<>();

@Override
public void intercept(Invocation inv) {
localRequest.set(inv.getController().getRequest());
inv.invoke();
localRequest.remove();
}

public static HttpServletRequest getRequest() {
return localRequest.get();
}
}

我们通过ThreadLocal将整个请求暂存起来,当然,为了节约内存,也可以只保存使用频次高的通用参数,比如当前登录人的信息等等。

使用时,只需要我们通过这个线程局部变量取值即可:

1
String para = RequestPool.localRequest.get().getParameter("username");

如此,我们便可以在处理每次请求的过程中,无需从Controller以及Service中的方法层层传值,只需要直接通过该局部变量取值即可。

问题起源

学习了Java的代理,动态代理,cglib的小伙伴对Java的代理中的一些概念应该会有一些基础的认识,比如如何使用代理实现一个拦截器,如果通过cglib的回调过滤器来修改方法的执行逻辑等等,那么这种实现方式是否可以抽象为更高级的编程思想呢?使用cglib创建对象的过程与代码很繁琐,是否有更便捷的开源包可以使用呢?

AOP

AOP:面向切面编程,其思想是:

通过预编译方式和运行期动态代理方式,实现在不修改源代码的情况下给程序动态统一添加额外功能的一种技术

我们今天要学习的AspectJ就是AOP思想的一个工具包,定义了AOP语法与基本概念。

AspectJ的一些基本概念

  1. 连接点:JoinPoint:

  2. 切入点:PointCut;

  3. 通知:Advice;

  4. 切面:Aspect。

    官方文档释义如下:

    A join point is a well-defined point in the program flow. A pointcut picks out certain join points and values at those points. A piece of advice is code that is executed when a join point is reached. These are the dynamic parts of AspectJ.

其含义如下图所示:

image-20220425184930262

从实践中学习

让我们从实际代码触发来理解这几个概念。

maven引入

使用aspectj需要我们引入以下两个maven包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- https://mvnrepository.com/artifact/org.aspectj/aspectjrt -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjrt</artifactId>
<version>1.9.9.1</version>
<scope>runtime</scope>
</dependency>

<!-- https://mvnrepository.com/artifact/org.aspectj/aspectjweaver -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.9.1</version>
</dependency>

第一个aspectjrt是aspectj运行时需要的库(Runtime Library),第二个aspectjweaver,支持切入点表达式,用于在加载Java类时起作用。

简单使用

(在学习的过程中,有个东西始终让我无法习惯,就是在java中引入.aj文件)

需要习惯这一点,在非spring项目中,使用aspectj可能需要修改maven文件与创建非.java文件。不过,现在的编译器可以帮助我们习惯这一点,maven引入aspectj之后,IDEA便有了创建aspectj文件的选项:

image-20220426190524717

最开始,我们有以下类:

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.qw.real.aop;

public class Person {

/**
* 读书
* @return
*/
public boolean readBook(String book) {
System.out.println("正在读" + book);
return true;
}
}

然后创建aspectj文件:

1
2
3
4
5
6
7
8
9
10
public aspect AjAspect {
pointcut say():
execution(* App.say(..));
before(): say() {
System.out.println("AjAspect before say");
}
after(): say() {
System.out.println("AjAspect after say");
}
}

配置aspectj进行编译执行

使用aspectj与其他java程序最大的不同是,需要特殊编译环境,我们以idea为例,除需要在maven中引入之前的两个aspectj运行环境,还需要引入一个aspectj编译器:

1
2
3
4
5
6
7
<!-- https://mvnrepository.com/artifact/org.aspectj/aspectjtools -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjtools</artifactId>
<version>1.9.9.1</version>
</dependency>

我们配置Java Compiler,测试:

image-20220427083638107

这个错误似乎是在说我们的编译环境与运行环境所使用的版本不一致:编译用version 55,而运行使用的是 version 52,猜测可能是因为我们使用的最新的aspect包,为此,降低aspectj版本到1.8.3再次尝试:

image-20220427083842832

测试成功!

然后我们去执行:

1
2
3
before...
正在读古文观止
after...

完结,鼓掌!

总结

总的来看,aspectj非常强大,但是其需要特殊配置编译环境这一点可能不太能被人接受。