责任链模式是是设计模式中的一种行为型模式。该模式下,多个对象通过next属性进行关系关联,从而形成一个对象执行链表。当发起执行请求时,会从首个节点对象开始向后依次执行,如果一个对象不能处理该请求或者完成了请求工作(需要结合具体的业务场景),那么它会把相同的请求传给下一个接收者,依此类推。

责任链上的每个节点的处理者负责处理请求,用户只需要将请求发送到责任链上即可,无须关心请求的处理细节和请求的传递,所以责任链将请求的发送者和请求的处理者解耦了,即使以后多加一些责任节点,也可以做到很好的扩展。
上部分简单的介绍了责任链模式,那么现在就结合实际的业务场景来使用该模式。刚好这两天公司的产品经理就提出非常应景的需求。在用户登录时,需要判断登录账号存在的风险,比如在短时间内输入密码错误次数达到预设值,在短时间内,同一账号的登录所在地不属于同一个城市,登录ip地址不属于白名单范围内等。
当满足这些风险规则时,那么就需要根据需求对账号做进一步的处理,例如阻断登录,发送短信提醒或者禁用账号等。下面就使用责任链模式来实现这个需求功能。首先需要确定一个抽象处理类Handler,该处理类包含抽象处理方法和一个后继连接。
其次需要有若干个具体处理类XXXHandler,这个具体处理类需要继承抽象处理类Handler并且实现抽象处理者的处理方法,判断能否处理本次请求,如果可以处理请求则处理,否则将该请求转给它的后继者。最后需要有一个执行器或者客户端类来确定执行顺序,它不关心处理细节和请求的传递过程。

