以小程序、短信验证码、账户密码三种方式登陆为例。
1.遇到的问题
1.1 以常规的接口设计来讲三种方式的接口请求参数各不相同,并且各自的业务逻辑处理不同,将会导致多个登陆接口暴露给前端。例如:
1.2 在此基础上也可以统一登陆的方式,例如暴露统一的api登陆接口,接口参数可以兼容多种登陆方式,在service层进行判断,调用不同的处理逻辑。
1.3 业务逻辑也可能根据登陆的方式进行大量的IF,ELSE操作,导致增加了很多繁琐的逻辑。从而也使得整个代码的可读性,可维护性大大降低,出错的几率更大。
1.4 不管是统一的API接口还是单独的API接口方式都存在了很多的问题。比如:登陆日志的收集、用户权限的校验、登陆后需要预执行的程序等等一系列的统一操作,在上述的方案中都存在同一份逻辑代码在多个场景下编写多份或者调用多份的情况,导致代码的复用性降低也导致代码比较臃肿。
2.解决方案
2.1 策略模式
策略模式作为一种软件设计模式,指对象有某个行为,但是在不同的场景中,该行为有不同的实现算法。比如每个人都要“交个人所得税”,但是“在美国交个人所得税”和“在中国交个人所得税”就有不同的算税方法。
实际上不同的登陆方式就是一种策略,这里不细讲策略模式的理论。目前我们有三种登陆方式,如果后续增加登陆方式,对程序而言都是一种不同的策略而已,我们只需要关注具体的策略实现即可,无需改动到其他的登陆方式。
2.2 模板方法
在统一登陆的过程中是无法避免一些统一的动作,上文也提到了登陆日志的收集等操作。
如果我们使用了策略模式那不是每一个策略都要重复这么一个动作,这种统一的动作越多,那么我们需要处理的也越多,如果统一的逻辑出现了改动,那么之前编写好的策略也会进行改动。那么如何解决这个问题呢?
因此我们采用模板方法来解决,我们可以抽象一个模板方法类,由他去定义统一登陆的流程,公共的业务处理。具体的策略由业务策略类自定义去实现。
1.Gradle配置文件
plugins {
id 'org.springframework.boot' version '2.3.4.RELEASE'
id 'io.spring.dependency-management' version '1.0.9.RELEASE'
id 'java'
}
group 'org.example'
version '1.0-SNAPSHOT'
repositories {
maven { url 'https://maven.aliyun.com/nexus/content/groups/public' }
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.projectlombok:lombok'
implementation 'com.alibaba:fastjson:1.2.73'
annotationProcessor 'org.projectlombok:lombok'
}
test {
useJUnitPlatform()
}
2.启动类
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
@SpringBootApplication
public class DemoApp extends SpringBootServletInitializer {
public static void main(String[] args) {
SpringApplication.run(DemoApp.class, args);
System.out.println("DemoApp started...");
}
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(DemoApp.class);
}
}
1 策略模式与模版方法的融合
1.1 定义模版方法
首先我们要考虑好一个统一登陆需要做一些什么业务?顺序是怎么样的?我这里简单的举个例:
准备工作:
Json工具类
public class Json {
public static <T> T str2Bean(String jsonStr, Class<T> destClazz) {
return JSON.parseObject(jsonStr, destClazz);
}
public static String bean2Str(Object origObj) {
return origObj == null ? null : JSON.toJSONString(origObj);
}
}
代码实现:
1.2 定义登陆基础DTO
/**
* 用户信息
*/
@Data
public class UserInfo implements Serializable {
/**
* 用户姓名
*/
private String username;
/**
* 用户ID
*/
private String userId;
}
/**
* 基础登陆对象
*/
@Data
public class LoginRo implements Serializable {
/**
* 登陆方式
* 用于匹配登陆的策略
*/
private String loginType;
}
1.3 定义统一登录接口
public interface LoginAdapter {
/**
* 用户统一登录接口
* @param loginForm
* @return
*/
UserInfo userLogin(String loginForm);
}
1.4 登陆模板方法定义
/**
* 登陆模版
*/
@Component
@Slf4j
public abstract class LoginTemplate implements LoginAdapter{
/**
* 用户响应信息
*/
protected UserInfo userInfo;
/**
* 用户登陆信息
*/
protected LoginRo loginRo;
@Override
public UserInfo userLogin(String loginForm) {
//初始化登陆参数
this.initialize(loginForm);
//登陆前Log记录
this.logBefore();
//自定义登陆
UserInfo userLoginInfo = this.login();
//校验黑名单
this.isBlacklist();
//异地风控校验
this.risk();
//用户权限校验
this.checkUserAuth();
//构建登陆用户响应数据
this.buildUserInfo(userLoginInfo);
//记录登陆信息
this.logAfter();
//登陆完成
return userInfo;
}
//子类可以自定义初始化登陆的参数
protected abstract void initialize(String loginForm);
//子类实现各自的策略
protected abstract UserInfo login();
private void logAfter() {
log.info("logAfter......");
}
private void buildUserInfo(UserInfo userLoginInfo) {
//将策略执行后的用户信息赋值给当前模板
this.userInfo = userLoginInfo;
log.info("buildUserInfo:{}", userLoginInfo);
}
private void checkUserAuth() {
log.info("checkUserAuth......");
}
private void risk() {
log.info("risk......");
}
private void isBlacklist() {
log.info("isBlacklist......");
}
private void logBefore() {
log.info("logBefore......");
}
}
2 以账号密码为例实现登陆
2.1 编写账号密码登录DTO
@Data
public class AccountPwdLoginRo implements Serializable {
/**
* 账号
*/
private String account;
/**
* 密码
*/
private String password;
}
2.2 编写账号密码登录策略类
@Service
public class AccountPwdLogProcessor extends LoginTemplate {
private AccountPwdLoginRo pwdLoginRo;
@Override
protected void initialize(String loginForm) {
AccountPwdLoginRo pwdLoginRo = Json.str2Bean(loginForm, AccountPwdLoginRo.class);
//对模板的登录参数赋值、可在其他校验方式中使用
super.loginRo = pwdLoginRo;
//对当前的上下文的登录参数赋值、可在下面的登录方法中使用
this.pwdLoginRo = pwdLoginRo;
}
@Override
protected UserInfo login() {
//模拟账号密码登录
String accountByDB = "admin";
String passwordByDB = "123456";
//这里不考虑其他因素。实际的登录可能复杂很多、例如空校验等
if (this.pwdLoginRo.getAccount().equals(accountByDB) && this.pwdLoginRo.getPassword().equals(passwordByDB)) {
//登录成功
UserInfo userLoginInfo = new UserInfo();
userLoginInfo.setUsername("admin");
userLoginInfo.setUserId("001");
return userLoginInfo;
}else{
//抛出异常以及登录失败的原因、为简化代码此处不封装自定义异常
throw new RuntimeException("账号密码错误或账号不存,登录失败");
}
}
}
2.3 编写登陆策略获取接口
@Component
public class LoginProcessor {
/**
* 根据loginType获取登录的策略
* @param loginRo
* @return
*/
public LoginAdapter userLogin(LoginRo loginRo) {
if (loginRo.getLoginType().equals("1")) {
return new AccountPwdLogProcessor();
}
return null;
}
}
2.4 统一登陆API
@RestController
public class LoginApi {
@Resource
private LoginProcessor loginProcessor;
@PostMapping(value = "/api/userLogin")
@ResponseBody
public String userLogin(LoginRo loginRo, HttpServletRequest request) {
UserInfo userInfo = loginProcessor.userLogin(loginRo)
.userLogin(getRequestJson(request));
return Json.bean2Str(userInfo);
}
/**
* 获取JSON请求参数
* @param request
* @return
*/
public String getRequestJson(HttpServletRequest request) {
try {
BufferedReader streamReader = new BufferedReader(new InputStreamReader(request.getInputStream(), "UTF-8"));
StringBuilder sb = new StringBuilder();
String line = null;
while ((line = streamReader.readLine()) != null) {
sb.append(line);
}
return sb.toString();
} catch (Exception e) {
e.printStackTrace();
}
return "";
}
}
3 增加短信验证码登陆策略
3.1 短信验证登陆DTO
@Data
public class AccountSmsLoginRo extends LoginRo {
/**
* 短信验证码
*/
private String smsCode;
/**
* 短信验证码ID
*/
private String smsId;
/**
* 手机号
*/
private String account;
}
3.2 短信验证码登陆策略
@Service
public class AccountSmsLogProcessor extends LoginTemplate {
private AccountSmsLoginRo smsLoginRo;
@Override
protected void initialize(String loginForm) {
AccountSmsLoginRo smsLoginRo = Json.str2Bean(loginForm, AccountSmsLoginRo.class);
//对模板的登录参数赋值、可在其他校验方式中使用
super.loginRo = smsLoginRo;
//对当前的上下文的登录参数赋值、可在下面的登录方法中使用
this.smsLoginRo = smsLoginRo;
}
@Override
protected UserInfo login() {
//模拟账号密码登录
String accountByDB = "admin";
String smsIdByRedis = "UUID";
String smsCodeByRedis = "123456";
//这里不考虑其他因素。实际的登录可能复杂很多、例如空校验等
if (this.smsLoginRo.getAccount().equals(accountByDB) && this.smsLoginRo.getSmsId().equals(smsIdByRedis)
&& this.smsLoginRo.getSmsCode().equals(smsCodeByRedis)) {
//登录成功
UserInfo userLoginInfo = new UserInfo();
userLoginInfo.setUsername("admin");
userLoginInfo.setUserId("001");
return userLoginInfo;
} else {
//抛出异常以及登录失败的原因、为简化代码此处不封装自定义异常
throw new RuntimeException("验证码错误或账号不存在,登录失败");
}
}
}
到此的话我们已经完成了策略模式与模版方法的融合,完成了统一登陆接口,但是我们发现LoginProcessor还是避免不了IF,ELSE的操作。那我们如何去解决这个问题?这个在另一篇文章再继续解决。
项目结构图: