SpringBoot でのインターフェイス冪等性実装スキーム - カスタム アノテーション + Redis + インターセプターの実装により、注文の繰り返し送信を防止します

シーン

SpringBoot + Redis + カスタム アノテーションは、インターフェイス アンチブラシを実装します (さまざまなインターフェイスの単位時間あたりの最大リクエスト数を制限します)。

SpringBoot + Redis + カスタム アノテーションはインターフェイス アンチブラシを実装します (さまざまなインターフェイスの単位時間あたりの最大リクエスト数を制限します)

以下のインターフェースの冪等性の実装は上記のブログと同様なので参考にしてください。

インターフェースの冪等性

べき等性とは何ですか?

冪等とは数学とコンピュータ サイエンスの概念です。数学における特定の初等演算が冪等である場合、その演算は任意の要素に 2 回作用します。

一度適用した場合と同じ結果になります。コンピュータ プログラミングにおける冪等操作は、すべての要素を実行することを特徴とします。

どちらも 1 回の実行と同じ影響を及ぼします。冪等な関数またはメソッドとは、同じパラメータを使用して繰り返し実行できる関数またはメソッドです。

と同じ結果を達成する関数。これらの関数はシステムの状態に影響を与えず、繰り返しの実行によるシステムの変化を心配する必要はありません。

インターフェイス冪等性とは何ですか?

HTTP/1.1 では冪等性が定義されています。リソースに対する 1 つ以上のリクエストがどのように機能するかを説明します。

同じ結果 (ネットワーク タイムアウトなどの問題を除く)。つまり、最初のリクエストはリソースに副作用がありますが、後続のリクエストは影響を受けません。

どちらもリソースに副作用を与えることはなくなります。ここでの副作用は、結果が破壊されたり、予期しない結果が生じたりすることではありません。

つまり、それを何回実行しても、リソース自体には 1 回の実行と同じ影響があります。

なぜ冪等性を実現する必要があるのでしょうか?

通常、インターフェイスが呼び出された場合、情報は正常に返され、繰り返し送信されることはありませんが、次の状況が発生した場合に問題が発生する可能性があります。

のように:

フロントエンドはフォームを繰り返し送信します。

一部のフォームに記入する際、ユーザーは記入して送信しますが、多くの場合、ネットワークの変動により、ユーザーは送信の成功に対して時間内に応答しません。

これにより、ユーザーは送信が失敗したと思い込み、送信ボタンをクリックし続けることになり、フォームの送信要求が繰り返し発生します。

ユーザーが悪意を持って注文をスワイプします:

たとえば、ユーザー投票機能を実装する場合、ユーザーが繰り返し投票を送信すると、インターフェイスは

ユーザーが投票情報を繰り返し送信すると、投票結果が事実と著しく矛盾することになります。

インターフェイスがタイムアウトになり、繰り返し送信されました。

多くの場合、HTTP クライアント ツールは、特にサードパーティがインターフェイスを呼び出した場合に、ネットワークの障害を防ぐために、デフォルトでタイムアウト再試行メカニズムを有効にします。

変動タイムアウトなどによるリクエストの失敗については、リトライ機構が追加され、1つのリクエストが複数回送信されることになります。

メッセージは繰り返し消費されます。

MQメッセージミドルウェアを使用している場合、メッセージミドルウェアでエラーが発生し、消費情報の投入が間に合わず、繰り返し消費されてしまう場合があります。

冪等性を使用する最大の利点は、インターフェイスが冪等な操作を保証し、再試行によってシステムによって引き起こされる未知の問題を回避できることです。

インターフェイスの冪等性を実現するためのソリューションは数多くありますが、次の例では、カスタム アノテーションとインターセプターおよび redis 実装を記録して、繰り返しの注文送信の防止をシミュレートしています。

注記:

ブログ:
Domineering Rogue Temperament_C#、アーキテクチャ ロード、SpringBoot-CSDN ブログ

成し遂げる

