Java註解方式之防止重復請求
自定義註解方式防止前端同一時間多次重復提交
一、 前情提要
有這樣一個業務,上課的時候老師給表現好的學生送小花花,
每節課都能統計出某個學生收到的花的總數。
按照產品需求,前端點擊送花按鈕後30秒內是不能再次送花的(信任的基礎)
(上課老師送花行為都進行統計瞭,可見互聯網是多麼可怕)
二、技術設計
2.1 庫表設計
CREATE TABLE `t_student_flower` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主鍵(自增)', `classroom_id` bigint(20) NOT NULL COMMENT '每堂課的唯一標識', `student_id` bigint(20) NOT NULL COMMENT '學生唯一標識', `flower_num` bigint(20) NOT NULL DEFAULT '0' COMMENT '學生收到的花數量', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8
2.2 業務邏輯
業務邏輯很簡單,針對某一堂課的某一個學生,老師第一次送花就新增一條記錄,之後老師給這個學生送花就在原有的記錄基礎上增加送花數量即可。
如果前端能保證一堂課,一個學生,30秒內隻能送一次花,這樣設計能99.9999%的保證業務沒問題
2.3 代碼編寫
至於創建SpringBoot項目,連接Mybatis 準備在Mybatis篇章寫,這裡主要點不是這些。
重要是業務邏輯
pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.5.4</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.example</groupId> <artifactId>student_flower</artifactId> <version>0.0.1-SNAPSHOT</version> <name>student_flower</name> <description>Demo project for Spring Boot</description> <properties> <java.version>1.8</java.version> </properties> <dependencies> <!--web--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!--mybatis--> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>1.3.2</version> </dependency> <!--mysql驅動--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!--lombok 一款還不錯的副主編程工具--> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.16.18</version> <scope>provided</scope> </dependency> <!--測試使用--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
application.yml
server: # 服務端口配置 port: 8888 spring: # 數據源配置 datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/test?characterEncoding=utf-8&useSSL=false username: root password: 123456 mybatis: # mapper掃描路徑 mapper-locations: classpath:mapper/*.xml # 實體類別名映射包路徑 type-aliases-package: com.example.student_flower.entity configuration: # 開啟駝峰命名 map-underscore-to-camel-case: true
StudentFlowerController
package com.example.student_flower.controller; import com.example.student_flower.service.StudentFlowerService; import com.sun.istack.internal.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; /** * @author 發現更多精彩 關註公眾號:木子的晝夜編程 * 一個生活在互聯網底層,做著增刪改查的碼農,不諳世事的造作 * @create 2021-09-11 10:35 */ @RestController public class StudentFlowerController { @Autowired StudentFlowerService studentFlowerService; /** * * @param classroomId 教師ID * @param studentId 學生ID */ @GetMapping(value = "/test/sendflower/{classroomId}/{studentId}") public void sendFlower(@NotNull @PathVariable("classroomId") Long classroomId , @NotNull @PathVariable("studentId") Long studentId){ studentFlowerService.SendFlower(classroomId,studentId); } }
StudentFlowerService
package com.example.student_flower.service; import com.example.student_flower.dao.TStudentFlowerMapper; import com.example.student_flower.entity.TStudentFlower; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; /** * @author 發現更多精彩 關註公眾號:木子的晝夜編程 * 一個生活在互聯網底層,做著增刪改查的碼農,不諳世事的造作 * @create 2021-09-11 10:38 */ @Service public class StudentFlowerService { @Autowired TStudentFlowerMapper mapper; public void SendFlower(Long classroomId, Long studentId){ TStudentFlower tStudentFlower = mapper.selectByClassroomIdAndStudentId(classroomId, studentId); // 第一次送花 沒有記錄 新增 if (tStudentFlower == null) { TStudentFlower tsf = new TStudentFlower(); tsf.setClassroomId(classroomId); tsf.setStudentId(studentId); tsf.setFlowerNum(1); mapper.insert(tsf); } else { // 已經送過花瞭 原來數量上+1 tStudentFlower.setFlowerNum(tStudentFlower.getFlowerNum() + 1); mapper.update(tStudentFlower); } } }
TStudentFlowerMapper
package com.example.student_flower.dao; import com.example.student_flower.entity.TStudentFlower; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; /** * @author 發現更多精彩 關註公眾號:木子的晝夜編程 * 一個生活在互聯網底層,做著增刪改查的碼農,不諳世事的造作 * @create 2021-09-11 10:14 */ @Mapper public interface TStudentFlowerMapper { // 插入 void insert(TStudentFlower tStudentFlower); // 更新 void update(TStudentFlower tStudentFlower); // 查詢 TStudentFlower selectByClassroomIdAndStudentId( @Param("classroomId") Long classroomId, @Param("studentId") Long studentId); }
TStudentFlowerMapper.xml
<?xml version="1.0" encoding="UTF-8" ?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" > <mapper namespace="com.example.student_flower.dao.TStudentFlowerMapper"> <!--新增--> <insert id="insert" parameterType="TStudentFlower"> INSERT INTO t_student_flower (classroom_id,student_id,flower_num) VALUES (#{classroomId},#{studentId},#{flowerNum}) </insert> <!--更新--> <update id="update" parameterType="TStudentFlower"> UPDATE t_student_flower SET flower_num = #{flowerNum} WHERE id=#{id}; </update> <select id="selectByClassroomIdAndStudentId" resultType="TStudentFlower"> select * from t_student_flower where classroom_id = #{classroomId} and student_id = #{studentId} </select> </mapper>
2.4 測試
瀏覽器直接訪問:
http://127.0.0.1:8888/test/sendflower/1/1
就會給classroomId = 1 ,studentId = 1 的學生送一朵花
2.5 問題所在
一切看似沒有問題,因為請求頻率還沒有達到可以出錯的速度。
我們寫一個測試用瞭來模擬前端不可信任的時候(由於某種原因他們送花事件綁定瞭多次沒有解綁,也就是同一時間發送多次送花請求)
package com.example.student_flower; import com.example.student_flower.service.StudentFlowerService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.util.concurrent.TimeUnit; @SpringBootTest class StudentFlowerApplicationTests { @Autowired StudentFlowerService service; @Test void sendFlower() throws InterruptedException { final Long classroomId = 2L; final Long studengId = 102L; Thread thread1 = new Thread(() -> { service.SendFlower(classroomId, studengId); System.out.println("thread1執行完瞭"); }); Thread thread2 = new Thread(() -> { service.SendFlower(classroomId, studengId); System.out.println("thread2執行完瞭"); }); Thread thread3 = new Thread(() -> { service.SendFlower(classroomId, studengId); System.out.println("thread3執行完瞭"); }); thread1.start(); thread2.start(); thread3.start(); // 睡會兒 等三個線程跑完 很low? 做測試湊活用吧 Thread.sleep(TimeUnit.SECONDS.toMillis(20)); } }
執行完看一下數據庫結果:
這肯定是有問題的 多三條要出問題的,要扣錢績效的
三、解決方案
解決方案有很多,我今天介紹一種自定義註解的方式(其實就是用瞭分佈redis鎖)
方案看似很簡單:
自定義註解MyAnotation
package com.example.student_flower.common.anotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * @author 發現更多精彩 關註公眾號:木子的晝夜編程 分享一個生活在互聯網底層做著增刪改查的碼農的感悟與學習 * * 關於自定義註解 後邊有機會專門寫一寫 先會用 * @create 2021-09-11 15:26 */ @Target({ElementType.METHOD}) // 方法上使用的註解 @Retention(RetentionPolicy.RUNTIME) // 運行時通過反射訪問 public @interface MyAnotation { /** * 獲取鎖時默認等待多久 */ int waitTime() default 3; /** * 鎖過期時間 */ int expireTime() default 20; /** * 鎖key值 */ String redisKey() default ""; /** * 鎖key後拼接的動態參數的值 */ String[] params() default {}; }
自定義切面處理邏輯,進行放重復提交校驗MyAspect
package com.example.student_flower.common.aspect; import com.example.student_flower.common.anotation.MyAnotation; import com.example.student_flower.util.HttpContextUtils; import com.example.student_flower.util.SpelUtil; import io.micrometer.core.instrument.util.StringUtils; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.reflect.MethodSignature; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.servlet.http.HttpServletRequest; import java.lang.reflect.Method; import java.util.concurrent.TimeUnit; /** * @author 發現更多精彩 關註公眾號:木子的晝夜編程 * 一個生活在互聯網底層,做著增刪改查的碼農,不諳世事的造作 * * 關於spring面向切面的知識 等以後文章有機會我寫一寫(自己也不太熟 暫時會用) * * @create 2021-09-11 15:29 */ @Slf4j @Aspect @Component public class MyAspect { @Autowired RedissonClient redissonClient; // 這個是那些方法需要被切 -- 被標記註解MyAnotation的方法要被切 @Pointcut("@annotation(com.example.student_flower.common.anotation.MyAnotation)") public void whichMethodAspect() { } /** * 切面 執行業務邏輯 在實際業務方法執行前 後 都可以進行一些額外的操作 * 切面的好處就是對你不知不覺 */ @Around("whichMethodAspect()") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { // 1. 獲取註解 MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); MyAnotation myAnotation = method.getAnnotation(MyAnotation.class); // 2. 鎖等待時間 int waitTime = myAnotation.waitTime(); // 2. 鎖超時時間 怕萬一finally沒有被執行到的時候 多長時間自動釋放鎖(基本不會不執行finnaly 除非那個點機器down瞭) final int lockSeconds = myAnotation.expireTime(); // 3. 特殊業務自定義key String key = myAnotation.redisKey(); // 自定義redisKey是否使用參數 String[] params = myAnotation.params(); // 4.獲取HttpServletRequest HttpServletRequest request = HttpContextUtils.getRequest(); if (request == null) { throw new Exception("錯誤的請求 request為null"); } assert request != null; // 5. 組合redis鎖key // 5.1 如果沒有自定義 用默認的 url+token if (StringUtils.isBlank(key) && (params == null || params.length == 0)) { // 這裡怎麼獲取token 主要看自己項目用的什麼框架 token在哪個位置存儲著 String token = request.getHeader("Authorization"); String requestURI = request.getRequestURI(); key = requestURI+token; } else { // 5.2 自定義key key = SpelUtil.generateKeyBySpEL(key, params, joinPoint); } // 6. 獲取key // 獲取鎖 獲取不到最多等waitTime秒 lockSeconds秒後自動釋放鎖 // 每個項目組應該會有自己的redisUtil的封裝 我這裡就用最簡單的方式 // 怎麼使用鎖不是重點 重點是這個思想 RLock lock = redissonClient.getLock(key); log.info("tryLock key = {}", key); boolean b = lock.tryLock(waitTime, lockSeconds, TimeUnit.SECONDS); // 獲取鎖成功 if (b) { try { log.info("tryLock success, key = {}", key); // 7. 執行業務代碼 返回結果 return joinPoint.proceed(); } finally { lock.unlock(); } } else { // 獲取鎖失敗 log.info("tryLock fail, key = {}", key); throw new Exception("請求頻繁,請稍後重試"); } } }
Redisson配置RedissonConfig
package com.example.student_flower; import org.redisson.Redisson; import org.redisson.api.RedissonClient; import org.redisson.config.Config; import org.springframework.context.annotation.Bean; /** * @author 發現更多精彩 關註公眾號:木子的晝夜編程 * 一個生活在互聯網底層,做著增刪改查的碼農,不諳世事的造作 * @create 2021-09-11 16:31 */ public class RedissonConfig { // 這裡就簡單設置 真實項目中會做到配置文件或配置中心 @Bean public RedissonClient getRedisson() { Config config = new Config(); config.useSingleServer().setAddress("redis://127.0.0.1:6379"); return Redisson.create(config); } }
獲取request對象HttpContextUtils
package com.example.student_flower.util; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; /** * @author 發現更多精彩 關註公眾號:木子的晝夜編程 * 一個生活在互聯網底層,做著增刪改查的碼農,不諳世事的造作 * @create 2021-09-11 16:17 * * 獲取springboot環境中的request/response對象 */ public class HttpContextUtils { // 獲取request public static HttpServletRequest getRequest(){ ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = servletRequestAttributes.getRequest(); return request; } // 獲取response public static HttpServletResponse getResponse(){ ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletResponse response = servletRequestAttributes.getResponse(); return response; } }
El表達式解析 SpelUtil
package com.example.student_flower.util; import java.lang.reflect.Method; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.core.DefaultParameterNameDiscoverer; import org.springframework.expression.EvaluationContext; import org.springframework.expression.Expression; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.spel.support.StandardEvaluationContext; /** * @author 發現更多精彩 關註公眾號:木子的晝夜編程 * 一個生活在互聯網底層,做著增刪改查的碼農,不諳世事的造作 * @create 2021-09-11 15:35 */ /** * EL表達式解析 */ public class SpelUtil { /** * 用於SpEL表達式解析. */ private static SpelExpressionParser parser = new SpelExpressionParser(); /** * 用於獲取方法參數定義名字. */ private static DefaultParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer(); /** * 解析表達式 */ public static String generateKeyBySpEL(String key, String[] params, ProceedingJoinPoint joinPoint) { StringBuilder spELString = new StringBuilder(); if (params != null && params.length > 0) { spELString.append("'" + key + "'"); for (int i = 0; i < params.length; i++) { spELString.append("+#" + params[i]); } } else { return key; } // 通過joinPoint獲取被註解方法 MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); Method method = methodSignature.getMethod(); // 使用spring的DefaultParameterNameDiscoverer獲取方法形參名數組 String[] paramNames = nameDiscoverer.getParameterNames(method); // 解析過後的Spring表達式對象 Expression expression = parser.parseExpression(spELString.toString()); // spring的表達式上下文對象 EvaluationContext context = new StandardEvaluationContext(); // 通過joinPoint獲取被註解方法的形參 Object[] args = joinPoint.getArgs(); // 給上下文賦值 for (int i = 0; i < args.length; i++) { context.setVariable(paramNames[i], args[i]); } return expression.getValue(context).toString(); } }
controller使用註解:
package com.example.student_flower.controller; import com.example.student_flower.common.anotation.MyAnotation; import com.example.student_flower.service.StudentFlowerService; import com.sun.istack.internal.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; /** * @author 發現更多精彩 關註公眾號:木子的晝夜編程 * 一個生活在互聯網底層,做著增刪改查的碼農,不諳世事的造作 * @create 2021-09-11 10:35 */ @RestController public class StudentFlowerController { @Autowired StudentFlowerService studentFlowerService; /** * * @param classroomId 教師ID * @param studentId 學生ID */ @MyAnotation(redisKey = "/test/sendflower", params = {"classroomId", "studentId"}) @GetMapping(value = "/test/sendflower/{classroomId}/{studentId}") public void sendFlower(@NotNull @PathVariable("classroomId") Long classroomId , @NotNull @PathVariable("studentId") Long studentId){ studentFlowerService.SendFlower(classroomId,studentId); } }
測試類(這裡用瞭MockMvc直接測試controller)
package com.example.student_flower; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultHandlers; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import java.util.concurrent.TimeUnit; @SpringBootTest @AutoConfigureMockMvc class StudentFlowerTests { @Autowired protected MockMvc mockMvc; @Test void sendFlower() throws Exception { final Long classroomId = 7L; final Long studengId = 102L; Thread thread1 = new Thread(() -> { try { mockMvc.perform(MockMvcRequestBuilders .get("/test/sendflower/" + classroomId + "/" + studengId).accept(MediaType.APPLICATION_JSON)) .andExpect(MockMvcResultMatchers.status().isOk()) .andDo(MockMvcResultHandlers.print()) .andReturn(); } catch (Exception e) { e.printStackTrace(); } }); Thread thread2 = new Thread(() -> { try { mockMvc.perform(MockMvcRequestBuilders .get("/test/sendflower/" + classroomId + "/" + studengId).accept(MediaType.APPLICATION_JSON)) .andExpect(MockMvcResultMatchers.status().isOk()) .andDo(MockMvcResultHandlers.print()) .andReturn(); } catch (Exception e) { e.printStackTrace(); } }); Thread thread3 = new Thread(() -> { try { mockMvc.perform(MockMvcRequestBuilders .get("/test/sendflower/" + classroomId + "/" + studengId).accept(MediaType.APPLICATION_JSON)) .andExpect(MockMvcResultMatchers.status().isOk()) .andDo(MockMvcResultHandlers.print()) .andReturn(); } catch (Exception e) { e.printStackTrace(); } }); thread1.start(); thread2.start(); thread3.start(); // 睡會兒 等三個線程跑完 很low? 做測試湊活用吧 Thread.sleep(TimeUnit.SECONDS.toMillis(20)); } }
去掉controller註解測試 會插入多條,加上MyAnotation註解隻會生成一條
四 、嘮嘮
4.1 項目
主要用到瞭自定義註解、RedissonClient的redis鎖、AOP等知識
可能麼有寫過這種場景代碼的人會覺得比較亂:木有關系全部代碼已經提交到github上瞭,
地址:https://github.com/githubforliming/student_flower
4.2 redis服務
貼心的我把redis的windows免安裝包都放到項目裡瞭
test/java/soft 解壓 雙擊redis-server.exe 即可運行
默認沒密碼
4.3 其他問題
支持參數是對象的自定義key
@MyAnotation(redisKey = "/test/sendflower", params = {"p.id"}) @PostMapping(value = "/test/sendflower02") public void sendFlower(@RequestBody Person p){ // xxx }
還有問題? 那就關註公眾號:木子的晝夜編程 然後留言吧
到此這篇關於Java註解方式之防止重復請求的文章就介紹到這瞭,更多相關Java 註解方式內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- springBoot Junit測試用例出現@Autowired不生效的解決
- SpringBoot整合之SpringBoot整合MongoDB的詳細步驟
- Spring使用IOC與DI實現完全註解開發
- 詳解Spring中BeanUtils工具類的使用
- SpringBoot使用Aspect切面攔截打印請求參數的示例代碼