關於easyopen,請前往:碼雲瞭解。git
在接口開發過程當中,表單重複提交的狀況會常常出現。好比作手機app開發,app端可能會連續觸發兩次請求,若是服務端不作處理,可能會有2次重複操做。redis
解決的方法也有多種:算法
第一種是使用token來解決,具體思路是:當用戶訪問視圖時,由服務端生成一個Token放入session中,同時這個token跟隨返回到視圖頁面,用js接收或者 hidden 放入要提交的表單中,當提交表單的時候 比較兩個Token的值是否一致,再進行數據操做,而且再次改變Token中的值,當表單再次提交時 token中的值不一致,則不會執行相應方法了。spring
第二種能夠用鎖來處理,當用戶請求進來後,對這個用戶進行加鎖處理,而後處理業務邏輯,只要業務邏輯沒有處理完畢,該用戶的其它線程請求進來始終被拒絕。json
本文使用easyopen攔截器來實現第二種方式。api
easyopen的攔截器使用方式同springmvc攔截器,其完整接口定義以下:數組
/** * 攔截器,原理同springmvc攔截器 * @author tanghc * */ public interface ApiInterceptor { /** * 預處理回調方法,在方法調用前執行 * @param request * @param response * @param serviceObj service類 * @param argu 方法參數 * @return * @throws Exception */ boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object serviceObj, Object argu) throws Exception; /** * 接口方法執行完後調用此方法。 * @param request * @param response * @param serviceObj service類 * @param argu 參數 * @param result 方法返回結果 * @throws Exception */ void postHandle(HttpServletRequest request, HttpServletResponse response, Object serviceObj, Object argu, Object result) throws Exception; /** * 結果包裝完成後執行 * @param request * @param response * @param serviceObj service類 * @param argu 參數 * @param result 最終結果,被包裝過 * @param e * @throws Exception */ void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object serviceObj, Object argu, Object result, Exception e) throws Exception; /** * 匹配攔截器 * @param apiMeta 接口信息 * @return */ boolean match(ApiMeta apiMeta); }
本次將要實現的需求以下:session
preHandle
方法中申請鎖afterCompletion
方法中釋放鎖首先給出redis申明鎖/釋放鎖工具類:mvc
/** <pre> redis分佈式鎖 https://wudashan.cn/2017/10/23/Redis-Distributed-Lock-Implement/ </pre> * @author tanghc * */ public class RedisTool { private static final String LOCK_SUCCESS = "OK"; private static final String SET_IF_NOT_EXIST = "NX"; private static final String SET_WITH_EXPIRE_TIME = "PX"; private static final Long RELEASE_SUCCESS = 1L; /** * 嘗試獲取分佈式鎖 * @param jedis Redis客戶端 * @param lockKey 鎖 * @param requestId 請求標識 * @param expireTimeMilliseconds 超期時間,多少毫秒後這把鎖自動釋放 * @return 是否獲取成功 */ public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTimeMilliseconds ) { String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTimeMilliseconds); if (LOCK_SUCCESS.equals(result)) { return true; } return false; } /** * 釋放分佈式鎖 * @param jedis Redis客戶端 * @param lockKey 鎖 * @param requestId 請求標識 * @return 是否釋放成功 */ public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId)); if (RELEASE_SUCCESS.equals(result)) { return true; } return false; } }
工具類的實現原理參考:Redis分佈式鎖的正確實現方式(Java版)app
而後須要一個管理Jedis對象的工具類
@Component public class JedisConfig { @Value("${spring.redis.database}") private String database; @Value("${spring.redis.host}") private String host; @Value("${spring.redis.password}") private String password; @Value("${spring.redis.port}") private String port; @Value("${spring.redis.timeout}") private String timeout; @Value("${spring.redis.pool.max-idle}") private String maxIdle; @Value("${spring.redis.pool.min-idle}") private String minIdle; @Value("${spring.redis.pool.max-active}") private String maxActive; @Value("${spring.redis.pool.max-wait}") private String maxWait; @Bean public JedisPool jedisPool() { JedisPoolConfig jedisPoolConfig = new JedisPoolConfig(); jedisPoolConfig.setMaxIdle(NumberUtils.toInt(maxIdle,JedisPoolConfig.DEFAULT_MAX_IDLE)); jedisPoolConfig.setMinIdle(NumberUtils.toInt(minIdle,JedisPoolConfig.DEFAULT_MIN_IDLE)); jedisPoolConfig.setMaxTotal(NumberUtils.toInt(maxActive,JedisPoolConfig.DEFAULT_MAX_TOTAL)); jedisPoolConfig.setMaxWaitMillis(NumberUtils.toLong(maxWait, JedisPoolConfig.DEFAULT_MAX_WAIT_MILLIS)); jedisPoolConfig.setTestOnBorrow(true); jedisPoolConfig.setTestOnReturn(true); return new JedisPool(jedisPoolConfig, host, NumberUtils.toInt(port, 6379), NumberUtils.toInt(timeout, 3000), password, NumberUtils.toInt(database, 0)); } }
這裏使用spring依賴注入一個JedisPool對象。
最後是編寫攔截器,首先攔截器的僞代碼以下:
/** <pre> 業務處理鎖(防暴擊): 同一我的同一時間只能處理一個業務。 </pre> * @author tanghc * */ public class LockInterceptor extends ApiInterceptorAdapter { private Logger logger = LoggerFactory.getLogger(getClass()); // 攔截接口名當中有這些關鍵字的 private static List<String> uriKeyList = Arrays.asList("order.cancel", "order.create"); private int lockExpireMilliseconds = 3000; // 鎖過時時間,3秒 private JedisPool jedisPool; public LockInterceptor() { // 從spring容器中獲取對象 jedisPool = SpringContextUtils.getBean(JedisPool.class); } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object serviceObj, Object argu) throws Exception { Jedis jedis = jedisPool.getResource(); try { boolean hasLock = 申請redis鎖 if(!hasLock) { // 若是沒有獲得鎖,說明重複提交 response返回錯誤信息 return false; } }finally { jedis.close(); // 最後別忘了關閉鎖 } return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object serviceObj, Object argu, Object result, Exception e) throws Exception { // 釋放鎖 Jedis jedis = jedisPool.getResource(); try { RedisTool.releaseDistributedLock(jedis, lockKey, requestId); } finally { jedis.close(); } } @Override public boolean match(ApiMeta apiMeta) { String name = apiMeta.getName(); return uriKeyList.contains(name); // 匹配接口,匹配到才執行該攔截器 } }
理解了僞代碼邏輯後,再來看下完整代碼
/** <pre> 業務處理鎖(防暴擊): 同一我的同一時間只能處理一個業務。 </pre> * @author tanghc * */ public class LockInterceptor extends ApiInterceptorAdapter { private Logger logger = LoggerFactory.getLogger(getClass()); // 攔截接口名當中有這些關鍵字的 private static List<String> uriKeyList = Arrays.asList("order.cancel", "order.create"); private int lockExpireMilliseconds = 3000; // 鎖過時時間,3秒 private JedisPool jedisPool; public LockInterceptor() { jedisPool = SpringContextUtils.getBean(JedisPool.class); } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object serviceObj, Object argu) throws Exception { LoginUser loginUser = ApiUtil.getCurrentUser(); // 獲取當前登陸用戶 String lockKey = this.getLockKey(loginUser); String requestId = this.getRequestId(loginUser); Jedis jedis = jedisPool.getResource(); try { boolean hasLock = RedisTool.tryGetDistributedLock(jedis, lockKey, requestId , lockExpireMilliseconds); // 若是沒有取到鎖,認爲是暴擊,直接返回 if(!hasLock) { logger.warn("用戶({},{})訪問{}產生暴擊!",loginUser.getId(),loginUser.getPhone(),ApiContext.getApiParam().fatchNameVersion()); ApiResult result = new ApiResult(); result.setCode(-102); result.setMsg("您已提交,請耐心等待哦"); ResponseUtil.renderJson(response, JSON.toJSONString(result)); return false; } }finally { jedis.close(); } return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object serviceObj, Object argu, Object result, Exception e) throws Exception { LoginUser loginUser = ApiUtil.getCurrentUser(); String lockKey = this.getLockKey(loginUser); String requestId = this.getRequestId(loginUser); Jedis jedis = jedisPool.getResource(); try { RedisTool.releaseDistributedLock(jedis, lockKey, requestId); } finally { jedis.close(); } } private String getLockKey(LoginUser loginUser) { return "api_lock_key:" + String.valueOf(loginUser.getId()); } private String getRequestId(LoginUser loginUser) { return "api_lock_request_id_" + loginUser.getId(); } @Override public boolean match(ApiMeta apiMeta) { String name = apiMeta.getName(); return uriKeyList.contains(name); } }
最後給出單元測試代碼
public class ApiTest extends TestCase { String url = "http://localhost:8080/api"; String appId = "test"; String secret = "123456"; String token = "0094FC708C34490F949A9FAB90453195"; /** * 暴擊測試,10條線程同時請求 * @throws InterruptedException */ @Test public void testLock() throws InterruptedException { int threadsCount = 10; final CountDownLatch countDownLatch = new CountDownLatch(1); final CountDownLatch count = new CountDownLatch(threadsCount); AtomicInteger successCount = new AtomicInteger(); for (int i = 0; i < threadsCount; i++) { new Thread(new Runnable() { @Override public void run() { try { countDownLatch.await(); // 等在這裏,執行countDownLatch.countDown();集體觸發 // 請求接口 Map<String, Object> busiParam = new HashMap<>(); String resp = doPost("order.create", busiParam); if("0".equals(JSON.parseObject(resp).getString("code"))) { successCount.incrementAndGet(); } System.out.println(resp); } catch (Exception e) { e.printStackTrace(); }finally { count.countDown(); } } }).start(); } countDownLatch.countDown(); count.await(); System.out.println("成功條數:" + successCount.get()); } private String doPost(String name, Map<String, Object> busiParam) throws IOException { Map<String, String> param = new HashMap<String, String>(); String json = JSON.toJSONString(busiParam); param.put(ParamNames.API_NAME, name); param.put(ParamNames.APP_KEY_NAME, appId); param.put(ParamNames.DATA_NAME, URLEncoder.encode(json, "UTF-8")); param.put(ParamNames.TIMESTAMP_NAME, getTime()); param.put(ParamNames.VERSION_NAME, ""); param.put(ParamNames.FORMAT_NAME, "json"); param.put(ParamNames.ACCESS_TOKEN_NAME, token); String sign = buildSign(param, secret); param.put(ParamNames.SIGN_NAME, sign); System.out.println("請求內容:" + JSON.toJSONString(param)); String resp = HttpUtil.post(url, param); return resp; } /** * 構建簽名 * * @param paramsMap * 參數 * @param secret * 密鑰 * @return * @throws IOException */ public static String buildSign(Map<String, ?> paramsMap, String secret) throws IOException { Set<String> keySet = paramsMap.keySet(); List<String> paramNames = new ArrayList<String>(keySet); Collections.sort(paramNames); StringBuilder paramNameValue = new StringBuilder(); for (String paramName : paramNames) { paramNameValue.append(paramName).append(paramsMap.get(paramName)); } String source = secret + paramNameValue.toString() + secret; return md5(source); } /** * 生成md5,所有大寫 * * @param message * @return */ public static String md5(String message) { try { // 1 建立一個提供信息摘要算法的對象,初始化爲md5算法對象 MessageDigest md = MessageDigest.getInstance("MD5"); // 2 將消息變成byte數組 byte[] input = message.getBytes(); // 3 計算後得到字節數組,這就是那128位了 byte[] buff = md.digest(input); // 4 把數組每一字節(一個字節佔八位)換成16進制連成md5字符串 return byte2hex(buff); } catch (Exception e) { throw new RuntimeException(e); } } /** * 二進制轉十六進制字符串 * * @param bytes * @return */ private static String byte2hex(byte[] bytes) { StringBuilder sign = new StringBuilder(); for (int i = 0; i < bytes.length; i++) { String hex = Integer.toHexString(bytes[i] & 0xFF); if (hex.length() == 1) { sign.append("0"); } sign.append(hex.toUpperCase()); } return sign.toString(); } public String getTime() { return new SimpleDateFormat(ParamNames.TIMESTAMP_PATTERN).format(new Date()); } }
若是您有其它好的方法和建議,歡迎在評論中討論。