近况
最近一直在忙专业课,各种课程交作业,考试,隔了半个月没写博客了,最近在看网上找的项目,
感觉大佬写的代码好帅啊,好优雅,我主要学学后端这块,前端VUE不太会,看不懂,后端的话
还是学了一点点,包括跨域问题的解决,自定义注解的应用,Token的使用,自定义异常类,
后端统一数据Result工具类,另外再把一些工具类上传到github存起来,以后用的时候就方便了。
跨域的基本知识点
跨域的概念
跨域指的是浏览器不能执行其他网站的脚本。它是由浏览器的同源策略造成的,是浏览器施加的安全限制
举个例子:http://www.123.com/index.html调用http://www.123.com/server.php (非跨域,即不能访问对方资源) http://www.123.com:8080/index.html 调用 http://www.123.com:8081/server.php (端口不同:8080/8081,跨域,即访问不了对方的资源) 当请求协议,域名,端口号三者有任意一个不同时,则该请求就是跨域请求 同源策略,很简单就是默认当以上三者都相同时,才能获取到返回的数据,这是浏览器默认设置的策略 当发出跨域请求,如果没有进行相应的处理,则我们可以看到,后端能正常处理请求并返回数据,但前端浏览器 接收不了数据。
以上个人见解,感觉不准确,看下图关于跨域的概念
浏览器如何判断一个请求是不是跨域请求?
浏览器会根据同源策略来判断一个请求是不是跨域请求。- 非跨域请求,在请求头中会只包含请求的主机名。
- 跨域请求,在请求头中会既包含要请求的主机名还包括当前的源主机名,
如果这两者不一致,那就是跨域请求了。
- 非跨域请求,在请求头中会只包含请求的主机名。
浏览器对请求的分类
在HTTP1.1 协议中的,请求方法分为GET、POST、PUT、DELETE、HEAD、TRACE、OPTIONS、CONNECT
八种。浏览器根据这些请求方法和请求类型将请求划分为简单请求和非简单请求。简单请求:浏览器先发送(执行)请求然后再判断是否跨域。
请求方法为 GET、POST、HEAD,请求头header中无自定义的请求头信息,请求类型Content-Type
为 text/plain、multipart/form-data、application/x-www-form-urlencoded的请求都是简单请求。非简单请求:浏览器先发送预检命令(OPTIONS方法),检查通过后才发送真正的数据请求。
预检命令会发送自定义头为Access-Control-Request-Headers: content-type的请求到服务器,
根据响应头的中的 “Access-Control-Allow-Headers”: “Content-Type” 判断服务器是否允许跨域访问。
预检命令是可以缓存,服务器端设置 “Access-Control-Max-Age”: “3600”,这样后面发送同样的
跨域请求就不需要先发送预检命令了。请求方法为 PUT、DELETE 的 AJAX 请求、发送 JSON 格式的 AJAX 请求、带自定义头的 AJAX 请求
都是非简单请求。谈谈CORS,CORS是一个W3C标准,全称跨域资源共享,CORS允许浏览器向跨域服务器发出
XMLHttpRequest请求,以克服AJAX只能基于同源策略的使用限制,像以上请求分类就是基于CORS标准对CORS每个块详解 1)Access-Control-Allow-Origin 该字段是必须的。它的值要么是请求时Origin字段的值,要么是一个*,表示接受任意域名的请求。 2)Access-Control-Allow-Credentials 该字段可选。它的值是一个布尔值,表示是否允许发送Cookie。默认情况下,Cookie不包括在CORS 请求之中。设为true,即表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器。 这个值也只能设为true,如果服务器不要浏览器发送Cookie,删除该字段即可。 3)Access-Control-Allow-Headers 如果浏览器请求包括Access-Control-Request-Headers字段, 则Access-Control-Allow-Headers字段是必需的。它也是一个逗号分隔的字符串, 表明服务器支持的所有头信息字段,不限于浏览器在"预检"中请求的字段。一般用来指定浏览器可额外 发送的请求头。 4)Access-Control-Allow-Methods 它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。 注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次"预检"请求。 5)X-Powered-By 这个值的意义用于告知网站是用何种语言或框架编写的,可以不写。 6)Content-Type 避免返回的值是乱码
开发中后端解决跨域问题
如果只是某几个接口需要接受跨域请求则直接使用Spring的@CrossOrigin注解
如果前后端分离,大多数请求都是跨域请求,则可以自定义跨域配置类
或者重写WebMvcConfigurer的默认方法1,自定义配置跨域类 0)application.yaml配置文件 #绑定跨域信息 cors: origin: '*' credentials: true headers: '*' methods: '*' maxAge: 3600 path: '/**' 1)配置CorsProperties资源类 @Data @Configuration @ConfigurationProperties(prefix = "cors") public class CorsProperties { private List<String> origin; private boolean credentials; private List<String> headers; private List<String> methods; private Long maxAge; private String path; } 2)配置GlobalCorsConfig配置类 @Configuration public class CorsConfig { @Bean public CorsFilter corsFilter(CorsProperties corsProperties) { //1,添加CORS配置信息 CorsConfiguration corsConfiguration = new CorsConfiguration(); //2,允许的域 corsConfiguration.setAllowedOriginPatterns(corsProperties.getOrigin()); //2,是否允许前端发送携带cookie信息的跨域请求 corsConfiguration.setAllowCredentials(corsProperties.isCredentials()); /* 2,允许的头信息 CORS请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段, 如果想拿到其他字段就必须在Access-Control-Expose-Headers中指定 */ corsConfiguration.setAllowedHeaders(corsProperties.getHeaders()); //2,允许的请求方式 corsConfiguration.setAllowedMethods(corsProperties.getMethods()); //2,允许的时间 corsConfiguration.setMaxAge(corsProperties.getMaxAge()); //3,添加映射路径,拦截一切请求 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(corsProperties.getPath(), corsConfiguration); return new CorsFilter(source); } }
2,自定义类实现WebMvcConfigurer接口,重写方法 /* 设置跨域 addMapping,设置可以被跨域的路径 allowedOrigins,域名的白名单 allowedMethods,请求的方式,GET,POST,DELETE,PUT allowedHeaders,允许所有请求header访问,可以自定义设置任意请求头信息 maxAge,这个是给复杂请求预检用的,设置预检多久失效 */ @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**").allowedOrigins("*") .allowedMethods("*").allowedHeaders("*").maxAge(3600); }
自定义注解,异常类,Token的应用
这三部分我放在一块讲,因为项目上使用这三个技术去实现了一个功能
功能是:当我使用自定义注解标注在控制器方法上,这个注解的作用是检查用户有没有登录,拦截器拦截所有方法,检查请求方法上有没有我定义的注解,如果有,则说明执行控制器方法前,用户需要先进行登录,
所以对用户的Token进行检查,需要获取到请求中携带的Token,对Token进行验证,
可以想象,这个自定义注解标注在所有需要登录后才能执行的控制器方法上。
自定义异常类,TokenException
这个比较简单,就先讲了,这个类的作用是,如果用户请求的控制器方法上标注了需要验证登录的注解
而请求中没有携带Token,就表示用户没有登录,也就没有权利执行控制器方法,此时抛出Token异常,
或者经过检查,发现Token不合法,也抛出自定义的异常。/** * 自定义token异常类 */ public class TokenException extends RuntimeException { private Integer code; public TokenException(Integer code, String message) { super(message); this.code = code; } public Integer getCode() { return code; } public void setCode(Integer code) { this.code = code; } }
自定义注解,@LoginToken,@PassToken
这两个注解的作用是,当我在控制器方法上标注@LoginToken注解,则表示该控制器方法需要进行登录验证
当我在控制器方法上标注@PassToken注解,或者什么都没标注时,则表示该控制器方法不需要进行登录验证/* 判断用户是否登录的注解 */ @Target({ElementType.METHOD,ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface LoginToken { boolean required() default true; } /* 跳过验证的注解 */ @Target({ElementType.METHOD,ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface PassToken { boolean required() default true; }
自定义注解的使用肯定是要配合反射的,我们需要在拦截器中拦截所有请求,通过反射判断请求的控制器方法
上有没有@LoginToken或者@PassToken注解。拦截器的代码放在Token部分。Token的使用
token的意思是“令牌”,是服务端生成的一串字符串,作为客户端进行请求的一个标识。
当用户第一次登录后,服务器生成一个token并将此token返回给客户端,
以后客户端只需带上这个token前来请求数据即可,无需再次带上用户名和密码。简单token的组成;uid(用户唯一的身份标识)、time(当前时间的时间戳)、
sign(签名,token的前几位以哈希算法压缩成的一定长度的十六进制字符串。为防止token泄露)。客户端登陆传递信息给服务端,服务端收到后把用户信息加密(token)传给客户端,客户端将token存放于
localStroage等容器中。客户端每次访问都传递token,服务端解密token,就知道这个用户是谁了。
通过cpu加解密,服务端就不需要存储session占用存储空间,就很好的解决负载均衡多服务器的问题了。
这个方法叫做JWT(Json Web Token)更多关于Token细节,Token与session的区别等,请看大佬的文章,Token详解
下面看看Token在开发中的应用
导入JWT依赖
<!-- jwt token处理 --> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.4.0</version> </dependency>
Token工具类
public class TokenUtil { // 过期时间,单位毫秒,正常30分钟 private static final long EXPIRE_TIME = 1000 * 60 * 30; //密钥 public static String SECRET = "HH_CC_FFF"; public static String getAdminToken(User user) { // 生成过期时间 Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME); String token; // 将user id保存到token里面 token = JWT.create().withAudience(String.valueOf(user.getUserId())) .withExpiresAt(date) .withIssuedAt(new Date()) .sign(Algorithm.HMAC256(SECRET));// 以SECRET作为token的密钥; return token; } public static Integer getAdminUserId(String token) { int adminUserId; try { adminUserId =Integer.parseInt(JWT.decode(token).getAudience().get(0)); return adminUserId; } catch (JWTDecodeException j) { throw new TokenException(403, "token不合法"); } } }
LoginTokenInterception拦截器,在拦截器中,对请求的Token进行验证
/* 这个拦截器是拦截没有登录的用户,当用户每次请求时会带上token, 如果你没有这个token就说明你没登录,如果token格式不对也不能登录, 如果请求的控制器方法上标注了@PassToken则表明该请求无需验证登录,直接通过。 */ public class LoginTokenInterception implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { //从请求头中取出token String token = request.getHeader("Authorization"); //如果不是请求的控制器方法,则直接通过 if (!(handler instanceof HandlerMethod)){ return true; } //转换成handlerMethod,控制器对象,我们写的控制器类都继承handlerMethod //获取被请求的控制器方法 HandlerMethod handlerMethod = (HandlerMethod) handler; Method method = handlerMethod.getMethod(); //如果控制器方法上标注了@PassToken,则放行 if (method.isAnnotationPresent(PassToken.class)){ PassToken passToken = method.getAnnotation(PassToken.class); if (passToken.required()){ return true; } } //检查有没有标注@LoginToken注解,有则进行验证 if (method.isAnnotationPresent(LoginToken.class)){ LoginToken loginToken = method.getAnnotation(LoginToken.class); if (loginToken.required()){ if (token==null){ throw new TokenException(403,"用户不存在"); } //验证token JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(TokenUtil.SECRET)).build(); try { jwtVerifier.verify(token); } catch (JWTVerificationException e) { throw new TokenException(403,"token不合法"); } } } return true; } }
总结
再次总结一下整个流程,因为浏览器的同源策略所产生的跨域问题,我配置了CorsConfig,CorsProperties
或者也可以重写WebMvcConfigurer接口的addCorsMappings方法,使得前后端能够跨域访问,
当用户第一次登陆的时候,后端生成一个Token,该Token存放着用户的Id值,并将该Token返回给客户端,
当用户访问其他需要登录后才能操作的请求时(这些控制器方法都会被标注我们的自定义注解,LoginToken)
拦截器拦截到所有请求,如果发现被请求的控制器方法标注了LoginToken注解,则进行Token验证登录,
如果发现请求中没有携带Token,或者Token不合法,则表明用户没有权利执行该请求,则抛出我们自定义的
异常,TokenException,这登录功能就把异常,注解,拦截器,Token验证,跨域问题全都用上了。
统一数据格式Result
前后端交互的时候,一般都会设计一个统一的返回数据格式,统一的返回数据格式有很多种实现,
我看网上写的都很不一样,有用到状态码枚举类,有没有使用枚举类的,有的枚举类只有状态码
有的枚举类中将状态码和消息都设置在一起,下面我就记录一下,这个项目中的统一返回数据体设计。
Result类
@Data @AllArgsConstructor @NoArgsConstructor public class Result implements Serializable { private static final long serialVersionUID = 1L; // 响应业务状态 /* * 200 成功 * 201 错误 * 400 参数错误 */ private Integer status; // 响应消息 private String msg; // 响应中的数据 private Object data; public Result(Integer status, String msg) { this.status = status; this.msg = msg; } }
ResultGenerator
/** * 响应结果生成工具 */ public class ResultGenerator { private static final String DEFAULT_SUCCESS_MESSAGE = "OK"; private static final String DEFAULT_FAIL_MESSAGE = "FAIL"; private static final int RESULT_CODE_SUCCESS = 200; private static final int RESULT_CODE_SERVER_ERROR = 201; //成功,携带默认消息,不带数据 public static Result genSuccessResult() { Result result = new Result(); result.setMsg(DEFAULT_SUCCESS_MESSAGE); result.setStatus(RESULT_CODE_SUCCESS); return result; } //成功,携带自定义消息,不带数据 public static Result genSuccessResult(String message) { Result result = new Result(); result.setMsg(message); result.setStatus(RESULT_CODE_SUCCESS); return result; } //成功,携带默认消息,带数据 public static Result genSuccessResult(Object data) { Result result = new Result(); result.setData(data); result.setMsg(DEFAULT_SUCCESS_MESSAGE); result.setStatus(RESULT_CODE_SUCCESS); return result; } //成功,携带自定义消息和数据 public static Result genSuccessResult(String message,Object data) { Result result = new Result(); result.setData(data); result.setMsg(message); result.setStatus(RESULT_CODE_SUCCESS); return result; } //请求失败,携带自定义消息 public static Result genFailResult(String message) { Result result = new Result(); result.setMsg(message); result.setStatus(RESULT_CODE_SERVER_ERROR); return result; } //请求错误,携带状态码及消息 public static Result genErrorResult(int code, String message) { Result result = new Result(); result.setMsg(message); result.setStatus(code); return result; } }
实际使用
//getUserInfo,获取信息 @GetMapping("/getUserInfo") public Object getUserInfo(HttpServletRequest request) { User user = userService.getById(TokenUtil.getAdminUserId(request.getHeader("Authorization"))); if (user == null) { return ResultGenerator.genFailResult("账号不存在,请检查账号是否正确或联系管理员"); } else { HashMap<String, Object> obj = new HashMap<>(); obj.put("userInfo", user); obj.put("token", TokenUtil.getAdminToken(user)); return ResultGenerator.genSuccessResult(obj); } }
这个项目的统一返回数据格式并不太好,我看其他博客使用注解方式,非常优雅
有关于更多统一返回体的设计,请看大佬博客,统一格式返回设计
一些小工具类
遇到过很多不错的工具类,记录一下,以后肯定会有用得到的地方。