挑战!一个人开发Spring Cloud Alibaba微服务(4)使用责任链模式处理多种用户相同登录入口登录的问题

上一篇: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,然后再将其丢给责任链去处理。

使用责任链模式实现登录逻辑

既然责任链模式对于处理多种用户同一登录入口登录的业务场景来说如此合适,那该怎么使用呢?

登录对象的设计

既然要使用责任链模式来解决问题,那高层模块自然不能只向责任链传递usernamepassword两个参数了,需要稍微封装一下。

首先,我们的系统中一共有6种用户,我们为他们分别指定一个Level:

  1. 管理员
  2. 学校
  3. 学院
  4. 教师
  5. 学生组织
  6. 学生

责任链模式除了处理者之外还涉及到三个类: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();
}

其中定义了一些登录中需要使用的方法,都是gettersetter

再来看实现类:

/**
 * 用户登录的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。

至此,我们就成功地使用责任链模式完美地解决了多种用户同一入口登录的难题。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,122评论 6 505
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,070评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,491评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,636评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,676评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,541评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,292评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,211评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,655评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,846评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,965评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,684评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,295评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,894评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,012评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,126评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,914评论 2 355

推荐阅读更多精彩内容

  • 书桌上放置着已开启两包价值不菲用于招待来访朋友的香烟,尽管我对此毫无兴趣,但即便哪天突发奇想狂抽几盒染上了令人作呕...
    程忆文阅读 587评论 5 14
  • 函数声明里的参数叫形参,函数调用里的参数叫实参。 Javascript函数定义不指定形参的类型,函数调用也不对实参...
    IFELSE阅读 289评论 0 0
  • 前言 最近在项目开发中听同事提起postcss,然后就去网上看了些关于这方面的文章,看的也是眼花缭乱,什么让css...
    飞到心头的小鸟阅读 944评论 0 0