前言
学了springboot一直没做过什么demo,正好看到了vue-springboot前后端分离的博客系统。就跟着学习了一遍
参考博客
文档:https://juejin.im/post/6844903823966732302#heading-7
配套视频:https://www.bilibili.com/video/BV1PQ4y1P7hZ
代码仓库:https://github.com/MarkerHub/vueblog
需要注意的是视频和文档有一点不一样,文档更全一点,比如JwtFilter那里视频中就没有关闭shior自带的Seesion,我也不知道他怎么就测试成功了。
数据准备
开发脚手架嘛,数据很简单,就两个表,一个m_blog,一个m_user.用文档中带的建表语句就可以了。mysql 5.6之前的版本datetime不支持默认时间,需要注意
包结构总览
集成Mybatis-plus
这也是我第一次使用Mybatis-plus,之前稍微学过一点Mybatis,不过Mybatis-plus声称是无侵入的附加框架,使用下来感受确实很好。简化了很多工作,不得不感叹在这些自动化框架的加持下做的确实更少了,但也更简单和单一了,更像无脑打工人了哈哈。
首先导入相关的依赖:
<!--mp-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<!--mp代码生成器-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.1.2</version>
</dependency>
第二个是用来自动生成代码时的代码模板,还有其他的模板,没有详细了解过,后面有空了解了再不,不是很重要,这里就采用了和原文档一致的模板。
然后还需要再配置文件中开启mybatis的包扫描
mybatis-plus:
mapper-locations: classpath*:/mapper/**Mapper.xml
自动生成代码
首先需要一个CodeGenerator的自动生成代码类,再修改里面的一些配置即可
// 演示例子,执行 main 方法控制台输入模块表名回车自动生成对应项目目录中
public class CodeGenerator {
/**
* <p>
* 读取控制台内容
* </p>
*/
public static String scanner(String tip) {
Scanner scanner = new Scanner(System.in);
StringBuilder help = new StringBuilder();
help.append("请输入" + tip + ":");
System.out.println(help.toString());
if (scanner.hasNext()) {
String ipt = scanner.next();
if (StringUtils.isNotEmpty(ipt)) {
return ipt;
}
}
throw new MybatisPlusException("请输入正确的" + tip + "!");
}
public static void main(String[] args) {
// 代码生成器
AutoGenerator mpg = new AutoGenerator();
// 全局配置
GlobalConfig gc = new GlobalConfig();
String projectPath = System.getProperty("user.dir");
gc.setOutputDir(projectPath + "/src/main/java");
// gc.setOutputDir("D:\\test");
gc.setAuthor("");
gc.setOpen(false);
// gc.setSwagger2(true); 实体属性 Swagger2 注解
gc.setServiceName("%sService");
mpg.setGlobalConfig(gc);
// 数据源配置
DataSourceConfig dsc = new DataSourceConfig();
dsc.setUrl("jdbc:mysql://localhost:3306/vueblog?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=UTC");
// dsc.setSchemaName("public");
dsc.setDriverName("com.mysql.jdbc.Driver");
dsc.setUsername("root");
dsc.setPassword("123456");
mpg.setDataSource(dsc);
// 包配置
PackageConfig pc = new PackageConfig();
pc.setModuleName(null);
pc.setParent("com.yunxiao");
mpg.setPackageInfo(pc);
// 自定义配置
InjectionConfig cfg = new InjectionConfig() {
@Override
public void initMap() {
// to do nothing
}
};
// 如果模板引擎是 freemarker
String templatePath = "/templates/mapper.xml.ftl";
// 如果模板引擎是 velocity
// String templatePath = "/templates/mapper.xml.vm";
// 自定义输出配置
List<FileOutConfig> focList = new ArrayList<>();
// 自定义配置会被优先输出
focList.add(new FileOutConfig(templatePath) {
@Override
public String outputFile(TableInfo tableInfo) {
// 自定义输出文件名 , 如果你 Entity 设置了前后缀、此处注意 xml 的名称会跟着发生变化!!
return projectPath + "/src/main/resources/mapper/"
+ "/" + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML;
}
});
cfg.setFileOutConfigList(focList);
mpg.setCfg(cfg);
// 配置模板
TemplateConfig templateConfig = new TemplateConfig();
templateConfig.setXml(null);
mpg.setTemplate(templateConfig);
// 策略配置
StrategyConfig strategy = new StrategyConfig();
strategy.setNaming(NamingStrategy.underline_to_camel);
strategy.setColumnNaming(NamingStrategy.underline_to_camel);
strategy.setEntityLombokModel(true);
strategy.setRestControllerStyle(true);
strategy.setInclude(scanner("表名,多个英文逗号分割").split(","));
strategy.setControllerMappingHyphenStyle(true);
strategy.setTablePrefix("m_");
mpg.setStrategy(strategy);
mpg.setTemplateEngine(new FreemarkerTemplateEngine());
mpg.execute();
}
}
主要需要配置的就是数据源那一部分,还有包配置修改成和自己的包结构一样即可。
执行这个main方法,输入需要自动生成的表名,就会按照模板自动生成实体类,mapper,service等类。
统一结果封装
由于是前后端分离的项目,所以需要对调用接口的结果进行一个统一封装,便于前端根据返回的结果进行相应的处理。
新建common.lang.Result这个类
@Data
public class Result {
private String code;//200 正常 400 异常
private String msg;
private Object data;
public static Result succ(String code,String msg, Object data){
Result result = new Result();
result.setCode(code);
result.setMsg(msg);
result.setData(data);
return result;
}
public static Result succ(Object data){
Result result = new Result();
result.setCode("200");
result.setMsg("操作成功");
result.setData(data);
return result;
}
public static Result fail(String code,String msg, Object data){
Result result = new Result();
result.setCode(code);
result.setMsg(msg);
result.setData(data);
return result;
}
public static Result fail(String msg, Object data){
Result result = new Result();
result.setCode("400");
result.setMsg(msg);
result.setData(data);
return result;
}
public static Result fail(String msg){
Result result = new Result();
result.setCode("400");
result.setMsg(msg);
result.setData(null);
return result;
}
}
就三个类成员,code表示需要的返回的状态码,msg表示返回信息,Data表示返回数据。这里定义200正常,400异常。然后根据需要定义静态方法succ和fail分别表示成功的结果封装和失败的结果封装,可以组合着适当重载。比如参数只要msg,只要Data等...
集成Shior安全框架
这应该是这个脚手架中代码最多的部分了,也是对我来说最难的一部分,毕竟第一次接触,我尽量梳理清楚。
shiro相关的包
<!--shiro redis hutool jwt工具类-->
<dependency>
<groupId>org.crazycake</groupId>
<artifactId>shiro-redis-spring-boot-starter</artifactId>
<version>3.2.1</version>
</dependency>
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-web-starter</artifactId>
<version>1.4.0-RC2</version>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.4.3</version>
</dependency>
<!-- jwt -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
jwt校验流程图分析
核心就是围绕jwt来做权限验证
JwtUtils工具类
这个工具类主要用来生成jwt,验证jwt是否过期,返回jwt的信息。
/**
* jwt工具类
*/
@Slf4j
@Data
@Component
@ConfigurationProperties(prefix = "yunxiao.jwt")
public class JwtUtils {
private String secret;
private long expire;
private String header;
/**
* 生成jwt token
*/
public String generateToken(long userId) {
Date nowDate = new Date();
//过期时间
Date expireDate = new Date(nowDate.getTime() + expire * 1000);
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setSubject(userId+"")
.setIssuedAt(nowDate)
.setExpiration(expireDate)
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
public Claims getClaimByToken(String token) {
try {
return Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
}catch (Exception e){
log.debug("validate is token error ", e);
return null;
}
}
/**
* token是否过期
* @return true:过期
*/
public boolean isTokenExpired(Date expiration) {
return expiration.before(new Date());
}
}
需要添加配置文件,用来设置Jwt过期时间,secrt密钥信息,同时需要开启redis。
shiro-redis:
enabled: true
redis-manager:
host: 127.0.0.1:6379
yunxiao:
jwt:
# 加密秘钥
secret: f4e2e52034348f86b67cde581c0f9eb5
# token有效时长,7天,单位秒
expire: 604800
header: token
AccountRealm自定义Realm
主要就是doGetAuthenticationInfo登录认证这个方法,可以看到我们通过jwt获取到用户信息,判断用户的状态,最后异常就抛出对应的异常信息,否者封装成SimpleAuthenticationInfo返回给shiro
@Component
public class AccountRealm extends AuthorizingRealm {
@Autowired
JwtUtils jwtUtils;
@Autowired
UserService userService;
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
return null;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
JwtToken jwtToken = (JwtToken)authenticationToken;
String userID = jwtUtils.getClaimByToken((String) jwtToken.getPrincipal()).getSubject();
User user = userService.getById(Long.valueOf(userID));
if(user == null){
System.out.println("用户不存在");
}
if(user.getStatus()==-1){
System.out.println("账户被锁定");
}
AccountProfile profile = new AccountProfile();
BeanUtil.copyProperties(user,profile);
return new SimpleAuthenticationInfo(profile,jwtToken.getCredentials(),getName());
}
}
AccountProfile
上面的AccountRealm中用到了这个类,这个类主要是用来封装返回信息的一个类,需要序列化,可以根据需要增删字段
@Data
public class AccountProfile implements Serializable {
private Long id;
private String username;
private String avatar;
}
JwtToken
这个类用来把jwt封装成token。
public class JwtToken implements AuthenticationToken {
String token;
public JwtToken(String jwt){
this.token = jwt;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
JwtFilter过滤器
这是这个项目中的shior重点
我们需要重写几个方法:
1.createToken:实现登录,我们需要从请求头获得我们自定义支持的JwtToken
2.onAccessDenied:拦截校验,当头部没有Authorization时候,我们直接通过,不需要自动登录;当带有的时候,首先我们校验jwt的有效性,没问题我们就直接执行executeLogin方法实现自动登录
3.onLoginFailure:登录异常时候进入的方法,我们直接把异常信息封装然后抛出
4.preHandle:拦截器的前置拦截,因为我们是前后端分离项目,项目中除了需要跨域全局配置之外,我们再拦截器中也需要提供跨域支持。这样,拦截器才不会在进入Controller之前就被限制了。
@Component
public class JwtFilter extends BasicHttpAuthenticationFilter {
@Autowired
JwtUtils jwtUtils;
@Override
protected AuthenticationToken createToken(ServletRequest servletRequest, ServletResponse servletResponse) {
HttpServletRequest request = (HttpServletRequest) servletRequest;
String jwt = request.getHeader("Authorization");
if(StringUtils.isEmpty(jwt)){
return null;
}
return new JwtToken(jwt);
}
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
String jwt = request.getHeader("Authorization");
if(StringUtils.isEmpty(jwt)){
return true;
}else{
Claims claim = jwtUtils.getClaimByToken(jwt);
if(claim == null || jwtUtils.isTokenExpired(claim.getExpiration())){
throw new ExpiredCredentialsException("token失效,请重新登陆");
}
return executeLogin(servletRequest,servletResponse);
}
}
@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
HttpServletResponse httpResponse = (HttpServletResponse) response;
try {
//处理登录失败的异常
Throwable throwable = e.getCause() == null ? e : e.getCause();
Result r = Result.fail(throwable.getMessage());
String json = JSONUtil.toJsonStr(r);
httpResponse.getWriter().print(json);
} catch (IOException e1) {
}
return false;
}
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = WebUtils.toHttp(request);
HttpServletResponse httpServletResponse = WebUtils.toHttp(response);
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个OPTIONS请求,这里我们给OPTIONS请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(org.springframework.http.HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
}
shiro总结
通过以上几个类就通过jwt来实现了登陆权限控制,这里还没有实现角色控制,后续学习了shiro后再补。
全局异常处理
我们在前面的代码中抛出了很多异常,我们用一个全局异常来统一处理。新建excption.GlobaExceptionHandler
@Slf4j
@RestControllerAdvice
public class GlobaExceptionHandler {
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(ShiroException.class)
public Result handle401(ShiroException e){
return Result.fail("401",e.getMessage(),null);
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result handler(MethodArgumentNotValidException e){
log.error("实体校验异常------------->",e);
BindingResult bindingResult = e.getBindingResult();
ObjectError objectError = bindingResult.getAllErrors().stream().findFirst().get();
return Result.fail(objectError.getDefaultMessage());
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public Result handler(IllegalArgumentException e){
log.error("断言异常------------->",e);
return Result.fail(e.getMessage());
}
@ExceptionHandler(value = RuntimeException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result handler(RuntimeException e){
log.error("运行时异常------------->",e);
return Result.fail(e.getMessage());
}
}
@ExceptionHandler这个注解用于捕获异常,@ResponseStatus用户返回错误状态码
后续如果有其它异常抛出,直接在这个全局异常类捕获处理,非常的鲁棒。
后台接口开发
AccountController
@RestController
public class AccountController {
@Autowired
JwtUtils jwtUtils;
@Autowired
UserService userService;
@CrossOrigin
@PostMapping("/login")
public Result login(@Validated @RequestBody LoginDto loginDto, HttpServletResponse response){
User user = userService.getOne(new QueryWrapper<User>().eq("username",loginDto.getUsername()));
Assert.notNull(user,"用户名不存在");
if(!user.getPassword().equals(SecureUtil.md5(loginDto.getPassword()))){
return Result.fail("密码错误!");
}
String jwt = jwtUtils.generateToken(user.getId());
response.setHeader("Authorization",jwt);
response.setHeader("Access-Control-Expose-Header","Authorization");
return Result.succ(MapUtil.builder()
.put("id",user.getId())
.put("userName",user.getUsername())
.put("avatar",user.getAvatar())
.put("email",user.getEmail())
.map()
);
}
@PostMapping("/logout")
@RequiresAuthentication
public Result logout(){
// System.out.println("test:"+SecurityUtils.getSubject().isAuthenticated());
SecurityUtils.getSubject().logout();
return Result.succ(null);
}
}
BlogController
@RestController
public class BlogController {
@Autowired
BlogService blogService;
@GetMapping("/blogs")
public Result blogs(Integer currentPage) {
if(currentPage == null || currentPage<1) currentPage=1;
Page page = new Page(currentPage, 5);
IPage pageData = blogService.page(page, new QueryWrapper<Blog>().orderByDesc("created"));
return Result.succ(pageData);
}
@GetMapping("/blog/{id}")
public Result blog(@PathVariable(name = "id") Long id) {
Blog blog = blogService.getById(id);
Assert.notNull(blog,"该博客已删除");
return Result.succ(blog);
}
@RequiresAuthentication
@PostMapping("/blog/edit")
public Result edit(@Validated @RequestBody Blog blog){
System.out.println(blog.toString());
Blog temp = null;
if(blog.getId()!=null){
temp = blogService.getById(blog.getId());
Assert.isTrue(temp.getUserId().equals(ShiroUtil.getProfile().getId()) ,"没有权限编辑");
}else {
temp = new Blog();
temp.setUserId(ShiroUtil.getProfile().getId());
temp.setCreated(LocalDateTime.now());
temp.setStatus(0);
}
BeanUtil.copyProperties(blog,temp,"userId","created","status");
blogService.saveOrUpdate(temp);
return Result.succ("200","编辑操作成功",null);
}
@PostMapping("blog/delete/{id}")
public Result delete(@PathVariable(value = "id") Long id){
if (blogService.removeById(id)){
return Result.succ("200","删除文章成功",null);
}else {
return Result.fail("400","文章不存在",null);
}
}
}
至此,登陆和博客增删查改的接口开发完成,用postman测试结果正常
测试需要权限的接口需要再headers中添加上jwt(通过登陆获取)
问题汇总
1. datetime格式的数据无法映射
参考官网mp版本从3.1.0及以下版本升级到高版本,JDK8日期新类型LocalDateTime等无法映射(报错)。
由于我是mysql5所以最多JDBC也就升级到5.9,然后问题就解决了。
2. jwtFilter不起作用
测试接口的发现jwtFilter没起作用,本来该自动拿到jwt执行自动登陆的动作,但是却没反应,导致访问不了需要权限的接口如编辑和删除。后来发现时JwtConfig这个配合文件中没有关闭shior自带的Seesion。
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
securityManager.setSubjectDAO(subjectDAO);
return securityManager;
加入这段代码后正常,这里要卡了好久,主要是还没有系统的学习过shiro这个框架就学习这个脚手架了,这个经验要引以为戒。
2. maven导入不了相关jar包
这个是经常遇到的问题,跟maven版本,网络条件,jar包版本很多都有关系。需要注意的是不能直接把jar包下载下来在idea中导入,是没用的,因为这个项目的jar包都被maven托管了。手动导入的流程应该是
mvn install:install-file -Dfile=jar包的路径 -DgroupId=gruopId中的内容 -DartifactId=actifactId的内容 -Dversion=version的内容 -Dpackaging=jar