1. 新しい SpringBoot プロジェクトを作成し、必要な依存関係を追加します。

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </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>
        <!--MySQL驱动-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <!--MyBatis整合SpringBoot框架的起步依赖-->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.2.1</version>
        </dependency>
        <!-- redis 缓存操作 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <!-- 阿里JSON解析器 -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.75</version>
        </dependency>
    </dependencies>

2. 構成ファイル yml を変更して、関連する構成を追加します。

# 开发环境配置
server:
  # 服务器的HTTP端口,默认为8080
  port: 996
  servlet:
    # 应用的访问路径
    context-path: /
  tomcat:
    # tomcat的URI编码
    uri-encoding: UTF-8
    # tomcat最大线程数,默认为200
    max-threads: 800
    # Tomcat启动初始化的线程数,默认值25
    min-spare-threads: 30

# 数据源
spring:
  application:
    name: badao-tcp-demo
  datasource:
   url:jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver
    dbcp2:
      min-idle: 5                                # 数据库连接池的最小维持连接数
      initial-size: 5                            # 初始化连接数
      max-total: 5                               # 最大连接数
      max-wait-millis: 150                       # 等待连接获取的最大超时时间

  # redis 配置
  redis:
    # 地址
    #本地测试用
    host: 127.0.0.1
    port: 6379
    password: 123456
    # 连接超时时间
    timeout: 10s
    lettuce:
      pool:
        # 连接池中的最小空闲连接
        min-idle: 0
        # 连接池中的最大空闲连接
        max-idle: 8
        # 连接池的最大数据库连接数
        max-active: 8
        # #连接池最大阻塞等待时间(使用负值表示没有限制)
        max-wait: -1ms

# mybatis配置
mybatis:
  mapper-locations: classpath:mapper/*.xml    # mapper映射文件位置
  type-aliases-package: com.badao.demo.entity    # 实体类所在的位置
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl   #用于控制台打印sql语句


3. Redis 用の 2 つの構成クラスを作成します。

RedisConfig:

import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;

/**
 * redis配置
 *
 */
@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport
{
    @Bean
    @SuppressWarnings(value = { "unchecked", "rawtypes" })
    @Primary
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
    {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class);

        ObjectMapper mapper = new ObjectMapper();
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
        serializer.setObjectMapper(mapper);

        template.setValueSerializer(serializer);
        // 使用StringRedisSerializer来序列化和反序列化redis的key值
        template.setKeySerializer(new StringRedisSerializer());
        template.afterPropertiesSet();
        return template;
    }
}

FastJson2JsonRedisシリアライザー:

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.ParserConfig;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.type.TypeFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;
import org.springframework.util.Assert;

import java.nio.charset.Charset;

/**
 * Redis使用FastJson序列化
 *
 */
public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T>
{
    @SuppressWarnings("unused")
    private ObjectMapper objectMapper = new ObjectMapper();

    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");

    private Class<T> clazz;

    static
    {
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
    }

    public FastJson2JsonRedisSerializer(Class<T> clazz)
    {
        super();
        this.clazz = clazz;
    }

    @Override
    public byte[] serialize(T t) throws SerializationException
    {
        if (t == null)
        {
            return new byte[0];
        }
        return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
    }

    @Override
    public T deserialize(byte[] bytes) throws SerializationException
    {
        if (bytes == null || bytes.length <= 0)
        {
            return null;
        }
        String str = new String(bytes, DEFAULT_CHARSET);

        return JSON.parseObject(str, clazz);
    }

    public void setObjectMapper(ObjectMapper objectMapper)
    {
        Assert.notNull(objectMapper, "'objectMapper' must not be null");
        this.objectMapper = objectMapper;
    }

    protected JavaType getJavaType(Class<?> clazz)
    {
        return TypeFactory.defaultInstance().constructType(clazz);
    }
}

4. 新しい Redis ツールクラスを作成する

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.*;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.concurrent.TimeUnit;

/**
 * spring redis 工具类
 *
 **/
@SuppressWarnings(value = { "unchecked", "rawtypes" })
@Component
public class RedisCache
{
    @Autowired
    public RedisTemplate redisTemplate;

