java客户端操作RabbitMQ

上一篇:RabbitMQ安装与原理详解

官方文档:http://next.rabbitmq.com/api-guide.html
API文档:https://rabbitmq.github.io/rabbitmq-java-client/api/current/

一、使用Java实现消息的发送与接收

1. 添加依赖

<!--rabbitMQ java客户端依赖-->
<dependency>
    <groupId>com.rabbitmq</groupId>
    <artifactId>amqp-client</artifactId>
    <version>5.1.1</version>
</dependency>

2. 与RabbitMQ建立连接

  • 先创建连接工厂ConnectionFactory ,并指定连接RabbitMQ的四要素(ip,端口,账号,密码)
  • 通过连接工厂创建连接对象Connection,再通过连接对象获取到信道对象Channel
  • 通过信道对象实现对 RabbitMQ 的操作
  • 关闭接连和信道

注意:有些类名与其它包中的重名了(比如java.sql.Connection,java.nio.channels.Channel等),导包的时候,一定是com.rabbitmq.client包下的

private static void send() {
    //创建连接工厂
    ConnectionFactory factory = new ConnectionFactory();
    //设置连接信息
    factory.setHost("192.168.245.128");//设置RabbitMQ所在机器的IP地址
    factory.setPort(5672);//指定端口
    factory.setUsername("root");//指定连接账号
    factory.setPassword("123");//指定连接密码
    Connection connection = null;
    final Channel channel;  //后面可能会在匿名内部类中使用,故设为常量
    try {
        //创建连接对象,用于连接到RabbitMQ
        connection=factory.newConnection();
        //创建通道对象
        channel=connection.createChannel();

        /**
         * 在这里实现对RabbitMQ的操作,之后的代码,如果没有特殊说明,默认都是写在这里的
         */

    } catch (IOException e) {
        e.printStackTrace();
    } catch (TimeoutException e) {
        e.printStackTrace();
    }finally {
        if(channel != null){
            try {
                channel.close();
            } catch (IOException e) {
                e.printStackTrace();
            } catch (TimeoutException e) {
                e.printStackTrace();
            }
        }
        if(connection != null){
            try {
                connection.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

3. Channel操作RabbitMQ

在获取到 Channel 对象后(channel),通过该对象的方法来操作 RabbitMQ,常用的方法有:

(1)创建队列

队列是单例的,如果多次创建同一个名字的队列,仍是原来的那个队列

创建一个指定名字的队列:

channel.queueDeclare(String queue, boolean durable, boolean exclusive, boolean autoDelete,
                     Map<String, Object> arguments) 
  • queue:队列名
  • durable:是否持久化,true表示开启持久化
  • exclusive:是否排外,true表示排外,一个队列只允许一个消费者连接
  • autoDelete:如果没有消费者连接是否自动删除队列
  • arguments:指定参数,通常为null

创建一个随机的队列:

//创建一个随机的默认的队列,队列名是随机的,当然也可以自己指定队列名
String queueName=channel.queueDeclare().getQueue();
  • 返回 String:队列的名字

(2)创建交换机

交换机是单例的,如果多次创建同一个名字的交换机,并不会改变原来的那个交换机

channel.exchangeDeclare(String exchange, String type, boolean durable)
  • exchange:交换机的名称
  • type:交换机类型
  • durable:是否是持久化的消息

(3)将队列和交换机绑定到到某个RoutingKey中

无论是接收消息还是发送消息,必须保证交换机已经创建和队列已经创建并实现绑定

因此这个3个步骤一般是在项目启动时直接创建好,例如交给Spring在启动容器时就可以创建

注意:

  1. 无论是交换机还是队列都不会因为重复的创建而给覆盖(单例)
  2. 如果不能在项目启动时就创建好交换机和队列,以及绑定,那么建议在消息消费者中完成这些操作,如果这么做了就必须要先启动消费者(一般也是先启动消费者)
channel.queueBind(String queue, String exchange, String routingKey)
  • queue:队列名,必须已经存在
  • exchange:需要绑定的交换机名称,必须已经存在
  • routionKey:RoutingKey 这个值取值任意但必须要与发送时完全一致

(4)发送消息

channel.basicPublish(String exchange, String routingKey, BasicProperties props, byte[] body)
  • exchange:将消息发送的指定的队列中,必须已经存在
  • routingKey:routingKey的名称,如果不指定exchange,则routingKey表示队列名字,直接往队列里发送
  • props:消息属性
  • body:具体的消息数据

注意:

  1. 使用direct消息模式时,必须要指定routingKey(路由键),将指定的消息绑定到指定的路由键上
  2. fanout模式的消息需要将一个消息同时绑定到多个队列中,因此这里不能创建并指定某个队列,即不绑定队列和交换机,方法中的routingKey为""
  3. 在topic模式中必须要指定routingkey,并且可以同时指定多层的routingKey,每个层次之间使用点(".")分隔即可 例如:aa.bb.cc

(5)接收消息

channel.basicConsume(String queue, boolean autoAck, String consumerTag, Consumer callback)
  • queue:队列名称,必须已经存在
  • autoAck:是否自动确认消息 true表示自动确认 false表示手动确认
  • consumerTag:消费的标签,用于区分不同的消费者
  • callback:消息接收后的回调方法,新建一个DefaultConsumer(channel)对象,构造方法的参数为信道对象,并重写handleDelivery方法,在该方法中对消息进行处理
    • handleDelivery方法的参数:
      • consumerTag:标识信道中投递的消息,每个信道中,每条消息的 consumerTag 从 1 开始递增
      • body:表示取到的消息的字节数组

注意:

  1. 消息消费者消费完成消息以后可以不关闭通道和链接,如果不关闭通道和链接那么消费者会不间断的接收消息,因为我们的消息接收底层会启动一个子线程,异步实现接收
  2. 使用Exchange的direct模式时接收者的RoutingKey必须要与发送时的RoutingKey完全一致否则无法获取消息,接收消息时队列名也必须要发送消息时的完全一致
  3. 使用fanout模式获取消息时不需要绑定特定的队列名称,只需使用channel.queueDeclare().getQueue();获取一个随机的队列名称,然后绑定到指定的Exchange即可获取消息。这种模式中,可以同时启动多个接收者,只要都绑定到同一个Exchang上,即可让所有接收者同时接收同一个消息,是一种广播的消息机制
  4. Topic模式的消息接收时必须要指定RoutingKey并且可以使用#*来做统配符号,#表示通配任意一个单词,*表示通配任意多个单词,例如aa.*.*或者aa.#都可以接收到 routingKey 为 aa.bb.cc 的发送者发送的消息

(6)举例

接收消息(先启动接收消息进行监听,再启动发送消息):

  1. 不经过交换机,直接接收名字为 myQueue 的队列中的消息:

    //不经过交换机,直接接收名字为 myQueue 的队列中的消息
    channel.basicConsume("myQueue",true,"",new DefaultConsumer(channel){
        @Override
        public void handleDelivery(String consumerTag, 
                                   Envelope envelope, 
                                   AMQP.BasicProperties properties, 
                                   byte[] body) throws IOException {
            
            //获取到队列中的消息
            String message=new String(body,"UTF-8");
    	}
    });
    
  2. 接收交换机类型为 direct 的交换机绑定的队列中的数据

    //接收与类型为 fanout 的交换机绑定的队列中的数据
    channel.queueDeclare("myDirectQueue", true, false, false, null);
    channel.exchangeDeclare("directExchange", "fanout", true);
    channel.queueBind("myDirectQueue", "directExchange", "");
    
    channel.basicConsume("myDirectQueue", true, "", new DefaultConsumer(channel){
        @Override
        public void handleDelivery(String consumerTag, 
                                   Envelope envelope, 
                                   AMQP.BasicProperties properties, 
                                   byte[] body) throws IOException {
            
            //获取到队列中的消息
            String message=new String(body,"UTF-8");
        }
    });
    
  3. 接收交换机类型为 fanout 的交换机绑定的队列中的数据

    //接收与类型为 fanout 的交换机绑定的队列中的数据
    String queueName=channel.queueDeclare().getQueue();
    channel.exchangeDeclare("fanoutExchange", "fanout", true);
    channel.queueBind(queueName, "fanoutExchange", "");
    
    channel.basicConsume(queueName, true, "", new DefaultConsumer(channel){
        @Override
        public void handleDelivery(String consumerTag, 
                                   Envelope envelope, 
                                   AMQP.BasicProperties properties, 
                                   byte[] body) throws IOException {
            
            //获取到队列中的消息
            String message=new String(body,"UTF-8");
        }
    });
    
  4. 接收交换机类型为 topic 的交换机绑定的队列中的数据

    String queueName = channel.queueDeclare().getQueue();
    
    //创建一个交换机
    channel.exchangeDeclare("topicExchange", "topic", true);
    
    //将队列和交换机绑定到到某个RoutingKey中
    channel.queueBind(queueName, "topicExchange", "aa.*");
    
    //接收消息
    channel.basicConsume(queueName, true, "", new DefaultConsumer(channel) {
        @Override
        public void handleDelivery(String consumerTag,
                                   Envelope envelope,
                                   AMQP.BasicProperties properties,
                                   byte[] body) throws IOException {
            //获取消息
            String message = new String(body, "UTF-8");
            /*
                这里对消息进行处理
            */
        }
    });
    
  5. 给上面的消息消费者发送消息:

    //定义消息数据
    String message="这是发送的消息数据";
    
    //不经过交换机,直接发送到名字为 myQueue 的队列中
    channel.basicPublish("", "myQueue", null, message.getBytes("UTF-8"));
    
    //将消息发送到类型为 direct 的交换机中 
    channel.basicPublish("directExchange", "directRoutingKey", null, message.getBytes("UTF-8"));
    
    //将消息发送到类型为 fanout 的交换机中 
    channel.basicPublish("fanoutExchange", "", null, message.getBytes("UTF-8"));
    
    //将消息发送到类型为 topic 的交换机中 
    channel.basicPublish("topicExchange", "aa.bb", null, message.getBytes("UTF-8"));
    

4. 事务与消息确认模式

事务消息与数据库的事务类似,只是MQ中的消息是要保证消息是否会全部发送成功,防止丢失消息的一种策略。

RabbitMQ有两种方式来解决这个问题:

  1. 通过AMQP提供的事务机制实现;
  2. 使用Confirm发送方和接收方确认模式实现;

由于事务机制的性能很差,故使用较多的是Confirm发送方确认模式

(1)事务机制

事务的实现主要是对信道(Channel)的设置,主要的方法有三个:

  1. channel.txSelect():声明启动事务模式;
  2. channel.txComment():提交事务;
  3. channel.txRollback():回滚事务;

注意:要在消息发送之前启动信道的事务模式,发送完毕后要提交事务,否则不会发送成功

(2)发送者确认模式

Confirm发送方确认模式使用和事务类似,也是通过设置Channel进行发送方确认的,最终达到确保所有的消息全部发送成功

Confirm的三种实现方式:
开启发送方确认模式:channel.confirmSelect();
方式一:channel.waitForConfirms():普通发送方确认模式;
方式二:channel.waitForConfirmsOrDie():批量确认模式;
方式三:channel.addConfirmListener():异步监听发送方确认模式

使用方式:在发送消息前,开启发送方确认模式,在发送完毕后,进行消息的确认

方式一:
在推送消息之前,channel.confirmSelect()声明开启发送方确认模式,再使用channel.waitForConfirms()等待消息被服务器确认即可。

//开启消息确认模式
channel.confirmSelect();

//发送消息到指定队列
channel.basicPublish("", "directRoutingKey", null, message.getBytes("UTF-8"));

if (channel.waitForConfirms()) {
    System.out.println("消息发送成功");
}

方式二:
channel.waitForConfirmsOrDie()使用同步方式等所有的消息发送之后才会执行后面代码,只要有一个消息未被确认就会抛出IOException异常。

//开启消息确认模式
channel.confirmSelect();

for (int i = 0; i < 10000; i++) {
    channel.basicPublish("", "directRoutingKey", null, String.valueOf(i).getBytes("UTF-8"));
}
channel.waitForConfirmsOrDie(); //直到所有信息都发布,只要有一个未确认就会IOException
System.out.println("全部执行完成");

方式三:
异步模式的优点,就是执行效率高,不需要等待消息执行完,只需要监听消息即可

//开启消息确认模式
channel.confirmSelect();


//发送消息到指定队列
for (int i = 0; i < 10000; i++) {
    channel.basicPublish("", "directRoutingKey", null, String.valueOf(i).getBytes("UTF-8"));
}

//异步监听确认和未确认的消息
channel.addConfirmListener(new ConfirmListener() {
    public void handleAck(long deliveryTag, boolean multiple) throws IOException {
    	//这里是确认的消息
        System.out.println("成功确认的消息" + deliveryTag + "==> " + multiple);
    }

    public void handleNack(long deliveryTag, boolean multiple) throws IOException {
    	//这里是未确认的消息
        System.out.println("未确认的消息");
    }
});

handleAck()方法与handleNack()方法的参数:

  • deliveryTag:表示第几条消息
  • multiple:boolean 类型,表示是否批量处理了消息,true表示批量执行了deliveryTag这个值的消息和它之前的所有消息,false的话表示单条确认

(3)消费者确认模式

为了保证消息从队列可靠地到达消费者,RabbitMQ提供消息确认机制(message acknowledgment)。接收者消息确认指的是否要将数据从队列中进行移除,如果确认消息则是将这条消息从队列中彻底移除掉。如果这条消息被成功处理(例如完成数据库的插入等等),这条消息才能被确认删除,如果没有被成功处理(例如服务崩溃),我们在队列中的消息不应该被确认移除

在声明接收消息时(channel.basicConsume),可以指定 autoAck 参数,当 autoAckfalse时,RabbitMQ会等待消费者显式发回ack信号后才从内存(和磁盘,如果是持久化消息的话)中移去消息。否则(autoAck=true),消息被消费后会在队列中立即删除它,不管消息是否被接收到。

在Consumer中Confirm模式中分为手动确认和自动确认(autoAck=true)。

手动确认主要并使用以下方法:

  • basicAck:用于肯定确认

    channel.basicAck(long deliveryTag, boolean multiple);
    
    • deliveryTage:消息的编号,由RabbitMQ提供
    • multiple:true时用于多个消息确认,确认deliveryTage对应的消息和之前的消息,false为单条消息确认。
  • basicRecover:路由不成功的消息可以使用recover重新发送到队列中。

    channel.basicRecover(boolean requeue);
    
    • requeue:true时将确认不成功的消息重新发送到队列中
  • basicReject:是接收端告诉服务器这个消息我拒绝接收,不处理,可以设置是否放回到队列中还是丢掉,而且只能一次拒绝一个消息,官网中有明确说明不能批量拒绝消息,为解决批量拒绝消息才有了basicNack。

    channel.basicReject(long deliveryTag, boolean requeue);
    
  • basicNack:可以一次拒绝N条消息,客户端可以设置basicNack方法的multiple参数为true。

    channel.basicNack(long deliveryTag, boolean multiple, boolean requeue);
    
    • multiple:true表示开启批量处理

完整的程序:

channel.basicConsume("confirmDirectQueue", false, "", new DefaultConsumer(channel) {
    @Override
    public void handleDelivery(String consumerTag, 
                               Envelope envelope, 
                               AMQP.BasicProperties properties, 
                               byte[] body) throws IOException {
        String message = new String(body, "UTF-8");
        System.out.println(message);
        //获取消息在队列中的唯一标识
        long messageNo = envelope.getDeliveryTag();
        //根据消息的编号来确认消息,确认以后则表示这个消息已经全部完成处理
        //进行消息确认,需要将这个消息从队列中移除掉
        channel.basicAck(messageNo, true);
    }
});

(4)事务与确认模式混用

强烈建议只使用消息确认模式,因为事务开销太大,假设消费者模式中使用了事务,并且在消息确认之后进行了事务回滚,那么RabbitMQ会产生什么样的变化?

结果分为两种情况:

  • autoAck=false手动确认的时候是支持事务的,也就是说即使你已经手动确认了消息已经收到,但在确认消息会等到事务提交之后,如果你手动确认现在之后,又回滚了事务,那么会以事务回滚为主,此条消息会重新放回队列;
  • autoAck=true如果自定确认为true的情况是不支持事务的,也就是说你即使在收到消息之后在回滚事务也是于事无补的,队列已经把消息移除了;

注意:如果两者都使用了的话,如果确认模式中使用的是异步的方法,则事务提交不能放在主线程中,因为主线程运行完后,确认模式的子线程可能还在运行,如果事务提交放在主线程中的话,则主线程执行完后,子线程中确认模式就无法进行事务提交了。故事务提交应放在模式确认的子线程中

二、SpringBoot集成RabbitMQ

1. 添加依赖

<!--spring集成amqp的起步依赖-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

<!--这个是测试的-->
<dependency>
    <groupId>org.springframework.amqp</groupId>
    <artifactId>spring-rabbit-test</artifactId>
    <scope>test</scope>
</dependency>

2. 配置RabbitMQ

通用配置:

# 配置 rabbitmq 的ip
spring.rabbitmq.host=192.168.29.128
# 配置 rabbitmq 的端口
spring.rabbitmq.port=5672
# 配置 rabbitmq 的用户名
spring.rabbitmq.username=root
# 配置 rabbitmq 的密码
spring.rabbitmq.password=123
# 配置虚拟主机
spring.rabbitmq.virtual-host=/
# 配置连接超时时间
spring.rabbitmq.connection-timeout=15000

如果rabbitmq是集群的,则使用 addresses 来替换 host 和 port 配置如下:

#配置RabbitMQ的集群访问地址
spring.rabbitmq.addresses=192.168.222.129:5672,192.168.222.130:5672

配置发送端:

#支持消息发送失败返回队列
spring.rabbitmq.publisher-returns=true
#开启消息确认机制,simple为普通发送方确认模式,none为不启用
spring.rabbitmq.publisher-confirm-type=simple
#设置为 true 后 消费者在消息没有被路由到合适队列情况下会被return监听,而不会自动删除
spring.rabbitmq.template.mandatory=true

配置消费端:
首先配置手工确认模式,用于 ACK 的手工处理,这样我们可以保证消息的可靠性送达,或者在消费端消费失败的时候可以做到重回队列、根据业务记录日志等处理。我们也可以设置消费端的监听个数和最大个数,用于控制消费端的并发情况。我们要开启限流,指定每次处理消息最多只能处理两条消息。

#设置消费端手动 ack
spring.rabbitmq.listener.simple.acknowledge-mode=manual
#消费者最小数量
spring.rabbitmq.listener.simple.concurrency=1
#消费之最大数量
spring.rabbitmq.listener.simple.max-concurrency=10

#在单个请求中处理的消息个数,他应该大于等于事务数量(unack的最大数量)
spring.rabbitmq.listener.simple.prefetch=2

3. 创建队列与交换机

  • 创建交换机(Exchange):
    • 直接new对应类型的交换机:DirectExchange,FanoutExchange,TopicExchange,并指定交换机的名字
    • 通过交换机构造器对象创建各种类型的交换机:ExchangeBuilder.directExchange().build() 等
  • 创建队列(Queue):
    • 直接new一个Queue对象(注意包名是:org.springframework.amqp.core.Queue),并指定队列的名字
  • 创建队列与交换机的绑定对象(Binding)
    • 通过BindingBuilder对象来进行绑定并指定RoutingKey
//@Configuration 标记当前类是Spring的一个配置类,用于模拟Spring的xml配置文件
@Configuration
public class AmqpConfig {
    //标记当前方法是一个Spring的Bean标签配置,方法名相当于bean标签的id 返回值相当于bean标签的class
    //作用是用于创建一个对象到Spring的容器中
    @Bean
    public DirectExchange directExchange(){
        //创建一个交换机 参数为交换机名
        return new DirectExchange("BootDirectExchange");
    }
    @Bean
    public Queue directQueue(){
        //创建一个队列参数 为队列名称,其他参数设置参考普通Java代码创建队列的参数列表
        return new Queue("BootDirectQueue");
    }
    //将队列与交换机进行绑定,并指定RoutingKey
    //参数 1 为需要绑定的队列对象,参数名必须要与某个标记了@Bean的方法名完全一致,Spring就会将这个方法的返回值注入到当前方法参数中
    //参数 2 为需要绑定的交换机对象,参数名必须要与某个标记了@Bean的方法名完全一致,Spring就会将这个方法的返回值注入到当前方法参数中
    @Bean
    public Binding directBinding(Queue directQueue, DirectExchange directExchange){
        return BindingBuilder.bind(directQueue).to(directExchange).with("BootDirectRoutingKey");
    }

	/**
     * 创建死信交换机,跟普通交换机一样,只是死信交换机只用来接收过期的消息
     */
    @Bean
    public DirectExchange deadExchange() {
        return new DirectExchange("deadExchange", true, false);
    }

    /**
     * 创建死信队列,该队列没有消费者,消息会设置过期时间,消息过期后会发送到死信交换机,在由死信交换机转发至处理该消息的队列中
     */
    @Bean
    public Queue BeadQueue() {
        Map<String, Object> arguments = new HashMap<>(2);
        // 死信路由到死信交换器DLX
        arguments.put("x-dead-letter-exchange", "deadExchange");
        arguments.put("x-dead-letter-routing-key", "deadRoutingKey");
        return new Queue("deadQueue", true, false, false, arguments);

    }
}

4. AmqpTemplate操作RabbitMQ

AmqpTemplate 它提供了通用的操作基于Amqp开发的消息队列的方法。同样我们需要进行注入到 Spring 容器中,然后直接使用。AmqpTemplate 在 Spring 整合时需要实例化,但是在 Springboot 整合时,在配置文件里添加配置即可。

  • 获取AmqpTemplate对象,在Springboot中,在需要使用的类中直接获取:

    @Autowired
    private AmqpTemplate amqpTemplate;
    
  • 将java对象转换为Message对象,并发送到RabbitMQ

    amqpTemplate.convertAndSend(String exchange, String routingKey, Object message)
    
    • exchange:交换机名称
    • routingKey:路由键
    • message:消息
  • 将Message消息转换为java对象

    amqpTemplate.receiveAndConvert(String queueName)
    
    • queueName:队列名字
    • 返回值为Object,需要类型强转
  • 持续监听队列,如果队列中有值,就取出来

    • 在方法上使用@RabbitListener注解
    • 参数queues 指定监听哪个队列
    @RabbitListener(queues = "myQueue")
    public void directReceive(String message, Channel channel) {
        //这里是对取出来的message进行处理
    }
    
    //也可以直接在注解中创建队列,交换机,然后指定routingKey进行绑定,监听
    @RabbitListener(
        	bindings = @QueueBinding(
            	value = @Queue(value = "queue2", durable = "true"),
            	exchange = @Exchange(value = "exchange2", 
    	            	type = "direct", 
    	            	durable = "true", i
    	            	gnoreDeclarationExceptions = "true"
    	        ),
            	key = "routingKey2"
        	)
    )
    public void directReceive(String message, Channel channel) {
        //这里是对取出来的message进行处理
    }
    

5. RabbitTemplate操作RabbitMQ

RabbitTemplate 即消息模板,RabbitTemplate 是 AmqpTemplate 接口的一个实现类,它除了提供了AmqpTemplate通用的方法外,还提供了针对RabbitMQ操作的方法,比如回调监听消息接口 ConfirmCallback、返回值确认接口 ReturnCallback 等等。

发送者确认模式:

//发送者消息确认,在发送消息之前设置
rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() {
    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        if (ack) {
            System.out.println("消息发送确认成功");
        } else {
            System.out.println("消息发送失败" + cause);
        }
    }
});

//消息发送失败后,会将消息回传,在发送消息之前设置
rabbitTemplate.setReturnCallback(new RabbitTemplate.ReturnCallback() {
    @Override
    public void returnedMessage(Message message, 
							    int replyCode, 
							    String replyText, 
							    String exchange, 
	    						String routingKey) {
	    //在这里对消息进行重发
        System.out.println("消息发送失败" + message.toString());
    }
});

关于确认与回调:

  • 如果消息没有到exchange,则confirm回调,ack=false

  • 如果消息到达exchange,则confirm回调,ack=true

  • exchange到queue成功,则不回调return

  • exchange到queue失败,则回调return

接收者确认模式:
直接在消息接收者中通过接收到的Channen对象,进行消息确认即可,和之前讲的消费者消息确认一样(1.4.3)。

讲解比较深,讲的也非常棒:Java SpringBoot集成RabbitMq实战和总结
参考了这一篇博客:https://www.cnblogs.com/haixiang/p/10959551.html

发布了45 篇原创文章 · 获赞 46 · 访问量 1811

猜你喜欢

转载自blog.csdn.net/zyx1260168395/article/details/103974873