范文健康探索娱乐情感热点
投稿投诉
热点动态
科技财经
情感日志
励志美文
娱乐时尚
游戏搞笑
探索旅游
历史星座
健康养生
美丽育儿
范文作文
教案论文
国学影视

教你用纯Java实现一个即时通讯系统(附源码)

  项目背景
  和各位读者大致介绍下具体场景,线上的小程序中开放一些语音麦克风的房间,让用户进入房间之后可以互相通过语音聊天的方式进行互动。
  这里分享一下相关的技术设计方案。这款系统的核心点设计在于如何能让一个用户发出的语音通知到其他用户上边。语音数据在客户端同事的处理下最终变成了io数据流请求到了后端,后端只需要将这些数据流传达给各个不同的终端即可达到广播通知的效果。
  单机版架构
  最初期上线的时候,为了赶速度,快速试错,所以简单地采用了单机版架构去设计。结合技术栈为 SpringBoot,WebSocket,MySQL技术。
  线上一间语音房间的同时在线人数并不会特别多,大概在15-50人的区间段内,系统核心代码是通过SpringBoot内部的WebSocket技术去进行数据的主动推送。
  设计思路
  整体的设计图比较简单,基本就是一台服务器存储WebSocket连接,如下图所示:
  用户进行WebSocket初始化连接的时候需要一个连接分配和存储的过程:
  早期的存储是存放在了服务器本地的一个Map集合中。
  当WebSocket进行连接的时候就会往内存中写入一条数据信息,当链接断开的时候,就将内存中的数据移除。然后进行语音广播的时候需要结合WebSocket内部的广播发送功能进行通知
  看似设计比较简单,但是在后期业务变得庞大的时候出现了瓶颈。因为随着参加语音活动用户的增加,越来越多的WebSocketSession对象需要被存储到内存当中,这种有状态性的存储对于单机扩容不灵活。
  设计缺陷
  1.假设原先的服务器扩容到了A,B两台机器,A用户在A机器上边建立了WebSocketSession,B用户在B机器上边建立的WebSocketSession连接。此时如果A想要和B进行对话发送,需要先查找到具体WebSocketSession存放在哪台机器上边。
  2.当用户出现了网络异常,临时断开连接进行重连的时候,也可能会出现1所说的问题。
  集群架构
  设计思路
  一旦出现需要发送语音通知的时候,发送一条广播的mq消息,每个机器都接收到消息之后,触发自己的广播操作即可。
  RocketMq的接入系统设计里面mq采用的是广播模式,这和我们通常使用的集群模式有一定的区别。
  消息队列RocketMQ版是基于发布或订阅模型的消息系统。消费者,即消息的订阅方订阅关注的Topic,以获取并消费消息。由于消费者应用一般是分布式系统,以集群方式部署,因此消息队列RocketMQ版约定以下概念:
  集群:使用相同Group ID的消费者属于同一个集群。同一个集群下的消费者消费逻辑必须完全一致(包括Tag的使用)。
  集群消费:当使用集群消费模式时,消息队列RocketMQ版认为任意一条消息只需要被集群内的任意一个消费者处理即可。
  广播消费:当使用广播消费模式时,消息队列RocketMQ版会将每条消息推送给集群内所有注册过的消费者,保证消息至少被每个消费者消费一次。
  集群消费模式适用场景 适用于消费端集群化部署,每条消息只需要被处理一次的场景。此外,由于消费进度在服务端维护,可靠性更高。具体消费示例如下图所示。
  注意事项
  集群消费模式下,每一条消息都只会被分发到一台机器上处理。如果需要被集群下的每一台机器都处理,请使用广播模式。
  集群消费模式下,不保证每一次失败重投的消息路由到同一台机器上。
  广播消费模式适用场景 适用于消费端集群化部署,每条消息需要被集群下的每个消费者处理的场景。具体消费示例如下图所示。
  注意事项
  广播消费模式下不支持顺序消息。
  广播消费模式下不支持重置消费位点。
  每条消息都需要被相同订阅逻辑的多台机器处理。
  消费进度在客户端维护,出现重复消费的概率稍大于集群模式。
  广播模式下,消息队列RocketMQ版保证每条消息至少被每台客户端消费一次,但是并不会重投消费失败的消息,因此业务方需要关注消费失败的情况。
  广播模式下,客户端每一次重启都会从最新消息消费。客户端在被停止期间发送至服务端的消息将会被自动跳过,请谨慎选择。
  广播模式下,每条消息都会被大量的客户端重复处理,因此推荐尽可能使用集群模式。
  广播模式下服务端不维护消费进度,所以消息队列RocketMQ版控制台不支持消息堆积查询、消息堆积报警和订阅关系查询功能。
  这里面的应用场景需要对集群内部对每个消费者都对服务器内存中的socket连接进行session是否存在对判断,因此需要采用mq的广播模式。
  关于mq部分的接入代码
  Consumer模块的配置:
  package org.idea.web.socket.config;
  import org.springframework.boot.context.properties.ConfigurationProperties;
  /**
  * @Author linhao
  * @Date created in 10:30 上午 2021/5/10
  */
  @ConfigurationProperties(prefix = "rocketmq.consumer")
  public class MqConsumerConfig {
  private boolean isOn;
  private String groupName;
  private String nameSrvAddr;
  private String topics;
  private Integer consumeThreadMin;
  private Integer consumeThreadMax;
  private Integer consumeMessageBatchMaxSize;
  /**
  getter 和 setter部分省略
  **/
  }
  Producer模块的配置展示:
  package org.idea.web.socket.config;
  import org.springframework.boot.context.properties.ConfigurationProperties;
  /**
  * @Author linhao
  * @Date created in 10:26 上午 2021/5/10
  */
  @ConfigurationProperties(prefix = "rocketmq.producer")
  public class MqProducerConfig {
  private boolean isOn;
  private String groupName;
  private String nameSrvAddr;
  private Integer maxMessageSize;
  private Integer sendMsgTimeout;
  private Integer retryTimesWhenSendFailed;
  /**
  getter 和 setter部分省略
  **/
  }
  RocketMq内部的消费端Bean配置
  package org.idea.web.socket.mq;
  import lombok.extern.slf4j.Slf4j;
  import org.apache.rocketmq.client.consumer.DefaultMQPushConsumer;
  import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
  import org.apache.rocketmq.client.exception.MQClientException;
  import org.apache.rocketmq.common.consumer.ConsumeFromWhere;
  import org.apache.rocketmq.common.protocol.heartbeat.MessageModel;
  import org.idea.web.socket.config.MqConsumerConfig;
  import org.idea.web.socket.config.MqProducerConfig;
  import org.springframework.boot.autoconfigure.AutoConfigureAfter;
  import org.springframework.boot.autoconfigure.AutoConfigureBefore;
  import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
  import org.springframework.boot.context.properties.EnableConfigurationProperties;
  import org.springframework.context.annotation.Bean;
  import org.springframework.context.annotation.Configuration;
  import javax.annotation.Resource;
  /**
  * @Author linhao
  * @Date created in 10:34 上午 2021/5/10
  */
  @Configuration
  @Slf4j
  @EnableConfigurationProperties({MqConsumerConfig.class})
  public class MqConsumerAutoConfig {
  @Resource
  private MqConsumerConfig mqConsumerConfig;
  @Resource
  //这个接口需要手动实现顺序消费的逻辑 每次获取到消息队列的第一条数据
  private MessageListenerHandler messageListenerConcurrently;
  @Bean
  @ConditionalOnMissingBean
  public DefaultMQPushConsumer defaultMQPushConsumer() {
  DefaultMQPushConsumer consumer = new DefaultMQPushConsumer();
  consumer.setNamesrvAddr(mqConsumerConfig.getNameSrvAddr());
  consumer.setConsumerGroup(mqConsumerConfig.getGroupName());
  consumer.setConsumeThreadMin(mqConsumerConfig.getConsumeThreadMin());
  consumer.setConsumeThreadMax(mqConsumerConfig.getConsumeThreadMax());
  consumer.registerMessageListener(messageListenerConcurrently);
  consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
  //消费模型是什么?
  consumer.setMessageModel(MessageModel.BROADCASTING);
  //默认一次拉取一条消费
  consumer.setConsumeMessageBatchMaxSize(mqConsumerConfig.getConsumeMessageBatchMaxSize());
  //*表示订阅所有的tag
  try {
  consumer.subscribe(mqConsumerConfig.getTopics(), "*");
  consumer.start();
  log.info("【 MqConsumerAutoConfig 】mq consumer is started!");
  } catch (Exception e) {
  log.error("mq start fail,e is ", e);
  }
  return consumer;
  }
  }
  RocketMq的服务生产者Bean配置
  package org.idea.web.socket.mq;
  import lombok.extern.slf4j.Slf4j;
  import org.apache.rocketmq.client.producer.DefaultMQProducer;
  import org.idea.web.socket.config.MqProducerConfig;
  import org.springframework.boot.autoconfigure.AutoConfigureAfter;
  import org.springframework.boot.autoconfigure.AutoConfigureBefore;
  import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
  import org.springframework.boot.context.properties.EnableConfigurationProperties;
  import org.springframework.context.annotation.Bean;
  import org.springframework.context.annotation.Configuration;
  import javax.annotation.Resource;
  /**
  * @Author linhao
  * @Date created in 11:05 上午 2021/5/10
  */
  @Configuration
  @Slf4j
  @EnableConfigurationProperties({MqProducerConfig.class})
  public class MqProducerAutoConfig {
  @Resource
  private MqProducerConfig mqProducerConfig;
  @Bean
  @ConditionalOnMissingBean
  //意味着DefaultMQProducer的配置可以被覆盖
  public DefaultMQProducer defaultMQProducer() {
  DefaultMQProducer producer = new DefaultMQProducer(mqProducerConfig.getGroupName());
  producer.setNamesrvAddr(mqProducerConfig.getNameSrvAddr());
  //没有则自动创建topic的key
  // producer.setCreateTopicKey("AUTO_CREATE_TOPIC_KEY");
  producer.setMaxMessageSize(mqProducerConfig.getMaxMessageSize());
  producer.setSendMsgTimeout(mqProducerConfig.getSendMsgTimeout());
  producer.setRetryTimesWhenSendFailed(mqProducerConfig.getRetryTimesWhenSendFailed());
  try {
  producer.start();
  log.info("【 MqProducerAutoConfig 】mq producer is started!");
  } catch (Exception e) {
  log.error("[MqProducerAutoConfig] start fail, e is ", e);
  }
  return producer;
  }
  }
  然后是对RocketMq内部发送消息事件的一层函数封装
  package org.idea.web.socket.mq;
  import com.alibaba.fastjson.JSON;
  import lombok.extern.slf4j.Slf4j;
  import org.apache.commons.lang3.StringUtils;
  import org.apache.rocketmq.client.producer.DefaultMQProducer;
  import org.apache.rocketmq.client.producer.SendResult;
  import org.apache.rocketmq.common.message.Message;
  import org.apache.rocketmq.remoting.common.RemotingHelper;
  import org.idea.web.socket.config.MqProducerConfig;
  import org.idea.web.socket.dto.BroadcastMqDTO;
  import org.springframework.stereotype.Component;
  import javax.annotation.Resource;
  import java.io.UnsupportedEncodingException;
  /**
  * 消息广播发送端
  *
  * @Author linhao
  * @Date created in 10:43 下午 2021/5/9
  */
  @Component
  @Slf4j
  public class BroadcastMqProducer {
  @Resource
  private DefaultMQProducer defaultMQProducer;
  @Resource
  private MqProducerConfig mqProducerConfig;
  private static String TOPIC = "ws-topic";
  private static String TAGS = "ws-tag";
  public static Integer ALL_USER_RECEIVE_TYPE = 1;
  public static Integer ONE_USER_RECEIVE_TYPE = 2;
  /**
  * 点对点之间的消息发送
  *
  * @param destSessionKey
  * @param msg
  * @return
  */
  public SendResult sendWebSocketToUser(String destSessionKey,String msg) {
  if (StringUtils.isEmpty(msg)) {
  log.error("[sendWebSocketToUser] msg can not be null!");
  return null;
  }
  Message message = null;
  SendResult sendResult = null;
  try {
  BroadcastMqDTO broadcastMqDTO = new BroadcastMqDTO();
  broadcastMqDTO.setEventType(ONE_USER_RECEIVE_TYPE);
  broadcastMqDTO.setMessage(msg);
  broadcastMqDTO.setSessionKey(destSessionKey);
  message = new Message(TOPIC, TAGS, (JSON.toJSONString(broadcastMqDTO)).getBytes(RemotingHelper.DEFAULT_CHARSET));
  sendResult = defaultMQProducer.send(message);
  } catch (Exception e) {
  log.error("[sendWebSocketBroadcastMsg] e is ", e);
  }
  return sendResult;
  }
  /**
  * 广播消息发送
  *
  * @param msg
  * @return
  */
  public SendResult sendWebSocketBroadcastMsg(String msg) {
  if (StringUtils.isEmpty(msg)) {
  log.error("[sendWebSocketBroadcastMsg] msg can not be null!");
  return null;
  }
  Message message = null;
  SendResult sendResult = null;
  try {
  BroadcastMqDTO broadcastMqDTO = new BroadcastMqDTO();
  broadcastMqDTO.setEventType(ALL_USER_RECEIVE_TYPE);
  broadcastMqDTO.setMessage(msg);
  message = new Message(TOPIC, TAGS, (JSON.toJSONString(broadcastMqDTO)).getBytes(RemotingHelper.DEFAULT_CHARSET));
  sendResult = defaultMQProducer.send(message);
  } catch (Exception e) {
  log.error("[sendWebSocketBroadcastMsg] e is ", e);
  }
  return sendResult;
  }
  }
  对消息的订阅模块实现代码如下:
  package org.idea.web.socket.mq;
  import com.alibaba.fastjson.JSON;
  import com.oracle.tools.packager.Log;
  import lombok.extern.slf4j.Slf4j;
  import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
  import org.apache.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
  import org.apache.rocketmq.client.consumer.listener.MessageListenerConcurrently;
  import org.apache.rocketmq.common.message.MessageExt;
  import org.idea.web.socket.dto.BroadcastMqDTO;
  import org.idea.web.socket.manager.SocketManager;
  import org.springframework.messaging.simp.SimpMessagingTemplate;
  import org.springframework.stereotype.Component;
  import org.springframework.util.CollectionUtils;
  import org.springframework.web.socket.WebSocketSession;
  import javax.annotation.Resource;
  import java.util.List;
  import static org.idea.web.socket.mq.BroadcastMqProducer.ALL_USER_RECEIVE_TYPE;
  import static org.idea.web.socket.mq.BroadcastMqProducer.ONE_USER_RECEIVE_TYPE;
  /**
  * @Author linhao
  * @Date created in 10:59 上午 2021/5/10
  */
  @Component
  @Slf4j
  public class MessageListenerHandler implements MessageListenerConcurrently {
  @Resource
  private SocketManager socketManager;
  @Resource
  private SimpMessagingTemplate template;
  @Override
  public ConsumeConcurrentlyStatus consumeMessage(List list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
  if (CollectionUtils.isEmpty(list)) {
  Log.info("receive empty msg");
  return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
  }
  MessageExt messageExt = list.get(0);
  byte[] bytes = messageExt.getBody();
  String json = new String(bytes);
  BroadcastMqDTO broadcastMqDTO = JSON.parseObject(json, BroadcastMqDTO.class);
  log.info("[MessageListenerHandler] broadcastMqDTO is " + broadcastMqDTO);
  if (ALL_USER_RECEIVE_TYPE.equals(broadcastMqDTO.getEventType())) {
  log.info("[consumeMessage] 广播发送消息:触发----》消息内容为:" + broadcastMqDTO);
  template.convertAndSend("/topic/sendTopic", broadcastMqDTO);
  } else if (ONE_USER_RECEIVE_TYPE.equals(broadcastMqDTO.getEventType())) {
  String sessionKey = broadcastMqDTO.getSessionKey();
  WebSocketSession webSocketSession = socketManager.get(sessionKey);
  if (webSocketSession != null) {
  template.convertAndSendToUser(sessionKey, "/queue/sendUser", broadcastMqDTO.getMessage());
  log.info("[consumeMessage] 点对点发送消息;触发----》消息内容为:" + broadcastMqDTO);
  }
  }
  return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
  }
  }
  整体设计结构如下图:
  于是按照这个结构进行了一版本的紧急开发迭代,原先的单台服务器扩展为了服务集群。
  业务拓展后续产品经理提出一个需求,要求支持在同一间房内的两个用户之间发送悄悄话功能。这就需要我们进行一个点对点之间传输通讯的功能了。因此需要在mq通知到每台机器的时候加一个本地Session遍历的逻辑,如果当前机器存有用户token对应的session变量,那么就单独针对那个Session进行WebSocket的发送通知。
  设计弊端一旦某台机器出现了异常崩溃,那么就意味着这台机器上的所有语音连接可能会出现中断情况。目前这一块的问题也在考虑解决,计划是将WebSocketSession存入到分布式缓存的redis中保证数据可靠存储,但是在后续尝试的时候发现WebSocketSession对象没有实现序列化接口,在存储到Redis的时候会出现异常。目前这个问题还在寻找解决思路中,不知道各位读者朋友们有什么好的思路。

