上一篇:docker-compose搭建Redis高可用集群
我相信我并不是第一个遇到“多种身份不同的用户需要从同一个登录入口登录”的需求的后端开发者了。
你可能要问了,这个奇怪的需求是怎么来的呢?如果你的团队用过D2Admin
作为前端框架的话,你应该就能明白为什么要让多种身份不同的用户从同一个登录入口登录了——因为前端就给你了那么一个登录入口,而且就只给你“用户名”和“密码”两个字段,但是你这后端却有五六种类型的用户。你就算想加更多的,前端由于框架的限制也不可能再给你加了啊!你只能自己想办法解决认证与鉴权的问题。
那么围绕着登录使用的字段只有“用户名”和“密码”以及所有的用户都要使用同一个入口登录这两个棘手的问题,我在思考了很多种解决方法之后,最终决定使用固定的用户名格式
以及责任链模式
来解决这个问题。
什么是责任链模式
责任链模式是一种比较常见的设计模式。我认为比较好的一个解释如下:
责任链模式的定义如下:
Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request. Chain the receiving objects and pass the request along the chain until an object handle it.(使多个对象都有机会处理请求,从而避免了请求的发送者和接受者之间的耦合关系。将这些对象形成一条链,并沿着这条链传递该请求,直到有对象处理它为止。)
......
责任链模式的优点:
责任链模式非常显著的优点是将请求与处理分开。请求者可以不用知道是谁处理的,处理者可以不用知道请求的全貌,两者解耦,提高系统的灵活性。
......
责任链模式屏蔽了请求的处理过程,你发求个请求到底是谁处理的,这个你不用关心,只要把请求抛给责任链的第一个处理者,最终会返回一个处理结果,作为请求者可以不用知道到底是需要谁来处理的,这是责任链模式的核心......
from 秦小波《设计模式之禅》
所以,责任链模式这种设计模式对于我们遇到的这种一个登录入口、不同类型的对象需要不同处理的业务场景来说非常合适,不仅可以解除登录接口与具体登录处理类之间的耦合,还非常方便扩展,用它来处理多种用户同一登录入口的业务场景来说再合适不过了。
用户身份鉴定:固定的用户名格式
处理方法我们选好了,但是还有一个问题没有解决——怎么在只有一个登录入口的情况下区分不同用户呢?我想到了一个可能不是那么优雅的处理方法——将用户名设置为一个固定的格式,比如用户名的第五位是一个数字,这个数字被限定在0~5之间,代表五种用户。每次登录的时候,取出这一个标志位,根据这一标志位为对象设置合适的Level,然后再将其丢给责任链去处理。
使用责任链模式实现登录逻辑
既然责任链模式对于处理多种用户同一登录入口登录的业务场景来说如此合适,那该怎么使用呢?
登录对象的设计
既然要使用责任链模式来解决问题,那高层模块自然不能只向责任链传递username
和password
两个参数了,需要稍微封装一下。
首先,我们的系统中一共有6种用户,我们为他们分别指定一个Level:
- 管理员
- 学校
- 学院
- 教师
- 学生组织
- 学生
责任链模式除了处理者之外还涉及到三个类:Level
类负责定义请求和处理级别,Request
类负责封装请求,Response
类负责封装链中返回的结果。在这里,因为业务场景并不是很复杂,简单起见我把三个类合并成了一个类,这样虽然不是很规范,但是更加便于解决问题。这个类的接口如下:
/**
* 登录用户的DTO接口
*
* @author Architect
* @date 2020/3/13 7:42 下午
*/
public interface ILoginDTO {
/**
* 获得用户类型
*
* @return 一个整数,代表用户类型
*/
Integer getType();
/**
* 获得用户id,用于签发token
*
* @return 用户的id
*/
String getId();
/**
* 为DTO设置ID(数据库中的主键)
*
* @param id 要设置的id
* @return 设置好id的ILoginDTO对象
*/
ILoginDTO setId(String id);
/**
* 为DTO设置学校id
* 注意:
* 管理员用户并不需要学校id,管理员登录的DTO学校id为null
*
* @param schoolId 要设置的学校id
* @return 设置好学校id的ILoginDTO对象
*/
ILoginDTO setSchoolId(String schoolId);
/**
* 获得要登录用户的用户名
*
* @return 用户名
*/
String getUsername();
/**
* 获得要登录用户的密码
*
* @return 密码,在登录前是明文的,登录后是加密的
*/
String getPassword();
}
其中定义了一些登录中需要使用的方法,都是getter
和setter
。
再来看实现类:
/**
* 用户登录的DTO
*
* @author Architect
* @date 2020/3/13 7:45 下午
*/
@Data
@Accessors(chain = true)
@EqualsAndHashCode
public class LoginDTO implements ILoginDTO, Serializable {
/**
* 通过一个Integer类型的参数来描述这个要登录的用户的类型
*
* 0:管理员
* 1:学校
* 2:学院
* 3:教师
* 4:学生组织
* 5:学生
*/
private final Integer type;
/**
* 用户的id,用于签发token
* 只有在登录成功之后才会有id,否则为null
*/
private String id;
/**
* 要登录用户的用户名
*/
private String username;
/**
* 要登录用户的密码
*/
private String password;
/**
* 要登录用户的学校id
* 用于签发token和确认信息
*/
private String schoolId;
public LoginDTO(Integer type) {
this.type = type;
}
}
有Lombok
帮忙,上面接口里那些方法定义了相应的属性之后就可以自动生成了,不需要显式实现。
因为这个类的对象需要在RPC调用中被传输,所以还实现了Serializable
接口。
这里的type
属性就是刚才说的Level
,代表了用户的类型。
抽象处理者
责任链模式的核心就是这条“链”,它是由许多个具体的Handler
组成的。先定义一下抽象的Handler
:
/**
* 登录处理类父类
* 由于要登录的用户有多种类型,而登录入口又只有一个,所以采用责任链模式处理
*
* @author Architect
* @date 2020/3/13 7:57 下午
*/
public abstract class AbstractLoginHandler {
// ========== 用户类型代表数字定义 ==========
/** 管理员 */
public static final Integer USER_TYPE_ADMIN = 0;
/** 学校 */
public static final Integer USER_TYPE_SCHOOL = 1;
/** 学院 */
public static final Integer USER_TYPE_COLLEGE = 2;
/** 教师 */
public static final Integer USER_TYPE_TEACHER = 3;
/** 学生组织 */
public static final Integer USER_TYPE_ORGANIZATION = 4;
/** 学生 */
public static final Integer USER_TYPE_STUDENT = 5;
/**
* 因为学生基数大,登录次数多,所以设定为先处理学生的登录请求,再处理其他的
* 这样有助于提高性能
*/
private int type = 5;
/**
* 责任传递,下一个处理类是谁
* 这个字段有setter方法,用于设置责任链中下一个处理类
*/
@Setter
private AbstractLoginHandler nextHandler;
/**
* 一个用户请求登录,需要处理这个请求
*
* @param loginDTO 要登录的用户
* @return 登录的用户
*/
public final ILoginDTO handle(ILoginDTO loginDTO) {
// 如果是本处理类可以处理的登录请求,就处理它,然后返回结果
if (loginDTO.getType() == this.type) {
return this.handleLogin(loginDTO);
} else {
// 有后续处理类,把请求往后推,让下一个处理类来处理它
if (this.nextHandler != null) {
return this.nextHandler.handle(loginDTO);
} else {
// 已经没有后续处理类了,不用处理了,直接登录失败
throw new AuthorizationException("登录失败:该用户不存在");
}
}
}
/**
* 处理登录请求的抽象处理方法
*
* @param loginDTO 要登录的用户
* @return 登录的用户
*/
protected abstract ILoginDTO handleLogin(ILoginDTO loginDTO);
/**
* 每个LoginHandler都要说明一下自己能处理哪种用户的登录请求
*/
public AbstractLoginHandler(Integer level) {
this.type = level;
}
}
看起来好像很长,实际上注释占了一大半。这个抽象的Handler
实现了三个职责:一个是定义了处理方法——handle()
方法和处理登录的抽象方法handleLogin()
方法,提供了一个统一的入口;二是定义了链中下一个处理者nextHandler
;三是说明了自己能够处理的等级level
。
具体处理者
接下来先来看看已经实现好的第一个处理者——系统管理员登录的处理类:
/**
* 管理员的登录处理类
*
* @author Architect
* @date 2020/3/13 8:32 下午
*/
@Slf4j
public class AdminLoginHandler extends AbstractLoginHandler {
AdminService adminService;
/** 管理员登录处理类只处理管理员的登录请求 */
public AdminLoginHandler(AdminService adminService) {
super(AbstractLoginHandler.USER_TYPE_ADMIN);
// TODO @Reference 居然没法注入这里的 AdminService,获取到的是null,所以只能暂时用这个办法获取到
// TODO 等找到原因之后,最好还是改成 @Reference 注入,这样更符合Dubbo的本意
this.adminService = adminService;
}
@Override
protected ILoginDTO handleLogin(ILoginDTO loginDTO) {
// 调用管理员服务提供者从数据库里查找管理员用户
AdminDTO adminDTO = adminService.findByUsername(loginDTO.getUsername());
// 用户不存在,抛异常
if (adminDTO == null) {
throw new UserNotFoundException("登录失败:该管理员用户不存在");
}
// 密码错误,抛异常
if (!PasswordUtil.check(loginDTO.getPassword(), adminDTO.getPassword())) {
throw new InvalidUsernameOrPasswordException("登录失败:用户名或密码错误");
}
// 登录成功,打印日志,设置id
log.info("admin user [{}] login.", adminDTO.getUsername());
loginDTO.setId(adminDTO.getId());
// 把loginDTO返回就代表登录成功了
return loginDTO;
}
}
这里遇到个bug:因为登录实际上是要RPC调用管理员服务提供者的,所以要使用@Reference
注入AdminService
的引用,但是实际使用发现这里根本没法注入,只能在高层模块里才能获取到AdminService
的引用,不知道是怎么一回事。无奈之下,只好使用构造函数,让高层模块在初始化这个Handler
时把AdminService
的引用传递进来方便调用。
这个具体处理者设置了自己的处理级别,并且实现了自己的处理逻辑——针对管理员用户登录的处理逻辑。
高层模块
有了具体处理者之后,需要在高层模块里把它们连成链。这个高层模块就是登录RPC接口的实现类,也是原来所有登录相关的业务逻辑所在的地方:
/**
* 用户登录相关方法实现
*
* @author Architect
* @date 2020/3/8 3:11 上午
*/
@Service
@Component
public class LoginServiceImpl implements LoginService {
@Reference
AdminService adminService;
@Override
public LoginDTO login(LoginDTO loginDTO) {
// 定义管理员登录的Handler
AbstractLoginHandler adminLoginHandler = new AdminLoginHandler(adminService);
// 设置登录处理顺序
adminLoginHandler.setNextHandler(null);
// 不管loginDTO是什么类型,直接丢给第一个
// 然后返回处理结果
return (LoginDTO) adminLoginHandler.handle(loginDTO);
}
}
因为目前只有一个具体处理者,所以这个处理者的下一个处理者就直接设置成null了。
再看服务消费者:
/**
* SSO单点登录的Controller
*
* @author Architect
* @date 2020/3/8 10:53 下午
*/
@Slf4j
@RestController
@RequestMapping("/api/passport")
@ComponentScan(basePackages = {"com.timeline.common.util", "com.timeline.common.token"})
public class LoginController {
@Reference
LoginService loginService;
@Autowired
TokenInfo tokenInfo;
@Autowired
TokenUtil tokenUtil;
@PostMapping("/login")
@SentinelResource(value = "login")
public ResponseEntity<IResponse> login(@RequestBody @Valid LoginVO loginVO) {
// 获得用户名
String username = loginVO.getUsername();
// 从用户名中得到第5位:用户类型
// 0:管理员
// 1:学校
// 2:学院
// 3:教师
// 4:学生组织
// 5:学生
String userType = username.substring(4, 5);
// 构造一个loginDTO,把刚才获取到的类型设置上去
LoginDTO loginDTO = new LoginDTO(Integer.parseInt(userType));
loginDTO.setUsername(loginVO.getUsername()).setPassword(loginVO.getPassword());
// 登录
LoginDTO result = loginService.login(loginDTO);
// 登录成功
// 返回200状态码
if (result != null) {
// 根据登录的用户类型不同签发不同的token
switch (result.getType()) {
case 0:
// 管理员登录成功,签发管理员token
// 权限为admin,schoolId为0
return new ResponseEntity<>(new ResultBean<>(
tokenUtil.getToken("admin", result.getId(), "0")),
HttpStatus.OK);
default:
// 运行到这里表示用户类型不正确,抛异常
throw new CheckException("用户类型不正确");
}
} else {
// 登录失败,抛出失败异常
// 实际上这段代码不大可能运行到,因为登录失败上游服务就直接抛异常了
throw new AuthorizationException("登录失败");
}
}
}
这里逻辑比较简单,就是单纯的判断并设置用户类型、把前端传过来的LoginVO
值对象转换为LoginDTO
数据传输对象丢给服务提供者去处理,以及登录成功以后签发JWT token
。如果以后逻辑变得复杂了或者代码过长,这个方法还需要进一步拆分。
经过测试,这样写是可以成功登录、成功签发token的。
扩展——学校登录的具体处理者
只有一种用户登录自然是体现不出责任链模式的优势,但那只是暂时的。在开发完下一个服务提供者——学校服务提供者之后,就要开始写学校的登录逻辑了。
首先,需要再实现一个具体处理者,用来处理学校的登录:
/**
* 学校登录处理类
*
* @author Architect
* @date 2020/3/16 1:52 上午
*/
@Slf4j
public class SchoolLoginHandler extends AbstractLoginHandler {
SchoolService schoolService;
/** 学校登录处理类只处理学校的登录请求 */
public SchoolLoginHandler(SchoolService schoolService) {
super(AbstractLoginHandler.USER_TYPE_SCHOOL);
// TODO @Reference 居然没法注入这里的 SchoolService,获取到的是null,所以只能暂时用这个办法获取到
// TODO 等找到原因之后,最好还是改成 @Reference 注入,这样更符合Dubbo的本意,也更简单
this.schoolService = schoolService;
}
@Override
protected ILoginDTO handleLogin(ILoginDTO loginDTO) {
// 调用学校服务提供者从数据库里查找学校用户
SchoolDTO schoolDTO = schoolService.findByUsername(loginDTO.getUsername());
// 用户不存在,抛异常
if (schoolDTO == null) {
throw new UserNotFoundException("登录失败:该学校不存在");
}
// 密码错误,抛异常
if (!PasswordUtil.check(loginDTO.getPassword(), schoolDTO.getPassword())) {
throw new InvalidUsernameOrPasswordException("登录失败:用户名或密码错误");
}
// 学校登录成功,打印日志,设置id和学校id
// 学校id取出来是Integer,需要转换一下变成String
log.info("school [{}] login.", schoolDTO.getSchoolName());
loginDTO.setId(schoolDTO.getId());
loginDTO.setSchoolId(schoolDTO.getSchoolId().toString());
// 返回带有id属性的loginDTO,登录成功
return loginDTO;
}
}
跟前面管理员用户的具体处理者几乎没什么区别。
高层模块也要略作修改,把这个新的具体处理者加进来:
/**
* 用户登录相关方法实现
*
* @author Architect
* @date 2020/3/8 3:11 上午
*/
@Service
@Component
public class LoginServiceImpl implements LoginService {
@Reference
AdminService adminService;
@Reference
SchoolService schoolService;
@Override
public LoginDTO login(LoginDTO loginDTO) {
// 定义管理员登录的Handler
AbstractLoginHandler adminLoginHandler = new AdminLoginHandler(adminService);
// 定义学校登录的Handler
AbstractLoginHandler schoolLoginHandler = new SchoolLoginHandler(schoolService);
// TODO 现在只有管理员和学校的登录逻辑,责任链为:管理员->学校
// 设置登录处理顺序
adminLoginHandler.setNextHandler(schoolLoginHandler);
schoolLoginHandler.setNextHandler(null);
// 不管loginDTO是什么类型,直接丢给第一个
// 然后返回处理结果
return (LoginDTO) adminLoginHandler.handle(loginDTO);
}
}
大家可以对比一下,仅仅是定义了学校登录的具体处理者,并且把管理员登录的具体处理者的nextHandler
设置为这个学校登录的具体处理者(也就是把学校登录的具体处理者加入到责任链中),其他的代码几乎没有发生变化。
服务消费者稍作修改,添加签发学校token的逻辑,其他代码也没有任何改动:
/**
* SSO单点登录的Controller
*
* @author Architect
* @date 2020/3/8 10:53 下午
*/
@Slf4j
@RestController
@RequestMapping("/api/passport")
@ComponentScan(basePackages = {"com.timeline.common.util", "com.timeline.common.token"})
public class LoginController {
@Reference
LoginService loginService;
@Autowired
TokenInfo tokenInfo;
@Autowired
TokenUtil tokenUtil;
@PostMapping("/login")
@SentinelResource(value = "login")
public ResponseEntity<IResponse> login(@RequestBody @Valid LoginVO loginVO) {
// 获得用户名
String username = loginVO.getUsername();
// 从用户名中得到第5位:用户类型
// 0:管理员
// 1:学校
// 2:学院
// 3:教师
// 4:学生组织
// 5:学生
String userType = username.substring(4, 5);
// 构造一个loginDTO,把刚才获取到的类型设置上去
LoginDTO loginDTO = new LoginDTO(Integer.parseInt(userType));
loginDTO.setUsername(loginVO.getUsername()).setPassword(loginVO.getPassword());
// 登录
LoginDTO result = loginService.login(loginDTO);
// 登录成功
// 返回200状态码
if (result != null) {
// 根据登录的用户类型不同签发不同的token
switch (result.getType()) {
case 0:
// 管理员登录成功,签发管理员token
// 权限为admin,schoolId为0
return new ResponseEntity<>(new ResultBean<>(
tokenUtil.getToken("admin", result.getId(), "0")),
HttpStatus.OK);
case 1:
// 学校登录成功,签发学校token
// 权限为school,schoolId为该学校的学校id
return new ResponseEntity<>(new ResultBean<>(
tokenUtil.getToken("school", result.getId(), result.getSchoolId())),
HttpStatus.OK);
default:
// 运行到这里表示用户类型不正确,抛异常
throw new CheckException("用户类型不正确");
}
} else {
// 登录失败,抛出失败异常
// 实际上这段代码不大可能运行到,因为登录失败上游服务就直接抛异常了
throw new AuthorizationException("登录失败");
}
}
}
这时使用学校用户登录,仍然可以成功登录并且正确签发token。
至此,我们就成功地使用责任链模式完美地解决了多种用户同一入口登录的难题。