    @Autowired
    public StringRedisTemplate stringRedisTemplate;

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     * @return 缓存的对象
     */
    public <T> ValueOperations<String, T> setCacheObject(String key, T value)
    {
        ValueOperations<String, T> operation = redisTemplate.opsForValue();
        operation.set(key, value);
        return operation;
    }

    /**
     * 缓存基本的对象,Integer、String、实体类等
     *
     * @param key 缓存的键值
     * @param value 缓存的值
     * @param timeout 时间
     * @param timeUnit 时间颗粒度
     * @return 缓存的对象
     */
    public <T> ValueOperations<String, T> setCacheObject(String key, T value, Integer timeout, TimeUnit timeUnit)
    {
        ValueOperations<String, T> operation = redisTemplate.opsForValue();
        operation.set(key, value, timeout, timeUnit);
        return operation;
    }

    /**
     * 获得缓存的基本对象。
     *
     * @param key 缓存键值
     * @return 缓存键值对应的数据
     */
    public <T> T getCacheObject(String key)
    {
        ValueOperations<String, T> operation = redisTemplate.opsForValue();
        return operation.get(key);
    }

    /**
     * 删除单个对象
     *
     * @param key
     */
    public void deleteObject(String key)
    {
        redisTemplate.delete(key);
    }

    /**
     * 删除集合对象
     *
     * @param collection
     */
    public void deleteObject(Collection collection)
    {
        redisTemplate.delete(collection);
    }

    /**
     * 缓存List数据
     *
     * @param key 缓存的键值
     * @param dataList 待缓存的List数据
     * @return 缓存的对象
     */
    public <T> ListOperations<String, T> setCacheList(String key, List<T> dataList)
    {
        ListOperations listOperation = redisTemplate.opsForList();
        if (null != dataList)
        {
            int size = dataList.size();
            for (int i = 0; i < size; i++)
            {
                listOperation.leftPush(key, dataList.get(i));
            }
        }
        return listOperation;
    }

    /**
     * 获得缓存的list对象
     *
     * @param key 缓存的键值
     * @return 缓存键值对应的数据
     */
    public <T> List<T> getCacheList(String key)
    {
        List<T> dataList = new ArrayList<T>();
        ListOperations<String, T> listOperation = redisTemplate.opsForList();
        Long size = listOperation.size(key);

        for (int i = 0; i < size; i++)
        {
            dataList.add(listOperation.index(key, i));
        }
        return dataList;
    }

    /**
     * 缓存Set
     *
     * @param key 缓存键值
     * @param dataSet 缓存的数据
     * @return 缓存数据的对象
     */
    public <T> BoundSetOperations<String, T> setCacheSet(String key, Set<T> dataSet)
    {
        BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);
        Iterator<T> it = dataSet.iterator();
        while (it.hasNext())
        {
            setOperation.add(it.next());
        }
        return setOperation;
    }

    /**
     * 获得缓存的set
     *
     * @param key
     * @return
     */
    public <T> Set<T> getCacheSet(String key)
    {
        Set<T> dataSet = new HashSet<T>();
        BoundSetOperations<String, T> operation = redisTemplate.boundSetOps(key);
        dataSet = operation.members();
        return dataSet;
    }

    /**
     * 缓存Map
     *
     * @param key
     * @param dataMap
     * @return
     */
    public <T> HashOperations<String, String, T> setCacheMap(String key, Map<String, T> dataMap)
    {
        HashOperations hashOperations = redisTemplate.opsForHash();
        if (null != dataMap)
        {
            for (Map.Entry<String, T> entry : dataMap.entrySet())
            {
                hashOperations.put(key, entry.getKey(), entry.getValue());
            }
        }
        return hashOperations;
    }

    /**
     * 获得缓存的Map
     *
     * @param key
     * @return
     */
    public <T> Map<String, T> getCacheMap(String key)
    {
        Map<String, T> map = redisTemplate.opsForHash().entries(key);
        return map;
    }

    /**
     * 获得缓存的基本对象列表
     *
     * @param pattern 字符串前缀
     * @return 对象列表
     */
    public Collection<String> keys(String pattern)
    {
        return redisTemplate.keys(pattern);
    }

    /**
     * 如果redis中不存在则存储进redis
     * @return
     */
    public Boolean setIfAbsent(Object key,Object value,long timeout){
        return redisTemplate.opsForValue().setIfAbsent(key, value,timeout, TimeUnit.SECONDS);
    }
}

