使用easyopen攔截器防止表單重複提交

關於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

  1. 當一個用戶線程正在處理一個業務方法時,該用戶的其它線程進來被拒絕
  2. 支持集羣環境處理(單機程序可用synchronize解決,但不適合集羣)

實現思路

  1. 使用redis作全局鎖,在preHandle方法中申請鎖
  2. 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());
	}

}

若是您有其它好的方法和建議,歡迎在評論中討論。

相關文章
相關標籤/搜索