在正式编码前,需要确认有哪些数据表和对象。
风险规则类RiskRule: 用于记录不同规则信息,如触发条件,处置措施等。
登录日志类LoginLog: 用于记录登录日志,其中包含登录地区,登录ip等。
账户类UserAccount: 简单的账号account和密码password。
- @Data
- public class RiskRule {
-
- private Integer id;
-
- /**
- * 风险名称
- */
- private String riskName;
-
- /**
- * 白名单ip
- */
- private String acceptIp;
-
- /**
- * 触发次数
- */
- private Integer triggerNumber;
-
- /**
- * 触发时间
- */
- private Integer triggerTime;
-
- /**
- * 触发时间类型
- */
- private Integer triggerTimeType;
-
- /**
- * 异常登录时间 (json)
- */
- private String unusualLoginTime;
-
- /**
- * 采取的操作措施 1:提示 2:发送短信 3:阻断登录 4:封号
- */
- private Integer operate;
-
- }
- @Data
- public class LoginLog {
-
- @TableId(type = IdType.AUTO)
- private Integer id;
-
- private String account;
-
- private Integer result;
-
- private String cityCode;
-
- private String ip;
-
- private Date time;
- }
在确认完需要的对象后,现在可以编写登录风险处理抽象父类AbstractLoginHandle,该类需要包含一个nextHandle对象和filterRisk方法。filterRisk主要处理风险控制的规则并筛选出满足触发条件的规则对象,用于最后统一处理。
- /**
- * 登录风险处理抽象父类
- */
- public abstract class AbstractLoginHandle {
-
- public AbstractLoginHandle nextHandle; // 下一个执行节点
-
- public void setNextHandle(AbstractLoginHandle nextHandle){
- this.nextHandle = nextHandle;
- }
-
-
- /**
- * 具体的执行方法,过滤出满足风控的规则
- * @param filter 满足风控的规则
- * @param ruleMap 所有规则集合
- * @param account 登录账户
- */
- public abstract void filterRisk(List
filter, Map ruleMap, UserAccount account) ; -
- }
在创建完抽象父类后,下面开始实现具体的子类。首先是常见的密码错误次数,实现起来简单,需要去登录日志表中按照对应规则配置的规定时间来查询密码错误的日志即可。如果查询出的数量大于等于该规则的触发数量,那么就将该RiskRule对象添加到filter中,最后继续向下执行。
- /**
- * 密码错误次数风险实现
- */
- @Component
- public class PasswordErrorRiskHandle extends AbstractLoginHandle {
-
- // 配置触发时间间隔类型是秒
- private static final Integer SEC = 1;
-
- // 配置触发时间间隔类型是分钟
- private static final Integer MIN = 2;
-
- // 配置触发时间间隔类型是小时
- private static final Integer HOU = 3;
-
- @Resource
- private LoginLogService loginLogService;
-
- @Override
- public void filterRisk(List
filter, Map ruleMap, UserAccount account) { - if (MapUtil.isNotEmpty(ruleMap)) {
- //获取密码错误的规则信息
- RiskRule passwordRisk = ruleMap.get(1);
- if (passwordRisk != null) {
- //触发次数
- Integer triggerNumber = passwordRisk.getTriggerNumber();
- //触发时间
- Integer triggerTime = passwordRisk.getTriggerTime();
- //时间类型
- Integer triggerTimeType = passwordRisk.getTriggerTimeType();
-
- Date endTime = new Date();
-
- Date startTime;
-
- if (triggerTimeType == SEC) {
- startTime = DateUtil.offsetSecond(endTime, -triggerTime);
- } else if (triggerTimeType == MIN) {
- startTime = DateUtil.offsetMinute(endTime, -triggerTime);
- } else {
- startTime = DateUtil.offsetHour(endTime, -triggerTime);
- }
- // 查询范围时间内密码错误的次数
- Integer count = loginLogService.lambdaQuery().eq(LoginLog::getResult, 2)
- .eq(LoginLog::getAccount, account.getAccount())
- .between(LoginLog::getTime, startTime, endTime)
- .count();
- // 如果达到触发规则,则记录
- if (count != null && count.intValue() >= triggerNumber.intValue()) {
- filter.add(passwordRisk);
- }
- }
- }
- //是否有下一个节点 , 如果有,继续向下执行
- if (this.nextHandle != null) {
- this.nextHandle.filterRisk(filter, ruleMap, account);
- }
- }
-
- }
到底什么时间登录才算异常时间登录,这个需要根据公司,系统来做判断。如果一家公司从来不加班,用的也都是些OA系统,正常的登录时间段都在早上8点到下午6点这样。如果有一天,一个账号突然在凌晨两三点进行了登录,那么这就可以算作异常登录。
当然,具体的时间段可以根据实际的需求进行设置。为了方便,这些时间段直接以json的方式存在的数据表中,具体格式如下。
- [
- {
- "week":0,
- "startTime":"12:00:00",
- "endTime":"14:00:00"
- },
- {
- "week":1,
- "startTime":"12:00:00",
- "endTime":"14:00:00"
- }
- ]
这个需求实现也非常简单,只需要判断当前的登录时间是否在配置的异常登录时间范围内即可,如果在这个范围为内,那么就将该风险规则添加到filter中。
- /**
- * 异常时间登录风险实现
- */
- @Component
- public class UnusualLoginRiskHandle extends AbstractLoginHandle {
-
- @Override
- public void filterRisk(List
filter, Map ruleMap, UserAccount account) { - if (MapUtil.isNotEmpty(ruleMap)) {
- RiskRule loginTimeExe = ruleMap.get(2);
- if (loginTimeExe != null) {
- // 将json转为异常时间对象
- List
unusualLoginTimes = JSONUtil.toList(loginTimeExe.getUnusualLoginTime(), UnusualLoginTime.class); - Date now = new Date();
- // 判断当前时间是周几
- int dayOfWeek = DateUtil.dayOfWeek(now);
- for (UnusualLoginTime unusualLoginTime : unusualLoginTimes) {
- // 如果当前的周数与配置的周数相等,那么判断当前的具体时间
- if (unusualLoginTime.getWeek() == dayOfWeek) {
- DateTime startTime = DateUtil.parseTimeToday(unusualLoginTime.getStartTime());
- DateTime endTime = DateUtil.parseTimeToday(unusualLoginTime.getEndTime());
- // 如果当前的时间,在配置的时间范围内,那么将算作异常时间登录
- if (DateUtil.isIn(now, startTime, endTime)) {
- filter.add(loginTimeExe);
- break;
- }
- }
- }
- }
- }
- // 是否有下一个节点 , 如果有,继续向下执行
- if (this.nextHandle != null) {
- this.nextHandle.filterRisk(filter, ruleMap, account);
- }
- }
-
- @Data
- public static class UnusualLoginTime {
-
- private int week;
-
- private String startTime;
-
- private String endTime;
- }
- }
在使用一些阿里云服务时,有时需要配置一些ip白名单才可以访问,非白名单内的ip将会阻断连接。这也是一种保证系统服务安全的一种方式,实现起来也比较容易。从数据库中读取ip白名单,如果是多个,可以使用英文逗号进行分割。
用户登录时,通过HttpServletRequest来获取用户的ip(这里为了方便测试,将ip作为一个字段放在了account中),如果这个ip不在白名单内,那么将这个风险规则添加到filter中。
- /**
- * 登录ip风险实现
- */
- @Component
- public class IPRiskHandle extends AbstractLoginHandle {
-
- @Override
- public void filterRisk(List
filter, Map ruleMap, UserAccount account) { - if (MapUtil.isNotEmpty(ruleMap)) {
- RiskRule ipRisk = ruleMap.get(3);
- //判断是否配置登录ip白名单
- if (null != ipRisk && StrUtil.isNotEmpty(ipRisk.getAcceptIp())) {
- List
acceptIpList = Arrays.asList(ipRisk.getAcceptIp().split(",")); - //当前登录ip是否在白名单内,如果不在,则添加到filter中
- if (!acceptIpList.contains(account.getIp())) {
- filter.add(ipRisk);
- }
- }
- }
- if (this.nextHandle != null) {
- this.nextHandle.filterRisk(filter, ruleMap, account);
- }
- }
- }
如果一个账号在短时间内在不同地区进行了登录操作,比如上一秒在北京登录,下一秒就在上海进行了登录。
那么这就可能出现了账号盗取情况,需要采取一定的处置措施,比如输入短信验证码,输入密保,封号等。
- /**
- * 登录地区风险实现
- */
- @Component
- public class LoginAreaRiskHandle extends AbstractLoginHandle {
-
- private static final Integer SEC = 1;
-
- private static final Integer MIN = 2;
-
- private static final Integer HOU = 3;
-
- @Resource
- private LoginLogService loginLogService;
-
- @Override
- public void filterRisk(List
filter, Map ruleMap, UserAccount account) { - if (MapUtil.isNotEmpty(ruleMap)) {
- RiskRule areaRisk = ruleMap.get(4);
- if (null != areaRisk) {
- Integer triggerTime = areaRisk.getTriggerTime();
- Integer triggerTimeType = areaRisk.getTriggerTimeType();
- Integer triggerNumber = areaRisk.getTriggerNumber();
- Date endTime = new Date();
- Date startTime;
- //获取查询时间范围的开始时间
- if (triggerTimeType == SEC) {
- startTime = DateUtil.offsetSecond(endTime, -triggerTime);
- } else if (triggerTimeType == MIN) {
- startTime = DateUtil.offsetMinute(endTime, -triggerTime);
- } else {
- startTime = DateUtil.offsetHour(endTime, -triggerTime);
- }
- // 指定时间范围内,登录地区是否超过指定个数
- List
loginLogList = loginLogService.lambdaQuery().select(LoginLog::getCityCode).between(LoginLog::getTime, startTime, endTime) - .eq(LoginLog::getResult, 1)
- .eq(LoginLog::getAccount, account.getAccount())
- .list();
- long areaCount = CollUtil.emptyIfNull(loginLogList).stream().map(LoginLog::getCityCode).distinct().count();
- //如果超过指定个数,则将该风险策略添加到filter
- if (areaCount >= triggerNumber.longValue()) {
- filter.add(areaRisk);
- }
- }
- }
- if (this.nextHandle != null) {
- this.nextHandle.filterRisk(filter, ruleMap, account);
- }
- }
- }
再将上面的各种情况实现完成后,需要有一个执行器来聚合这些handle。让这些hande节点有一定的执行顺序。
并且在所有节点执行完成后,对触发的风险规则进行处理。我自己定义的执行顺序是密码错误次数->异常时间登录->ip白名单->异常地区登录。
- @Slf4j
- @Component
- public class LoginHandleManage {
-
- @Resource
- private RiskRuleService riskRuleService;
-
- @Resource
- private LoginLogService loginLogService;
-
- @Resource
- private IPRiskHandle ipRiskHandle;
-
- @Resource
- private LoginAreaRiskHandle loginAreaRiskHandle;
-
- @Resource
- private PasswordErrorRiskHandle passwordErrorRiskHandle;
-
- @Resource
- private UnusualLoginRiskHandle unusualLoginRiskHandle;
-
-
- /**
- * 构建执行顺序
- * passwordErrorRiskHandle -> unusualLoginRiskHandle -> ipRiskHandle -> loginAreaRiskHandle
- */
-
- @PostConstruct
- public void init() {
- passwordErrorRiskHandle.setNextHandle(unusualLoginRiskHandle);
- unusualLoginRiskHandle.setNextHandle(ipRiskHandle);
- ipRiskHandle.setNextHandle(loginAreaRiskHandle);
- }
-
-
- /**
- * 执行链路入口
- * @param account
- * @throws Exception
- */
- public void execute(UserAccount account) throws Exception {
- //获取所有风险规则
- List
riskRules = riskRuleService.lambdaQuery().list(); - Map
riskRuleMap = riskRules.stream().collect(Collectors.toMap(RiskRule::getId, r -> r)); - List
filterRisk = new ArrayList<>(); - //开始从首节点执行
- passwordErrorRiskHandle.filterRisk(filterRisk, riskRuleMap, account);
- if (CollUtil.isNotEmpty(filterRisk)) {
- // 获取最严重处置措施的规则
- Optional
optional = filterRisk.stream().max(Comparator.comparing(RiskRule::getOperate)); - if (optional.isPresent()) {
- RiskRule riskRule = optional.get();
- handleOperate(riskRule);//处置
-
- //TODO 记录日志
-
- }
- }
- }
-
- /**
- * 处置风险
- * @param riskRule
- * @throws Exception
- */
-
- public void handleOperate(RiskRule riskRule) throws Exception {
- int operate = riskRule.getOperate().intValue();
- if (operate == OperateEnum.TIP.op) { //1
- log.info("========执行提示逻辑========");
- } else if (operate == OperateEnum.SMS.op) {//2
- log.info("========执行短信提醒逻辑========");
- } else if (operate == OperateEnum.BLOCK.op) {//3
- log.info("========执行登录阻断逻辑========");
- throw new Exception("登录存在风险!");
- } else if (operate == OperateEnum.DISABLE.op) {//4
- log.info("========执行封号逻辑========");
- throw new Exception("登录存在风险,账号被封!");
- }
- }
- }
现在所有的逻辑已经搞定了,那么在登录的实现方法中只需要注入LoginHandleManage并调用execute即可,这样就可以与主体的登录逻辑代码实现解耦。
责任链模式使用了委托的思想构建了一个链表,通过遍历链表来挨个询问链表中的每一个节点是否可以胜任某件事情,如果某个节点能够胜任,则直接处理,否则继续向下传递。责任链会造成处理的时延,但是能够很好的解耦合,提高可扩展性,可以结合具体场景,选择性使用。