5. 新しいカスタム アノテーション クラス Idempotent を作成します

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {

    /**
     * 用于拼接幂等性判断的key的入参字段
     * @return
     */
    String[] fields();
    /**
     * 用于接口幂等性校验的Redis中Key的过期时间,单位秒
     * @return
     */
    long timeout() default 10l;
}

6. カスタム インターセプター IdempotentInterceptor を作成する

import com.badao.demo.annotation.Idempotent;
import com.badao.demo.utils.RedisCache;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.OutputStream;

@Component
public class IdempotentInterceptor implements HandlerInterceptor {

    @Resource
    private RedisCache redisCache;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        Idempotent idempotent = ((HandlerMethod)handler).getMethodAnnotation(Idempotent.class);
        if(idempotent == null){
            return true;
        }
        String idempotentKey = this.idempotentKey(idempotent,request);
        Boolean success = redisCache.setIfAbsent(idempotentKey,1, idempotent.timeout());
        if(Boolean.FALSE.equals(success)){
            render(response,"请勿重复请求");
            return false;
        }
        return true;
    }

    private String idempotentKey(Idempotent idempotent,HttpServletRequest request){
        String[] fields = idempotent.fields();
        StringBuilder idempotentKey = new StringBuilder();
        for (String field : fields) {
            String parameter = request.getParameter(field);
            idempotentKey.append(parameter);
        }
        return idempotentKey.toString();
    }

    /**
     * 接口渲染
     * @param response
     * @throws Exception
     */
    private void render(HttpServletResponse response,String message)throws Exception {
        response.setContentType("application/json;charset=UTF-8");
        OutputStream out = response.getOutputStream();
        out.write(message.getBytes("UTF-8"));
        out.flush();
        out.close();
    }
}

7. SpringMVC インターセプター チェーンに登録するインターセプター IdempotentInterceptor を構成します。

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import javax.annotation.Resource;

/**
 * 配置拦截器IdempotentInterceptor注册到SpringMVC的拦截器链中
 */
@Configuration
public class WebConfig extends WebMvcConfigurationSupport {

    @Resource
    private IdempotentInterceptor idempotentInterceptor;

    @Override
    protected void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(idempotentInterceptor);
    }
}

8. テスト インターフェイスを作成し、カスタム アノテーションを追加します

import com.badao.demo.annotation.Idempotent;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {

    @Idempotent(fields = {"orderId"},timeout = 10)
    @GetMapping("/test")
    public String test(@RequestParam("orderId") String orderId, String remark){
        return "success";
    }
}

ここでは、オーダーの一意のIDパラメータとしてorderIdを指定しており、10秒以内に同じフィールドのリクエストが設定された場合はインターセプトされます。

9. テストメソッドを作成する

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
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.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

@SpringBootTest
class IdempotenceTest {

    @Autowired
    private WebApplicationContext webApplicationContext;

    @Test
    void test1() throws Exception {
        //初始化MockMvc
        MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
        //循环调用5次进行测试
        for (int i = 1; i <= 5; i++) {
            System.out.println("第"+i+"次调用接口");
            //调用接口
            String result = mockMvc.perform(MockMvcRequestBuilders.get("/test")
                    .accept(MediaType.TEXT_HTML)
                    .param("orderId","001")
                    .param("remark","badao"))
                    .andReturn()
                    .getResponse()
                    .getContentAsString();
            System.out.println(result);
        }
    }
}

試験結果

postman およびその他のインターフェイス テスト ツールを使用して再テストおよび検証する

 

おすすめ

転載: blog.csdn.net/BADAO_LIUMANG_QIZHI/article/details/131910440