不要忘记我这次重看视频的初衷可是要整一个Android借助本地MySQL进行登录注册的功能
第3章·用户模块开发的基本流程:
整体架构分层—>用户信息分层处理 —> 封装返回信息 —> otp手机验证码开发 —> 前端页面开发—>登录注册功能开发
上一章,我们学会了:
接下来就要详细的学习使用SpringMVC了,具体知识点有:
MVC分层架构
通用返回类型
otp验证码生成
登录、注册功能
项目中的校验逻辑
一、整体架构分层
上一章的最后我们在App.java中简单的使用了具有RESTful风格的SpringMVC,但总不能所有逻辑都写到App.java中吧,因此,急需做的事情就是先分层。
新建两个包,一个controller,一个service
在controller里新建一个UserController,service里新建一个接口UserService以及对应实现类UserServiceImpl
而Service层是要将用户模型即用户信息返回给Controller层,需要访问数据库。
这样就有需要一个DAO层,这一层Mybatis已经帮我们实现好了。
它们的关系是:
URL请求发送到Controller层,
Controller层调用Service层,
Service层调用DAO层,
DAO层通过Mybatis的mapping映射进行数据库的CURD操作(CURD就是增删改查的意思)。
对整体架构分层总结:
Controller层需要在类级别加上@Controller(…)、@RequestMapping(…)、@CrossOrigin(…)
Service层只需要在类级别加上@Service
对于Controller,注解加载子类上,BaseController不需要
对于Service,注解加载实现类上,UserService接口不需要
这些层是前辈们多少年的工业经验,作为后辈应以谦卑的心态虚心学习,理解其中的思想,诸如解耦,可扩展性,易维护性,单一职责原则(SRP)等等。
二、用户模型在三层不同的处理方式
上面的流程是从View层到Model层,下面再来理解一下从Model层到View层的流程。
DAO层:最底层的就是DAO从数据库一一映射过来的dataobject,DAO层需要做的就是用户信息的CURD工作。
Service层:在设计数据库的时候我们因为业务需求,把用户信息和密码进行了分表设计,这样就会导致出现UserDO和UserPasswordDO两个dataobject。Service层需要做的工作就是将这两个DO整合到一起并封装为model。
Controller层:从Service层获取到model,但具体方法需要具体使用。比如Controller层的getUser方法,前端发送localhost:8090/user/get请求,请求用户信息。前端没必要知道用户的密码,登录方式等敏感信息。因此就需要一个viewobject层,用来在返回给前端时屏蔽掉敏感信息。
疑问:model整合dataobject而来,现在又要进行屏蔽,图个啥?
回答:Controller层的具体方法具体使用,getUser方法是用来返回给前端的,用不到model中的一些信息,因此使用viewobject屏蔽用户密码等敏感信息,但是Controller层不是仅仅getUser这一个功能,比如还有登录验证login等功能,这时候就需要model更多的信息来进行比较了。
对用户信息分三层的总结:
1. DAO层 --- dataobject,与数据库一一映射
2. Service层 --- model,整合因业务需求而分离的dataobject
3. Controller层 --- viewobject,屏蔽如密码等敏感信息返回给前端
如果能够理解这三层的具体作用,对整个项目的理解会有很大的帮助。
三、Controller中方法的返回值与类型
我们知道,Controller层返回值是返回给前端页面的。
然而Controller中的方法返回值类型会出现不同,比如返回viewobject,返回null,返回异常Exception信息等等。如果每个方法都有独自的返回值类型的话,那样的设计不太好,也不容易以统一的形式(比如统一的JSON格式)呈现给前端。好的解决方法就是独立出一层:response,创建一种通用的返回值类型CommonReturnType,它有两个属性status和data。
status:success表示成功,fail表示失败;
data:会根据具体业务的不同返回正确信息viewobject或异常信息(异常信息包含errorCode和errorMsg)或空。
CommonReturnType构造对象的代码如下,正如三少老师所讲,二重奏方法:
public static CommonReturnType create(Object result){
return CommonReturnType.create(result,"success");
}
public static CommonReturnType create(Object result, String status){
CommonReturnType type = new CommonReturnType();
type.setData(result);
type.setStatus(status);
return type;
}
四、返回正确信息
返回正确信息的具体实现已经在response包下CommonReturnType类内部写出,既然是正确的信息,那么status就是指定的“success”,而data会根据业务需要或者返回viewobject,或者返回null。
有关null的设计,可以去看一下《Java null的艺术处理》
有了这样结构化的返回值,再加上@ResponseBody注解,就会被序列化为前端容易理解的JSON字符串。
比如登录验证成功后会返回给前端
{
"status":"success",
"data":null
}
再比如成功获取用户信息后会返回给前端
{
"status":"success",
"data":{
"name":"张三",
"age":"18",
......
}
}
五、返回错误信息
错误信息有多种情况,比如业务异常:用户不存在,输入数据异常等等,再比如非业务异常:空指针异常等;因此需要进行特殊的设计,实现统一格式。我们需要独立出一层:error,然后抽象出一个CommonError接口:
public interface CommonError {
public int getErrorCode();
public String getErrorMsg();
public CommonError setErrorMsg(String errorMsg);
}
然后新建一个业务上异常类BusinessException extends Exception implements CommonError,
这个BusinessException类的实现属于一种设计模式:包装器业务异常类实现;
但凡是出现设计模式的地方,都蕴含着深刻的内涵,有些一看就知道有内涵,有些乍看没内涵,但总会有一天回首发现,每个设计模式都有它的精妙之处。
因为异常有ErrorCode和ErrorMsg两个属性,因此我们将异常封装一下抽象到一个枚举类EmBusinessError中
有关Enum类的介绍,可以参考:【CSDN】zejian_《深入理解Java枚举类型(enum)》
另外,写这个博客的博主写了一系列Java知识点深入理解的文章,可以去参观一下。
这样,当Controller层出现业务异常的时候,可以使用:
throw new BusinessException(EmBusinessError.USER_NOT_EXIST);
进行抛出,当然如果遇到java.lang.NullPointException这种运行时异常而非业务异常,JVM虚拟机会自动抛出。
但不管是直接throw的业务异常或者JVM虚拟机抛出的运行时异常,仅仅是抛到了Tomcat的容器层,我们在Controller层并不能进行处理。
有关Tomcat的具体分层,可以看一下《看透SpringMVC源代码分析与实战》这本书,我记得是上来就讲了Tomcat的源码
好在SpringBoot早就为我们实现好了一个注解
@ExceptionHandler(Exception.class)
在Controller层自定义处理异常的方法上添加注解,并指定要拦截的异常类型,就可以把异常拦截到,进行自定义处理了。
处理方式就是先判断抛出的Exception是否是BusinessException,如果是的话,把Exception强转为BusinessException,使用CommonReturnType进行封装对应的status和data。
如果不是BusinessException这种业务异常,那就统一归为未知错误,处理方法也是使用CommonReturnType封装出同一个errorCode和errorMsg。
像处理异常这种工作,不只是UserController要用到,以后处理其他模块也有可能有异常,因此将代码提取到BaseController中,让UserController继承BaseController,这样以后扩展其他模块时,代码就可以复用了。
返回异常JSON格式如下
{
"status":"fail",
"data":{
"errorCode":10001,
"errorMsg":"未知错误"
}
}
对返回值与类型的总结:
对于这个返回值与类型还是需要多理解,虽然这是第二遍看这个视频,但理解起这个来还是有些吃力。
六、otp验证码获取
前端发送URL请求,localhost:8090/user/getotp,URL映射到Controller层,生成otpCode,与手机号进行绑定,最后通过短信通道返回给用户。最后要留意,ajax请求发过来会有跨区请求问题。SpringBoot提供的解决方法就是需要在Controller类级别加上@CrossOrigin注解。
在这一阶段,老师没有验证手机号是否重复注册。我把这一功能给添加上了。流程如下:
Controller层接收用户输入的手机号,调用Service层的getUserByTelphone()方法,Service层通过userDOMapper的selectByTelphone去数据库中查询,如果手机号已存在,返回true,否则false。
Controller层根据Service层的返回值判断是否已注册,如果重复注册,则抛出异常给前端,如果未注册,走正常获取验证码流程。
对otp验证码获取的总结:
Otp验证码的生成方式就是单纯的Random随机数。
Otp验证码与手机号进行绑定,企业级是使用Redis来实现的,但是目前这个项目属于入门级别,暂时用httpServletRequest对象获取session方式来绑定手机号与验证码。
Otp验证码发送给前端方式,因为是个测试项目,也就没有花钱去接入第三方短信通道,老师是直接在控制台输出绑定的手机号和验证码。我们这里升级成了返回给前端,然后前端接收返回的JSON值解析后进行自动填充。
七、用户注册流程
用户在前端填写好数据,发送URL请求 localhost:8090/user/register,URL映射到Controller层,Controller层将数据组装成userModel对象传给Service层,Service层实现具体的register方法。register方法首先要做的就是将userModel进行拆分,然后调用DAO层插入数据库,注意要加上@Transactional事务注解。
有关判空处理的文章推荐:《java匠人手法-优雅的处理空值》
关于传参,前端Android和后端的SpringMVC一定要把参数名称一一对应,比如我就犯了一个错误就是,前端传参的key为username,后端接收的key为name。导致无法传递成功。
关于主键外键,user_password表里有个字段user_id, 是持有user_info表的id作为外键。Controller层获取用户传递过来的数据的时,是没有id的, 用户在注册的时候,也不可能知道自己在数据库中的id。所以我们需要去UserDOMapper.xml文件中,手动指定insertSelective方法中id为主键自增。同时需要在执行insertSelective方法之后,通过UserDO,从数据库中查询到当前id,然后赋值给userModel。让userModel带着它去构建userPasswordDO。
关于重复用户,我们注册的时候是需要填写手机号的,而手机号是唯一的,所以在设计数据库的时候,我们可以把user_info表中的telphone字段设为唯一索引。之所以会出现重复用户,就是在Service层,register方法中的userDOMapper.insertSelective(userDO);语句重复执行了,如果设置好了唯一索引,那么,再次执行插入的时候就会抛出异常,我们可以在这里进行try-catch捕获,然后自定义抛出一个手机号已注册的异常。
关于跨域请求,在上面otp验证码获取时就说到了跨域请求的问题,那里仅仅是get请求,只写一个@CrossOrigin注解即可,但注册这里是POST请求,因此需要在@CrossOrigin注解后面再添加两个参数,即@CrossOrigin(allowCredentials = “true”, allowedHeaders = “*”)
关于密码,用户写的密码是明文密码,属于敏感信息,数据库不应该直接存储用户敏感信息,常用的手法就是在Controller层进行MD5加密后再传给Service层进行插入操作。
其实getInstance(“MD5”)这里可选的算法有好多,比如SHA-1、SHA-256、SHA-512,具体可以参考:
MessageDigest Algorithms
base64百度百科
// MD5加密+BASE64编码
public String EncodeByMd5(String str) throws NoSuchAlgorithmException, UnsupportedEncodingException {
MessageDigest md5 = MessageDigest.getInstance("MD5");
BASE64Encoder base64en = new BASE64Encoder();
String newstr = base64en.encode(md5.digest(str.getBytes("utf-8")));
return newstr;
}
话说,当年这些算法都属于美国军方算法,随着科技进步与民众需要,将其公之于众。
这些算法具体设计我还不了解,但使用方式就是上面那样,先获取一个指定算法的MessageDigest实例,然后调用md5.digest()对UTF-8的编码的字符串进行加密,单纯的MD5加密返回值是32位字符串定长,最后再调用BASE64Encoder的encode方法进行编码,使其成为具有不可读性的字符串。天底下没有绝对的安全,只不过这样整相对保险一些。这样的代码,无他,孰能生巧耳!
关于判空处理,首先明确一点,后端的判空处理都是在Service层。一开始就是最原始的校验规则,后来老师在优化校验规则一节里,给出了如何使用Hibernate的Validator进行优化,使校验规则具有通用性,从而实现代码复用。这里具体的实现方法,我还是第一次接触,需要再多看几遍加深理解。
对用户注册流程的总结:
在文章一开始,讲了用户数据从数据库,通过Mybatis转化为dataobject,然后整合为model,再屏蔽敏感信息成为viewobject。
注册流程这里是上面过程的逆过程,即用户填写viewobject,转化为model,再拆分为dataobject,最后在通过DAO插入数据库。
通过注册流程可以看出,Controller层做的事情只是传递用户填写的数据给Service层,具体的逻辑判断都是在Service层的实现里写的。
另外,要时刻注意代码的健壮性,牢记判空处理,作为一个健壮的项目,前后端都要有判空处理,而后端判空处理的地方就是Service层。
最后,要考虑信息安全性,对于用户敏感信息,诸如密码之类,一定不能明文存到数据库,一定要在Controller层进行加密然后再传递给Service层,执行插入数据库操作。
八、用户登录流程
用户注册流程,后端Controller层需要获取用户输入,然后封装成Model,传递给Service层,Service层再拆分成dataobject,调用DAO层,将DO插入数据库。
登录和注册流程相似,只不过在调用DAO层时,不是进行插入操作,而是查询操作。
查询密码的时候,需要修改两个文件(Mybatis自动生成的只有selectByPrimaryKey)
UserPasswordDOMapper.xml 添加 selectByUserId
UserPasswordDOMapper.java 添加 selectByUserId
UserPasswordDOMapper.java中的方法与UserPasswordDOMapper.xml 中数据库CURD语句一一映射。
其他流程可以参考注册流程,这里就不再赘述。
九、后端所有带异常的方法的返回值
我们这套代码是前后端分离,且前后端都有进行校验。有些校验只有后端才能完成,
比如说:
手机号是否重复
验证码是否获取成功
是否登录成功
是否注册成功
参数是否合法
用户登录手机号和密码是否匹配等等。
这些校验之后,如果异常则会抛出对应的异常, Service层throw Exception会抛到调用服务的Controller层,Controller中throw Exception最终都会在BaseController中进行return给前端。
下面我就列一下后端所有带异常的方法的返回值,有助于逻辑的理解。
层 | 类名 | 方法名 | 返回正确信息 | 返回异常信息 |
---|---|---|---|---|
Controller层 | BaseController | handlerException | 无 | return CommonReturnType.create(responseData,“fail”); |
Controller层 | UserController | getUser | return CommonReturnType.create(userVO); | 无,但会throw new BusinessException(EmBusinessError.USER_NOT_EXIST);通过BaseController进行return |
Controller层 | UserController | getOtp | return CommonReturnType.create(otpCodeObj, “successGetOtpCode”); | 无,但会throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,“手机号已重复注册”);通过BaseController进行return |
Controller层 | UserController | register | return CommonReturnType.create(null); | 无,但会 throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, “短信验证码错误”);通过BaseController进行return |
Controller层 | UserController | login | return CommonReturnType.create(null); | 无,但会throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR);通过BaseController进行return |
Service层 | UserServiceImpl | register | 无 | throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR);通过BaseController进行return throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, result.getErrMsg()); 通过BaseController进行return throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,“手机号已重复注册”);通过BaseController进行return |
Service层 | UserServiceImpl | validateLogin | return userModel; | throw new BusinessException(EmBusinessError.USER_LOGIN_FAIL); 通过BaseController进行return throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR);通过BaseController进行return |