Use Redis to make your interface automatically idempotent

Posted by byrt on Tue, 04 Jan 2022 22:32:00 +0100

1, What is interface idempotency

Idempotency is that the results of only multiple operations are consistent. Someone here may have questions.

Q: why should the results of multiple operations be consistent? For example, when I query data, what I find out is the same every time. Even if I modify it, should it be the same every time?

A: by multiple, we mean multiple operations in the same request. This multiple operation may occur in the following situations:

  • Front end duplicate submission. For example, this business process takes 2 seconds. I clicked the submit button three times in a row within 2 seconds. If the non idempotent interface is used, the backend will process it three times. If it is a query, it will naturally have no impact, because the query itself is an idempotent operation, but if it is a new one, it would only add one record. If you click it three times, it will add three records, which is obviously not possible.

  • Request retry due to response timeout: in the process of microservices calling each other, if the order service calls the payment service, the payment service succeeds, but the order service times out when receiving the information returned by the payment service, so the order service retries and requests the payment service. As a result, the payment service deducts the user's money again. If so, the user may have come with a machete.

2, How to design an idempotent interface

After the above description, I believe you have understood what interface idempotency is and its importance. So how to design? There are several schemes:

  • Database record status mechanism: query the status before each operation, and judge whether to continue the operation according to the status of the database record. For example, the order service invokes the payment service. Before each call, query the payment status of the order to avoid repeated operations.

  • Token mechanism: before requesting the business interface, first request the token interface (which will put the generated token into redis) to obtain a token, and then bring the token when requesting the business interface. Before conducting business operations, we first obtain the token carried in the request to see if it exists in redis. If so, delete it. If the deletion is successful, it means that the token verification has passed and continue to perform business operations; If the token is not available in redis, it indicates that it has been deleted, that is, business operations have been performed, and business operations will not be performed. The general process is as follows:

  • Other schemes: there are many other schemes for interface idempotent design, such as globally unique id, optimistic lock, etc. This paper mainly talks about the use of token mechanism. If you are interested, you can study it yourself.

3, Using token mechanism to realize idempotency of interface

1,pom.xml: mainly introduces redis related dependencies

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- spring-boot-starter-data-redis -->
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
	<artifactId>commons-pool2</artifactId>
</dependency>
<!-- jedis -->
<dependency>
	<groupId>redis.clients</groupId>
	<artifactId>jedis</artifactId>
</dependency>
<dependency>
	<groupId>org.projectlombok</groupId>
	<artifactId>lombok</artifactId>
	<optional>true</optional>
</dependency>
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-test</artifactId>
	<scope>test</scope>
</dependency>
<!-- commons-lang3 -->
<dependency>
	<groupId>org.apache.commons</groupId>
	<artifactId>commons-lang3</artifactId>
</dependency>
<!-- org.json/json -->
<dependency>
    <groupId>org.json</groupId>
    <artifactId>json</artifactId>
    <version>20190722</version>
</dependency>

2,application.yml: mainly used to configure redis

server:
  port: 6666
spring:
  application:
    name: idempotent-api
  redis:
    host: 192.168.2.43
    port: 6379

3. Business Code:

  • Create a new enumeration to list the common return information, as follows:
@Getter
@AllArgsConstructor
public enum ResultEnum {
	REPEATREQUEST(405, "Repeat request"),
	OPERATEEXCEPTION(406, "Abnormal operation"),
	HEADERNOTOKEN(407, "Request header not carried token"),
	ERRORTOKEN(408, "token incorrect")
	;
	private Integer code;
	private String msg;
}
  • Create a new JsonUtil and output json to the page when the request is abnormal:
public class JsonUtil {
	private JsonUtil() {}
	public static void writeJsonToPage(HttpServletResponse response, String msg) {
		PrintWriter writer = null;
        response.setCharacterEncoding("UTF-8");
        response.setContentType("text/html; charset=utf-8");
        try {
            writer = response.getWriter();
            writer.print(msg);
        } catch (IOException e) {
        } finally {
            if (writer != null)
                writer.close();
        }
	}
}
  • Create a RedisUtil to operate redis:
@Component
public class RedisUtil {
	
	private RedisUtil() {}

	private static RedisTemplate redisTemplate;

    @Autowired
	public  void setRedisTemplate(@SuppressWarnings("rawtypes") RedisTemplate redisTemplate) {
		redisTemplate.setKeySerializer(new StringRedisSerializer());
        //Sets the instantiated object of the serialized Value
		redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
		RedisUtil.redisTemplate = redisTemplate;
	}
	