人老了就得少吃肉?人到老年应该吃什么肉?4个问题,不妨了解下因为吃素,陈女士把自己吃进了医院。陈女士(化名)今年47岁,儿女都在外奋斗,自己一个人在老家生活。平时饮食上基本都以素菜为主,因为她是吃素更养生观点的支持者,所以会刻意减少肉类等荤这4大行为或能延缓衰老,你知道多少?建议学习一下爱美是每个人的天性,大家都希望自己可以永远年轻漂亮,特别是女性朋友。但是随着年龄的增长我们身体的各项机能会迅速下降,皱纹也越来越多,这让很多人都感到非常苦恼,于是会通过各种办法来延RedVelvet新闻211130YERI圣诞晚会气氛满满的颜值画报BeautyLife杂志Beauty公开了RedVelvet忙内YERI魅力十足的画报。时隔3个月再次拍摄画报,以激动的心情出现在摄影棚的YERI,配合独自派对的概念,完美演绎了充开眼角,怎么开?都说眼睛是心灵的窗户,很多求美者在考虑双眼皮手术的同时,也在考虑自己是否需要进行开眼角手术。开眼角手术一般分为开内眼角和开外眼角手术,当然两种手术的适应症也是不同的。内眦赘皮明显是做一次拉皮手术会损失20的表情?听医生说真相相信大家都听过这样一个传言,做一次拉皮手术就会损失20的表情。这也让很多求美者对拉皮手术望而却步,害怕做完手术后面部会变僵硬,不自然。那么,做拉皮手术,真的会造成表情的损失吗?面神托蒂回忆告别战全场哭成一片拿到我球的哥们拒绝10万欧元买卖直播吧11月30日讯球员生涯结束时,托蒂曾形容那种感觉是掉入一个大洞。近日,他的自传角斗士英文版出版,他在书里回忆了自己2017年的最后一场职业比赛。FootballItalia网还珠格格人到三十才明白,为什么尔康选择紫薇而不是晴儿小时候看还珠格格,纯粹只是孩子角度,喜欢热热闹闹与搞笑。那时源于天性会偏爱小燕子,后来嫌小燕子咋呼,觉得还是紫薇温柔文静。等第二部剧播出,晴儿成了毫无争议的心中第一。可能是因为第一因张雪迎与秦俊杰分手,搭档赵丽颖引王思聪不满,杨紫如今怎样了因张雪迎与秦俊杰分手,搭档赵丽颖引王思聪不满,杨紫如今怎样了杨紫红得太难,疑似有第三者插足与男友分手?搭档赵丽颖因番位之争被王思聪怒怼?如今青簪行处境困难,杨紫今年难有大爆剧?杨紫武大郎墓葬出土,打开棺椁一看,原来水浒传中隐瞒了他的真相水浒传,自成书后便一直被人们所津津乐道。最早上映的是由山东卫视拍摄的电视剧版本。自此以后以水浒传作为原型的电视剧电影动画手游等多的不计其数,使水浒传一直没有远离人们的视线。各种版本AIC国际赛开打,梦泪时隔五年掏出韩信都输,两连败的AG何去何从kpl季后赛本周开打,与此同时王者荣耀国际邀请赛(简称aic)已经开赛。中国赛区派出了ag超玩会二队来去试水国际赛,开局ag超玩会20轻取日本战队,让大家看到梦泪夺冠希望。谁知道接3块钱,带你坐船游遍杭州前些天瓜瓜和大家分享了西湖最新极具性价比的游船玩法(详戳6块钱,包船游西湖),说到水上游杭州高性价比,那必然少不了杭州的水上巴士呀。水上巴士我们在之前杭州相关的文章中提到过很多次,
元旦吃什么?分享10道家常菜,做法简单又好吃,家人喜欢时间真快,明天就是元旦了。作为2022年的起点,也是阳历新年的开始,这一天大家都有什么新年愿望呢?我的愿望之一就是希望大家新的一年万事如意!祝大家新年快乐呀新年伊始,家人团圆,饭饭做法简单的几道家常美食,自己在家就能做,给家人做一道吧番茄海带虾仁汤食材番茄,虾仁,海带结,葱姜蒜,盐鸡精,生抽,芝麻油。做法第一步番茄切小块,葱姜蒜切碎,热锅入油,放入葱姜蒜炒香。第二步加入番茄丁炒软,加一碗清水煮沸,加入海带结,虾5岁娃不会提裤子,妈妈与老师互怼,3项能力孩子入园前要掌握大家好,我是七悦妈一到孩子上幼儿园很多妈妈都发愁,害怕孩子在幼儿园得不到最好的关注,孩子受委屈。在家的小皇帝过惯了衣来伸手饭来张口的日子,突然到了幼儿园什么事都要自己来可怎么办?这王者荣耀不会飞雷神?没关系!这套出装,新手镜也能秀到飞起文丹青解说全网原创谢绝转载镜的KPL限定皮肤,匿光小队首款出场的皮肤,匿光追影者终于上架了!之前预定过的玩家,只需要补足412点券,即可拥有这一款限定皮肤。不得不说,这个价值能得到国产独立游戏琉隐抢先体验版上线Steam!解谜剧情惊喜重重12月31日,国产独立游戏琉隐在Steam上线了抢先体验版,售价为48,目前首周还有20的折扣,仅售38。制作组kingna工作室在商店界面中,直白表明了游戏不适合所有玩家这个观点王者荣耀最冷门的召唤师技能,为何终结已经无人问津?在干扰和疾跑技能加强后,终结技能无疑是成为王者荣耀中最鸡肋的召唤师技能,而且没有之一。只能说,现在的游戏内部越来越卷了,召唤师技能的竞争相当激烈。在王者荣耀刚上线的前两年,终结技能幻塔萌新体力怎么分配新手体力安排攻略幻塔萌新合理分配体力很重要,建议优先打星界探索这个副本,星门是可以掉落武器升级材料的一直都会是刚需材料,然后再是联合作战次元历练源能信标及时空乱境等。幻塔体力规划解析一星界探索星门曾夺走过万千海盗们的生命,海盗游戏ATLAS的坏血病太可怕为了能复现最真实的海盗战争,Steam海盗战争游戏ATLAS特地将游戏背景还原到了大航海时代,并且开发团队在设计这款游戏时翻阅了许多的古籍和资料,这让ATLAS在最大程度上的变得真这就是合作的魅力,在方舟生存进化里猴子抱起团来也很厉害在Steam生存沙盒游戏方舟生存进化的世界里,玩家们能遇见许多团队意识特强的生物,就比如说美颌龙,这种恐龙就有着超强的团队意识,当美颌龙发现猎物时,它就会以呼朋唤友的形式召唤出大量王者荣耀鲁班七号存枪王者小知识玩鲁班七号一定要知道存空气枪。什么叫存空气枪?存空气枪就是在没人的时候,对着空气平A四下,一二三四。然后头上会有一个标志,说明接下来的普攻会变为扫射。然后被动存着。这个被文明与征服爆头流阵容搭配方法教学在文明与征服这个游戏里面的阵容搭配是极其重要的,玩家可以通过合理的搭配获取更加强大的战力,但是很多的玩家也是最不了解这个的,赶快来看看搭配机制吧。文明与征服爆头流阵容搭配方法教学1