1. 消息发送方式

1.1 同步发送

简单来说,同步发送就是指 producer 发送消息后,会在接收到 broker 响应后才继续发下一条消息的通信方式。

由于这种同步发送的方式确保了消息的可靠性,同时也能及时得到消息发送的结果,故而适合一些发送比较重要的消息场景,比如说重要的通知邮件、营销短信等等。在实际应用中,这种同步发送的方式还是用得比较多的。

1.2 异步发送

接着就是异步发送,异步发送是指 producer 发出一条消息后,不需要等待 broker 响应,就接着发送下一条消息的通信方式。需要注意的是,不等待 broker 响应,并不意味着 broker 不响应,而是通过回调接口来接收 broker 的响应。所以要记住一点,异步发送同样可以对消息的响应结果进行处理。

由于异步发送不需要等待 broker 的响应,故在一些比较注重 RT(响应时间)的场景就会比较适用。比如,在一些视频上传的场景,我们知道视频上传之后需要进行转码,如果使用同步发送的方式来通知启动转码服务,那么就需要等待转码完成才能发回转码结果的响应,由于转码时间往往较长,很容易造成响应超时。此时,如果使用的是异步发送通知转码服务,那么就可以等转码完成后,再通过回调接口来接收转码结果的响应了。

        producer.send(message,new SendCallback() {public void onSuccess(SendResult sendResult) {// TODO Auto-generated method stubSystem.out.println("ok");}public void onException(Throwable e) {// TODO Auto-generated method stube.printStackTrace();System.out.println("err");}});

1.3 单向发送

单向发送,见名知意,就是一种单方向通信方式,也就是说 producer 只负责发送消息,不等待 broker 发回响应结果,而且也没有回调函数触发,这也就意味着 producer 只发送请求不等待响应结果。

由于单向发送只是简单地发送消息,不需要等待响应,也没有回调接口触发,故发送消息所耗费的时间非常短,同时也意味着消息不可靠。所以这种单向发送比较适用于那些耗时要求非常短,但对可靠性要求并不高的场景,比如说日志收集。

1.4 批量消息发送

可以多条消息打包一起发送,减少网络传输次数提高效率。

producer.send(Collection c)方法可以接受一个集合 实现批量发送

 public SendResult send(Collection<Message> msgs) throws MQClientException, RemotingException, MQBrokerException, InterruptedException {return this.defaultMQProducerImpl.send(batch(msgs));}
  • 批量消息要求必须要具有同一topic、相同消息配置
  • 不支持延时消息
  • 建议一个批量消息最好不要超过1MB大小
  • 如果不确定是否超过限制,可以手动计算大小分批发送

1.5 总结

下面通过一张表格,简单总结一下同步发送、异步发送和单向发送的特点。

发送方式 发送TPS 发送结果响应 可靠性
同步发送 不丢失
异步发送 不丢失
单向发送 没有 可能丢失

可以看到,从发送 TPS 来看,由于单向发送不需要等待响应也没有回调接口触发,发送速度非常快,一般都是微秒级的,在消息体大小一样的情况下,其发送 TPS 最大。而同步发送,需要等待响应结果的返回,受网络状况的影响较大,故发送 TPS 就比较小。异步发送不等待响应结果,发送消息时几乎不受网络的影响,故相比同步发送来说,其发送 TPS 要大得多。

关于可靠性,大家需要牢记前面提过的,异步发送并不意味着消息不可靠,异步发送也是会接收到响应结果,也能对响应结果进行处理。即使发送失败,也可以通过一些补偿手段进行消息重发。和同步发送比起来,异步发送的发送 TPS 更大,更适合那些调用链路较长的一些场景。在实际使用中,同步发送和异步发送都是较为常用的两种方式,大家要视具体业务场景进行合理地选择。

2. 消息类型

2.1 普通消息

普通消息也叫做无序消息,简单来说就是没有顺序的消息,producer 只管发送消息,consumer 只管接收消息,至于消息和消息之间的顺序并没有保证,可能先发送的消息先消费,也可能先发送的消息后消费。

举个简单例子,producer 依次发送 order id 为 1、2、3 的消息到 broker,consumer 接到的消息顺序有可能是 1、2、3,也有可能是 2、1、3 等情况,这就是普通消息。

因为不需要保证消息的顺序,所以消息可以大规模并发地发送和消费,吞吐量很高,适合大部分场景。

2.1.1 代码示例

生产者:

public class Producer {public static void main(String[] args) throws MQClientException, InterruptedException {//声明并初始化一个producer//需要一个producer group名字作为构造方法的参数,这里为concurrent_producerDefaultMQProducer producer = new DefaultMQProducer("concurrent_producer");//设置NameServer地址,此处应改为实际NameServer地址,多个地址之间用;分隔//NameServer的地址必须有,但是也可以通过环境变量的方式设置,不一定非得写死在代码里producer.setNamesrvAddr("10.1.54.121:9876;10.1.54.122:9876");//调用start()方法启动一个producer实例producer.start();//发送10条消息到Topic为TopicTest,tag为TagA,消息内容为“Hello RocketMQ”拼接上i的值for (int i = 0; i < 10; i++) {try {Message msg = new Message("TopicTestConcurrent",// topic"TagA",// tag("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET)// body);//调用producer的send()方法发送消息//这里调用的是同步的方式,所以会有返回结果,同时默认发送的也是普通消息SendResult sendResult = producer.send(msg);//打印返回结果,可以看到消息发送的状态以及一些相关信息System.out.println(sendResult);} catch (Exception e) {e.printStackTrace();Thread.sleep(1000);}}//发送完消息之后,调用shutdown()方法关闭producerproducer.shutdown();}
}

消费者:

public class Consumer {public static void main(String[] args) throws InterruptedException, MQClientException {//声明并初始化一个consumer//需要一个consumer group名字作为构造方法的参数,这里为concurrent_consumerDefaultMQPushConsumer consumer = new DefaultMQPushConsumer("concurrent_consumer");//同样也要设置NameServer地址consumer.setNamesrvAddr("10.1.54.121:9876;10.1.54.122:9876");//这里设置的是一个consumer的消费策略//CONSUME_FROM_LAST_OFFSET 默认策略,从该队列最尾开始消费,即跳过历史消息//CONSUME_FROM_FIRST_OFFSET 从队列最开始开始消费,即历史消息(还储存在broker的)全部消费一遍//CONSUME_FROM_TIMESTAMP 从某个时间点开始消费,和setConsumeTimestamp()配合使用,默认是半个小时以前consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);//设置consumer所订阅的Topic和Tag,*代表全部的Tagconsumer.subscribe("TopicTestConcurrent", "*");//设置一个Listener,主要进行消息的逻辑处理//注意这里使用的是MessageListenerConcurrently这个接口consumer.registerMessageListener(new MessageListenerConcurrently() {@Overridepublic ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,ConsumeConcurrentlyContext context) {System.out.println(Thread.currentThread().getName() + " Receive New Messages: " + msgs);//返回消费状态//CONSUME_SUCCESS 消费成功//RECONSUME_LATER 消费失败,需要稍后重新消费return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;}});//调用start()方法启动consumerconsumer.start();System.out.println("Consumer Started.");}
}

2.2 有序消息

有序消息就是按照一定的先后顺序的消息类型。

举个例子来说,producer 依次发送 order id 为 1、2、3 的消息到 broker,consumer 接到的消息顺序也就是 1、2、3 ,而不会出现普通消息那样的 2、1、3 等情况。

那么有序消息是如何保证的呢?我们都知道消息首先由 producer 到 broker,再从 broker 到 consumer,分这两步走。那么要保证消息的有序,势必这两步都是要保证有序的,即要保证消息是按有序发送到 broker,broker 也是有序将消息投递给 consumer,两个条件必须同时满足,缺一不可。
进一步还可以将有序消息分成

  • 全局有序消息
  • 局部有序消息

之前我们讲过,topic 只是消息的逻辑分类,内部实现其实是由 queue 组成。当 producer 把消息发送到某个 topic 时,默认是会消息发送到具体的 queue 上。

2.2.1 全局有序

举个例子,producer 发送 order id 为 1、2、3、4 的四条消息到 topicA 上,假设 topicA 的 queue 数为 3 个(queue0、queue1、queue2),那么消息的分布可能就是这种情况,id 为 1 的在 queue0,id 为 2 的在 queue1,id 为 3 的在 queue2,id 为 4 的在 queue0。同样的,consumer 消费时也是按 queue 去消费,这时候就可能出现先消费 1、4,再消费 2、3,和我们的预期不符。那么我们如何实现 1、2、3、4 的消费顺序呢?道理其实很简单,只需要把订单 topic 的 queue 数改为 1,如此一来,只要 producer 按照 1、2、3、4 的顺序去发送消息,那么 consumer 自然也就按照 1、2、3、4 的顺序去消费,这就是全局有序消息。

由于一个 topic 只有一个 queue ,即使我们有多个 producer 实例和 consumer 实例也很难提高消息吞吐量。就好比过独木桥,大家只能一个挨着一个过去,效率低下。

那么有没有吞吐量和有序之间折中的方案呢?其实是有的,就是局部有序消息。

2.2.2 局部有序

我们知道订单消息可以再细分为订单创建、订单付款、订单完成等消息,这些消息都有相同的 order id。同时,也只有按照订单创建、订单付款、订单完成的顺序去消费才符合业务逻辑。但是不同 order id 的消息是可以并行的,不会影响到业务。这时候就常见做法就是将 order id 进行处理,将 order id 相同的消息发送到 topicB 的同一个 queue,假设我们 topicB 有 2 个 queue,那么我们可以简单的对 id 取余,奇数的发往 queue0,偶数的发往 queue1,消费者按照 queue 去消费时,就能保证 queue0 里面的消息有序消费,queue1 里面的消息有序消费。

由于一个 topic 可以有多个 queue,所以在性能比全局有序高得多。假设 queue 数是 n,理论上性能就是全局有序的 n 倍,当然 consumer 也要跟着增加才行。在实际情况中,这种局部有序消息是会比全局有序消息用的更多。

2.2.3 示例代码

生产者:

public class Producer {public static void main(String[] args) throws UnsupportedEncodingException {try {// 声明并初始化一个producer// 需要一个producer group名字作为构造方法的参数,这里为ordered_producerDefaultMQProducer orderedProducer = new DefaultMQProducer("ordered_producer");// 设置NameServer地址,此处应改为实际NameServer地址,多个地址之间用;分隔//NameServer的地址必须有,但是也可以通过环境变量的方式设置,不一定非得写死在代码里orderedProducer.setNamesrvAddr("10.1.54.121:9876;10.1.54.122:9876");// 调用start()方法启动一个producer实例orderedProducer.start();// 自定义一个tag数组String[] tags = new String[]{"TagA", "TagB", "TagC", "TagD", "TagE"};// 发送10条消息到Topic为TopicTestOrdered,tag为tags数组按顺序取值,// key值为“KEY”拼接上i的值,消息内容为“Hello RocketMQ”拼接上i的值for (int i = 0; i < 10; i++) {int orderId = i % 10;Message msg =new Message("TopicTestOrdered", tags[i % tags.length], "KEY" + i,("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET));SendResult sendResult = orderedProducer.send(msg, new MessageQueueSelector() {// 选择发送消息的队列@Overridepublic MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {// arg的值其实就是orderIdInteger id = (Integer) arg;// mqs是队列集合,也就是topic所对应的所有队列int index = id % mqs.size();// 这里根据前面的id对队列集合大小求余来返回所对应的队列return mqs.get(index);}}, orderId);System.out.println(sendResult);}orderedProducer.shutdown();} catch (MQClientException e) {e.printStackTrace();} catch (RemotingException e) {e.printStackTrace();} catch (MQBrokerException e) {e.printStackTrace();} catch (InterruptedException e) {e.printStackTrace();}}
}

至于是要实现全局有序,还是局部有序,在此示例代码中,就取决于 TopicTestOrdered 这个 Topic 的队列数了。

消费者:

public class Consumer {public static void main(String[] args) throws MQClientException {//声明并初始化一个consumer//需要一个consumer group名字作为构造方法的参数,这里为concurrent_consumerDefaultMQPushConsumer consumer = new DefaultMQPushConsumer("ordered_consumer");//同样也要设置NameServer地址consumer.setNamesrvAddr("10.1.54.121:9876;10.1.54.122:9876");//这里设置的是一个consumer的消费策略//CONSUME_FROM_LAST_OFFSET 默认策略,从该队列最尾开始消费,即跳过历史消息//CONSUME_FROM_FIRST_OFFSET 从队列最开始开始消费,即历史消息(还储存在broker的)全部消费一遍//CONSUME_FROM_TIMESTAMP 从某个时间点开始消费,和setConsumeTimestamp()配合使用,默认是半个小时以前consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);//设置consumer所订阅的Topic和Tagconsumer.subscribe("TopicTestOrdered", "TagA || TagC || TagD");//设置一个Listener,主要进行消息的逻辑处理//注意这里使用的是MessageListenerOrderly这个接口consumer.registerMessageListener(new MessageListenerOrderly() {@Overridepublic ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {System.out.println(Thread.currentThread().getName() + " Receive New Messages: " + msgs);//返回消费状态//SUCCESS 消费成功//SUSPEND_CURRENT_QUEUE_A_MOMENT 消费失败,暂停当前队列的消费return ConsumeOrderlyStatus.SUCCESS;}});//调用start()方法启动consumerconsumer.start();System.out.println("Consumer Started.");}
}

2.3 延时消息

延时消息,简单来说就是当 producer 将消息发送到 broker 后,会延时一定时间后才投递给 consumer 进行消费。

RcoketMQ的延时等级为:1s,5s,10s,30s,1m,2m,3m,4m,5m,6m,7m,8m,9m,10m,20m,30m,1h,2h。level=0,表示不延时。level=1,表示 1 级延时,对应延时 1s。level=2 表示 2 级延时,对应5s,以此类推。

这种消息一般适用于消息生产和消费之间有时间窗口要求的场景。比如说我们网购时,下单之后是有一个支付时间,超过这个时间未支付,系统就应该自动关闭该笔订单。那么在订单创建的时候就会就需要发送一条延时消息(延时15分钟)后投递给 consumer,consumer 接收消息后再对订单的支付状态进行判断是否关闭订单。

设置延时非常简单,只需要在Message设置对应的延时级别即可:

Message msg = new Message("TopicTest",// topic"TagA",// tag("Hello RocketMQ " + i).getBytes(RemotingHelper.DEFAULT_CHARSET)// body);// 这里设置需要延时的等级即可msg.setDelayTimeLevel(3);SendResult sendResult = producer.send(msg);

延迟等级对应的时间:1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h,这个可以通过修改broke.conf文件来进行
配置,配置项=messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h,时间单位支持:s、m、h、d,分别表示秒、分、时、天;
不是特殊的业务场景,不建议去修改,因为源码中有些地方也用到了这些延迟等级,改了可能会影响RocketMQ的正常运行

第一步:Producer拿着topic-c去NameServer获取路由信息(能拿到对应broker的ip:port和目标queue)

第二步:Producer将消息的Topic手动替换为系统的延迟Topic(SCHEDULE_TOPIC_XXXX),根据延迟等级,放到对应的延迟队列中,并备份原来的Topic和queue

第三步:broker端接收到消息请求,将消息发送到系统队列SCHEDULE_TOPIC_XXXX中对应的延迟等级的queue中

第四步:broker会定时去扫描延迟队列中的数据,到了触发时间会将消息投递到对应的topic和queue中

2.4 事务消息

RocketMQ支持分布式最终一致性事务,我们先看发送事务消息的代码

TransactionMQProducer producer = new TransactionMQProducer("transaction-group");
producer.setNamesrvAddr("127.0.0.1:9876");
//设置事务监听器
producer.setTransactionListener(new TransactionListener() {/*** 执行本地事务,如果执行成功返回COMMIT_MESSAGE* broker会将消息发送出去,* 本地实物执行失败的话,broker会将消息删除* @param message* @param o* @return*/@Overridepublic LocalTransactionState executeLocalTransaction(Message message, Object o) {System.out.println("------------执行本地事务-------------");System.out.println("message:"+new String(message.getBody()));System.out.println("messageId:"+message.getTransactionId());try {//执行本地事务代码System.out.println("try code exec");} catch (Exception e) {//回滚事务return LocalTransactionState.ROLLBACK_MESSAGE;}//提交事务return LocalTransactionState.ROLLBACK_MESSAGE;}/*** broker长时间没收到确认信息* 会回调接口来查看本地事务的执行情况* @param messageExt* @return*/@Overridepublic LocalTransactionState checkLocalTransaction(MessageExt messageExt) {//broker长时间没收到本地事务返回的状态,会主动回调询问事务状态 System.out.println("--------------------Broker执行回调检查本地事务状态-----------------------");System.out.println("message:"+new String(messageExt.getBody()));System.out.println("messageId:"+new String(messageExt.getTransactionId()));//回滚信息//return LocalTransactionState.ROLLBACK_MESSAGE;//等一会//return LocalTransactionState.UNKNOW;//事务执行成功return LocalTransactionState.COMMIT_MESSAGE;}
});
//启动producer
producer.start();
//发送消息(半消息)
TransactionSendResult sendResult = producer.sendMessageInTransaction(new Message("transaction-topic", "测试!这是事务消息".getBytes()), null);System.out.println(sendResult);
//这里可能有异步回调 所以这里睡15s
TimeUnit.SECONDS.sleep(15);
producer.shutdown();

这里很容易发现,这里发送消息使用的是sendMessageInTransaction(),这是专门用来发送事务消息的,producer还注册了一个事务监听器。我们说一下事务消息发送的逻辑,大家有个概念,后面笔者会写一篇深入讲解事务消息的文章。

第一步:设置监听器以后,调用事务消息发送的方法,并不会将消息投递到消息真正的topic中,和延迟消息一样,会发送到系统默认的半消息Topic(RMQ_SYS_TRANS_HALF_TOPIC)中。

第二步:半消息发送完以后,会回调到executeLocalTransaction()这个方法中,我们执行本地事务,

  • 本地事务成功:返回LocalTransactionState.COMMIT_MESSAGE,然后将消息从半消息队列中取出来,放到消息本身的Topic队列中。
  • 本地事务失败或异常:返回LocalTransactionState.ROLLBACK_MESSAGE,Broker收到该状态,会将消息删除掉

第三步: 如果一分钟内,Broker还未收到本地事务返回的状态,Broker开始发起询问请求,也就是回调到checkLocalTransaction(),根据方法中判断本地事务是否执行成功。

  • 事务失败:返回LocalTransactionState.ROLLBACK_MESSAGE,Broker收到该状态后,会将半消息删除掉
  • 不确定:返回LocalTransactionState.UNKNOW,Broker收到该状态后,Broker会默认6s询问一次,最多询问15次
  • 事务成功:返回LocalTransactionState.COMMIT_MESSAGE,Broker收到该状态会把消息从半消息队列移到消息本身的Topic的队列

3. 消息过滤

消息由Topic分组以后,还可以在Topic的基础上再分,假如订单服务,下单和退款都往一个Topic下发消息,Consumer监听了该Topic,收到消息以后,分不清哪条消息是下单的,哪条消息是退款的。当然我们可以在消息Body里面添加参数来标识消息是订单还是退款,这样我们在Consumer收到消息以后,需要去判断消息体的参数,才能知道具体消息该走哪套处理逻辑,RocketMQ对消息做了一个过滤的解决方案

3.1 Tag

我们先看一段代码

final DefaultMQProducer mqProducer = new DefaultMQProducer("test-group");
mqProducer.setNamesrvAddr("127.0.0.1:9876");
//启动producer
mqProducer.start();
//添加过滤条件
Message syncMessage = new Message("testMsg","tag-a", "sync: hello world".getBytes());
//同步发送
SendResult result = mqProducer.send(syncMessage);
System.out.println("同步发送成功:"+result);

这条消息会在Message的properties属性里面添加一个TAGS=tag-a

这样消费组在监听的时候,也只需要加过滤条件就能取到哪些想要的消息。如以下Consumer代码

DefaultMQPushConsumer mqPushConsumer = new DefaultMQPushConsumer("oneGroup");
mqPushConsumer.setNamesrvAddr("127.0.0.1:9876");
//并发消费
mqPushConsumer.registerMessageListener(new MessageListenerConcurrently() {@Overridepublic ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {for (MessageExt msg : msgs) {System.out.println(new String(msg.getBody()));}return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;}
});
//监听所有消息使用符号*  多个条件使用|| eg: tag-a || tag-b
mqPushConsumer.subscribe("testMsg","tag-a");
mqPushConsumer.start();
TimeUnit.SECONDS.sleep(5);
mqPushConsumer.shutdown();

这样,消费组只会收到testMsg下所有带有tag-a标签的消息。笔者画一个图方便读者理解

3.2 SQL过滤

RocketMQ除了支持Tag来过滤消息,还支持更复杂的过滤方式 ,不过这样过滤方式默认是未开启的,需要在broker.conf文件中添加该属性

enablePropertyFilter=true

先看Producer代码

final DefaultMQProducer mqProducer = new DefaultMQProducer("test-group");
mqProducer.setNamesrvAddr("127.0.0.1:9876");
producer.start();
//发送50条消息,每条消息设置一个num属性,consumer可以根据这个属性来进行过滤
for (int i = 1; i <= 50; i++) {Message message = new Message("testMsg","key"+i,("batch message no:"+i).getBytes());message.putUserProperty("num",String.valueOf(i));producer.send(message);
}

Producer发向testMsg的Topic中发送50条消息,消息内容会把当前是第几条消息标识出来,并未每条消息添加了一个自定义属性num,num的值就是Consumer来过滤的条件值。

支持过滤的语法:

  1. 数字比较, 像 >>=<<=BETWEEN=;
  2. 字符比较, 像 =<>IN;
  3. IS NULL 或者 IS NOT NULL;
  4. 逻辑运算ANDORNOT;

Consumer端代码

DefaultMQPushConsumer mqPushConsumer = new DefaultMQPushConsumer("oneGroup");
mqPushConsumer.setNamesrvAddr("127.0.0.1:9876");
//过滤器
MessageSelector selector = MessageSelector.bySql("num > 16 and num < 30");
consumer.subscribe("testMsg",selector);
//注册消息监听器
consumer.registerMessageListener(new MessageListenerConcurrently() {@Overridepublic ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {for (MessageExt msg : msgs) {System.out.println("customer received: " +new String(msg.getBody()));}return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;}
});
consumer.start();
TimeUnit.SECONDS.sleep(10);

Consumer端监听了testMsg这个Topic,并添加了一个sql表达式的消息选择器,条件为num大于 16 小于 30,有了这个选择器,Consumer就只能收到num 大于16和小于30之间的消息。

4. 消息重投机制

只有同步发送才会进行重投机制,并且还要打开下面这个属性才行,默认失败重新投递2次

//打开失败重新投递
mqProducer.setRetryAnotherBrokerWhenNotStoreOK(true);

4.1 offset

消息发送以后都是存储在Message Queue,Message Queue是一个无限长的数组,offset就是它的下标,一条消息存到Message Queue中,该Message Queue的offset就要累加1,消费时就是通过offset来快速定位到具体的消息。

对应的控制界面上的数据

Rocket MQ(二)消息详解相关推荐

  1. [深入浅出Cocoa]之消息(二)-详解动态方法决议(Dynamic Method Resolution)

    [深入浅出Cocoa]之消息(二)-详解动态方法决议(Dynamic Method Resolution) 罗朝辉 (http://www.cnblogs.com/kesalin/) 本文遵循&quo ...

  2. php获取微信聊天图片,vbot微信聊天机器人微信聊天消息详解(4):图片消息

    <vbot微信聊天机器人微信聊天消息详解(4):图片消息>要点: 本文介绍了vbot微信聊天机器人微信聊天消息详解(4):图片消息,希望对您有用.如果有疑问,可以联系我们. 图片是资源文件 ...

  3. 微信发送视频消息php,vbot微信聊天机器人微信聊天消息详解(5):视频消息

    <vbot微信聊天机器人微信聊天消息详解(5):视频消息>要点: 本文介绍了vbot微信聊天机器人微信聊天消息详解(5):视频消息,希望对您有用.如果有疑问,可以联系我们. 视频消息是资源 ...

  4. ViewPager 详解(二)---详解四大函数

    前言:上篇中我们讲解了如何快速实现了一个滑动页面,但问题在于,PageAdapter必须要重写的四个函数,它们都各有什么意义,在上节的函数内部为什么要这么实现,下面我们就结合Android的API说明 ...

  5. RxJS 系列之二 - Observable 详解

    查看新版教程,请访问前端修仙之路 RxJS 系列目录 RxJS 系列之一 - Functional Programming 简介 RxJS 系列之二 - Observable 详解 (本文) RxJS ...

  6. CORS跨域资源共享(二):详解Spring MVC对CORS支持的相关类和API【享学Spring MVC】

    每篇一句 重构一时爽,一直重构一直爽.但出了问题火葬场 前言 上篇文章通过我模拟的跨域请求实例和结果分析,相信小伙伴们都已经80%的掌握了CORS到底是怎么一回事以及如何使用它.由于Java语言中的w ...

  7. 十二、详解计算网络中的流量控制和差错控制、HDLC

    十二.详解计算网络中的流量控制和差错控制 提示:这里可以添加系列文章的所有文章的目录,目录需要自己手动添加 例如:第一章 Python 机器学习入门之pandas的使用 提示:写完文章后,目录可以自动 ...

  8. DFT - 对芯片测试的理解(二) 详解

    DFT - 对芯片测试的理解(二) 详解 参考: https://www.docin.com/p-2014360649.html The basic view of DFT scan chain 这图 ...

  9. php 微信 群聊,vbot微信机器人微信聊天消息详解(18):群组变动

    <vbot微信机器人微信聊天消息详解(18):群组变动>要点: 本文介绍了vbot微信机器人微信聊天消息详解(18):群组变动,希望对您有用.如果有疑问,可以联系我们. 当微信群新增了成员 ...

  10. 32.深度解密三十二:详解影响QQ群整体排名的那些秘密

    网络营销推广技术.技巧深度解密(三十二)指南: 1.本文档适合零基础以及互联网营销推广人员,主要讲解营销QQ群排名的一些问题. 2.原创版权文档,任何抄袭或者全部.部分模仿都是侵权行为. 3.敬畏法律 ...

最新文章

  1. 十二、经典问题解析一
  2. 99.99%准确率!AI数据训练工具No.1来自中国
  3. 一步带你了解java程序逻辑控制
  4. 【C++基础 09】避免对象的拷贝
  5. 避免出现anr的方法_ANR原因及解决方法
  6. 每日一句(2014-8-26)
  7. 美的集团2022全球招聘正式启动
  8. 领域驱动 开源项目_在开源领域建立职业的建议
  9. Linq之动态条件(1)
  10. mysql 1045 拒绝远程链接
  11. SPSS软件安装与常见入门问题
  12. 添加过滤器后登录界面无法登录的bug--已解决
  13. IDOC的处理函数IDOC_INPUT_ORDERS的增强点的分析
  14. 狗和猫有相同的情绪反应吗?
  15. 带你撸一台免费云服务器
  16. 从零开始学习区块链技术
  17. Qt编写安防视频监控系统46-视频存储
  18. 永恒之蓝病毒事件所引发的运维安全行业新思考
  19. 电脑开机显示“被调用的对象已与其客户端断开连接”解决方法
  20. 微信小程序学习day01-WXML 模板语法

热门文章

  1. poi导出兼容xls和xlsx时报错XmlValueDisconnectedException
  2. 生态网络连通性定义_生态网络结构与格局演变
  3. [分子动力学模拟资料]几种常用力场参数网站及LJ参数计算
  4. HTML引入CSS和JavaScript的方式
  5. 如何下载期刊的封面和目录页?
  6. 【苹果家庭推群发】更新3.2更换证书连接到Apple Push Server
  7. Redis常用配置详解
  8. 【源码】QT获取QQ昵称
  9. Windows11任务栏时钟的秒钟显示
  10. 市级医药集中采购系统(一)