	/**
	 * Set the key value, and the expiration time is timeout seconds
	 * @param key
	 * @param value
	 * @param timeout
	 */
	public static void setString(String key, String value, Long timeout) {
		redisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS);
	}
	
	/**
	 * Set key value
	 * @param key
	 * @param value
	 */
	public static void setString(String key, String value) {
		redisTemplate.opsForValue().set(key, value);
	}
	
	/**
	 * Get key value
	 * @param key
	 * @return
	 */
	public static String getString(String key) {
		return (String) redisTemplate.opsForValue().get(key);
	}
	
	/**
	 * Determine whether the key exists
	 * @param key
	 * @return
	 */
	public static boolean isExist(String key) {
		return redisTemplate.hasKey(key);
	}
	
	/**
	 * Delete key
	 * @param key
	 * @return
	 */
	public static boolean delKey(String key) {
		return redisTemplate.delete(key);
	}
}
  • Create a new TokenUtil to generate and verify Tokens: there is nothing to say about generating tokens. Here, for simplicity, you can directly generate them with uuid and then put them into redis. Verify the token. If the user does not carry the token, return false directly; If a token is carried but not in redis, it indicates that it has been deleted, that is, it has been accessed, and false is returned; If there is a token in redis, but the token in redis is inconsistent with the token carried by the user, false is also returned; Yes and consistent. It means that the token in redis is deleted for the first time, and then true is returned.
public class TokenUtil {

	private TokenUtil() {}
	
	private static final String KEY = "token";
	private static final String CODE = "code";
	private static final String MSG = "msg";
	private static final String JSON = "json";
	private static final String RESULT = "result";
	
	/**
	 * Generate a token and put it into redis
	 * @return
	 */
	public static String createToken() {
		String token = UUID.randomUUID().toString();
		RedisUtil.setString(KEY, token, 60L);
		return RedisUtil.getString(KEY);
	}
	
	/**
	 * Verification token
	 * @param request
	 * @return
	 * @throws JSONException 
	 */
	public static Map<String, Object> checkToken(HttpServletRequest request) throws JSONException {
		String headerToken = request.getHeader(KEY);
		JSONObject json = new JSONObject();
		Map<String, Object> resultMap = new HashMap<>();
		// The request header does not carry a token and returns false directly
		if (StringUtils.isEmpty(headerToken)) {
			json.put(CODE, ResultEnum.HEADERNOTOKEN.getCode());
			json.put(MSG, ResultEnum.HEADERNOTOKEN.getMsg());
			resultMap.put(RESULT, false);
			resultMap.put(JSON, json.toString());
			return resultMap;
		}
		
		if (StringUtils.isEmpty(RedisUtil.getString(KEY))) {
			// If there is no token in redis, it indicates that the access has been successful, and false is returned directly
			json.put(CODE, ResultEnum.REPEATREQUEST.getCode());
			json.put(MSG, ResultEnum.REPEATREQUEST.getMsg());
			resultMap.put(RESULT, false);
			resultMap.put(JSON, json.toString());
			return resultMap;
		} else {
			// If there is a token in redis, it will be deleted. true will be returned if the deletion succeeds, and false will be returned if the deletion fails
			String redisToken = RedisUtil.getString(KEY);
			boolean result = false;
			if (!redisToken.equals(headerToken)) {
				json.put(CODE, ResultEnum.ERRORTOKEN.getCode());
				json.put(MSG, ResultEnum.ERRORTOKEN.getMsg());
			} else {
				result = RedisUtil.delKey(KEY);
				String msg = result ? null : ResultEnum.OPERATEEXCEPTION.getMsg();
				json.put(CODE, 400);
				json.put(MSG, msg);
			}
			resultMap.put(RESULT, result);
			resultMap.put(JSON, json.toString());
			return resultMap;
		}
	}
}
  • Create a new annotation to mark the interface to be idempotent:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NeedIdempotent {
}
  • Next, create a new interceptor to intercept the methods annotated with @ NeedIdempotent and automatically idempotent them.
public class IdempotentInterceptor implements HandlerInterceptor{

	@Override
	public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,Object object) throws JSONException {
		// Interception is not a method, direct release
		if (!(object instanceof HandlerMethod)) {
            return true;
        }
		HandlerMethod handlerMethod = (HandlerMethod) object;
        Method method = handlerMethod.getMethod();
        // If it is a method with @ NeedIdempotent annotation, it is automatically idempotent
        if (method.isAnnotationPresent(NeedIdempotent.class)) {
			Map<String, Object> resultMap = TokenUtil.checkToken(httpServletRequest);
			boolean result = (boolean) resultMap.get("result");
			String json = (String) resultMap.get("json");
			if (!result) {
				JsonUtil.writeJsonToPage(httpServletResponse, json);
			}
			return result;
		} else {
			return true;
		}
	}
	
	@Override
    public void postHandle(HttpServletRequest httpServletRequest,HttpServletResponse httpServletResponse, Object o,ModelAndView modelAndView) {
    }

    @Override
    public void afterCompletion(HttpServletRequest httpServletRequest,HttpServletResponse httpServletResponse,Object o, Exception e) {
    }
}
  • Then configure the interceptor in spring:
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
	
	@Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(idempotentInterceptor())
                .addPathPatterns("/**");   
    }
    @Bean
    public IdempotentInterceptor idempotentInterceptor() {
        return new IdempotentInterceptor();
    }

}
  • Finally, create a new controller, and you can test happily.
@RestController
@RequestMapping("/idempotent")
public class IdempotentApiController {

	@NeedIdempotent
	@GetMapping("/hello")
	public String hello() {
		return "are you ok?";
	}
	
	@GetMapping("/token")
	public String token() {
		return TokenUtil.createToken();
	}
}

When accessing / token, no verification is required. When accessing / hello, it will be automatically idempotent. A token must be obtained first for each access. A token cannot be used twice.

Topics: Java Redis Cache