訊息中介軟體之ActiveMQ

隱風發表於2022-02-21

一、為什麼需要MQ?

主要原因是由於在高併發環境下,由於來不及同步處理,請求往往會發生堵塞,比如說,大量的insert,update之類的請求同時到達MySQL,直接導致無數的行鎖表鎖,甚至最後請求會堆積過多,從而觸發too many connections錯誤。通過使用訊息佇列,我們可以非同步處理請求,從而緩解系統的壓力。

RPC和訊息中介軟體的不同很大程度上就是“依賴性”和“同步性”。RPC方式是典型的同步方式,讓遠端呼叫像本地呼叫。訊息中介軟體方式屬於非同步方式。訊息佇列是系統級、模組級的通訊。RPC是物件級、函式級通訊。

訊息中介軟體常常用於:非同步處理、應用解耦、流量削峰、日誌處理、訊息通訊

1.MQ對比與選型

  • 中小型專案用於解耦和非同步操作,可考慮ActiveMQ,簡單易用,對佇列數較多的情況支援不好。
  • RabbitMQ,erlang開發,效能較穩定,社群活躍度高,但是不利於做二次開發和維護。ActiveMQ和RabbitMQ都適用於中小型公司,技術挑戰不是特別高。
  • 大公司,基礎架構研發實力較強,用RocketMQ不錯,支援海量訊息,但並沒有實現JMS規範,使用起來很簡單。
  • 大資料領域、日誌採集等場景,Kafka是標準,其社群活躍度也很高。

Apache ActiveMQ下載:http://activemq.apache.org/download-archives.html
執行後在瀏覽器中訪問http://127.0.0.1:8161/admin,出現管理介面時,可採用如下使用者名稱和密碼進行登入:admin/admin。

2.JMS規範:

JMS即Java訊息服務(Java Message Service)應用程式介面是一個Java平臺中關於面向訊息中介軟體(MOM)的API,用於在兩個應用程式之間,或分散式系統中傳送訊息,進行非同步通訊。Java訊息服務是一個與具體平臺無關的API,絕大多數MOM提供商都對JMS提供支援。

JMS是一種與廠商無關的 API,用來訪問訊息收發系統訊息。它類似於JDBC:這裡,JDBC 是可以用來訪問許多不同關聯式資料庫的 API,而 JMS 則提供同樣與廠商無關的訪問方法,以訪問訊息收發服務。JMS 使您能夠通過訊息收發服務(有時稱為訊息中介程式或路由器)從一個 JMS 客戶機向另一個 JMS客戶機傳送訊息。訊息是 JMS 中的一種型別物件,由兩部分組成:報頭和訊息主體。報頭由路由資訊以及有關該訊息的後設資料組成。訊息主體則攜帶著應用程式的資料或有效負載。根據有效負載的型別來劃分,可以將訊息分為幾種型別,它們分別攜帶:簡單文字(TextMessage)、可序列化的物件 (ObjectMessage)、屬性集合 (MapMessage)、位元組流 (BytesMessage)、原始值流 (StreamMessage),還有無有效負載的訊息 (Message)。

  • 連線工廠。連線工廠(ConnectionFactory)是由管理員建立,並繫結到JNDI樹中。客戶端使用JNDI查詢連線工廠,然後利用連線工廠建立一個JMS連線。

  • JMS連線。JMS連線(Connection)表示JMS客戶端和伺服器端之間的一個活動的連線,是由客戶端通過呼叫連線工廠的方法建立的。

  • JMS會話。JMS會話(Session)表示JMS客戶與JMS伺服器之間的會話狀態。JMS會話建立在JMS連線上,表示客戶與伺服器之間的一個會話執行緒。

  • JMS目的。JMS目的(Destination),又稱為訊息佇列,是實際的訊息源。

  • JMS生產者和消費者。生產者(Message Producer)和消費者(Message Consumer)物件由Session物件建立,用於傳送和接收訊息。

3.JMS訊息通常有兩種型別

點對點(Point-to-Point)。在點對點的訊息系統中,訊息分發給一個單獨的使用者。點對點訊息往往與佇列(javax.jms.Queue)相關聯。如果希望傳送的每個訊息都會被成功處理的話,那麼需要P2P模式

8926909-66ddb8161942866b.png
點對點

  • 每個訊息只有一個消費者(Consumer)(即一旦被消費,訊息就不再在訊息佇列中)
  • 傳送者和接收者之間在時間上沒有依賴性,也就是說當傳送者傳送了訊息之後,不管接收者有沒有正在執行,它不會影響到訊息被髮送到佇列
  • 接收者在成功接收訊息之後需向佇列應答成功

釋出/訂閱(Publish/Subscribe)。釋出/訂閱訊息系統支援一個事件驅動模型,訊息生產者和消費者都參與訊息的傳遞。生產者釋出事件,而使用者訂閱感興趣的事件,並使用事件。該型別訊息一般與特定的主題(javax.jms.Topic)關聯。如果希望傳送的訊息可以不被做任何處理、或者只被一個訊息者處理、或者可以被多個消費者處理的話,那麼可以採用Pub/Sub模型。

8926909-35ab93a92881e779.png
釋出訂閱模式

  • 每個訊息可以有多個消費者
  • 釋出者和訂閱者之間有時間上的依賴性。針對某個主題(Topic)的訂閱者,它必須建立一個訂閱者之後,才能消費釋出者的訊息。
  • 為了消費訊息,訂閱者必須保持執行的狀態。

在JMS中,訊息的產生和消費都是非同步的。對於消費來說,JMS的訊息者可以通過兩種方式來消費訊息。

  • (1)同步:訂閱者或接收者通過receive方法來接收訊息,receive方法在接收到訊息之前(或超時之前)將一直阻塞;
/* 建立訊息消費者*/
messageConsumer = session.createConsumer(destination);
Message message;
while ((message = messageConsumer.receive()) != null) {
      System.out.println(((TextMessage) message).getText());
}
  • (2)非同步:訂閱者或接收者可以註冊為一個訊息監聽器。當訊息到達之後,系統自動呼叫監聽器的onMessage方法。
/* 建立訊息消費者*/
messageConsumer = session.createConsumer(destination);
/* 設定消費者監聽器,監聽訊息*/
messageConsumer.setMessageListener(new MessageListener() {
     public void onMessage(Message message) {
         try {
              System.out.println(((TextMessage) message).getText());
         } catch (JMSException e) {
              e.printStackTrace();
         }
     }
});

二、ActiveMQ的使用

使用Active中介軟體只需要在pom檔案中新增如下配置項即可,非常簡便:

8926909-b5d5f39d6461997b.png
maven依賴

建立會話的引數:

/* 建立session*/
session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);

第一個引數是否使用事務:當訊息傳送者向訊息提供者(即訊息代理)傳送訊息時,訊息傳送者等待訊息代理的確認,沒有迴應則丟擲異常,訊息傳送程式負責處理這個錯誤。

第二個引數訊息的確認模式

  • AUTO_ACKNOWLEDGE: 指定訊息接收者在每次收到訊息時自動傳送確認。訊息只向目標傳送一次,但傳輸過程中可能因為錯誤而丟失訊息。
  • CLIENT_ACKNOWLEDGE: 由訊息接收者確認收到訊息,通過呼叫訊息的acknowledge()方法(會通知訊息提供者收到了訊息)
  • DUPS_OK_ACKNOWLEDGE: 指定訊息提供者在訊息接收者沒有確認傳送時重新傳送訊息(這種確認模式不在乎接收者收到重複的訊息)。

整合Spring

1、Maven中新增依賴:

<dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-jms</artifactId>
      <version>4.3.11.RELEASE</version>
</dependency>

2、訊息生產者模式:
生產者在Spring的配置檔案中增加ActiveMQ相關配置,包括名稱空間、連線工廠、連線池配置

    <amq:connectionFactory id="amqConnectionFactory" 
     brokerURL="tcp://127.0.0.1:61616" userName="" password=""/>

    <!-- Spring用於管理真正的ConnectionFactory的ConnectionFactory -->
    <bean id="connectionFactory"
          class="org.springframework.jms.connection.CachingConnectionFactory">
        <property name="targetConnectionFactory" ref="amqConnectionFactory"></property>
        <property name="sessionCacheSize" value="100"></property>
    </bean>

定義生產者模式:

    <!-- 定義JmsTemplate的Queue型別 -->
    <bean id="jmsQueueTemplate" class="org.springframework.jms.core.JmsTemplate">
        <constructor-arg ref="connectionFactory"></constructor-arg>
        <!-- 佇列模式-->
        <property name="pubSubDomain" value="false"></property>
    </bean>

    <!-- 定義JmsTemplate的Topic型別 -->
    <bean id="jmsTopicTemplate" class="org.springframework.jms.core.JmsTemplate">
        <constructor-arg ref="connectionFactory"></constructor-arg>
        <!-- 釋出訂閱模式-->
        <property name="pubSubDomain" value="true"></property>
    </bean>

在Java程式碼裡封裝生產者:

@Component("topicSender")
public class TopicSender {

    @Autowired
    @Qualifier("jmsTopicTemplate")
    private JmsTemplate jmsTemplate;

    public void send(String queueName, final String message) {
        jmsTemplate.send(queueName, new MessageCreator() {
            @Override
            public Message createMessage(Session session) throws JMSException {
                TextMessage textMessage = session.createTextMessage(message);
                return textMessage;
            }
        });
    }
}

定義好上述bean物件以後,提供出相應的send()方法,可以再Spring框架的Service層中進行方法的呼叫。

3、訊息消費者模式
同樣的需要定義好Jms的連線工廠、連線池配置,這部分同生產者的配置檔案保持一致,不同的是需要定義好消費者的消費模板:

<!-- 定義Topic監聽器 -->
    <jms:listener-container destination-type="topic" container-type="default"
                            connection-factory="connectionFactory" acknowledge="auto">
        <jms:listener destination="topicDemo1" ref="topicReceiver1"></jms:listener>
        <jms:listener destination="topicDemo2" ref="topicReceiver2"></jms:listener>
    </jms:listener-container>

    <!-- 定義Queue監聽器 -->
    <jms:listener-container destination-type="queue" container-type="default"
                            connection-factory="connectionFactory" acknowledge="auto">
        <jms:listener destination="queueDemo1" ref="queueReceiver1"></jms:listener>
        <jms:listener destination="queueDemo2" ref="queueReceiver2"></jms:listener>
    </jms:listener-container>

在Java程式碼裡封裝消費者:

@Component
public class QueueReceiver1 implements MessageListener {
    @Override
    public void onMessage(Message message) {
        try {
            String messgeStr= ((TextMessage) message).getText();
            // ....業務邏輯...
        } catch (JMSException e) {
            e.printStackTrace();
        }

    }
}

4、擴充套件的P2P模式——請求應答
Request-Response的通訊方式很常見,但不是預設提供的。在前面的兩種模式中都是一方負責傳送訊息而另外一方負責處理。實際中的很多應用可能需要一應一答,需要雙方都能給對方傳送訊息。請求-應答方式並不是JMS規範系統預設提供的一種通訊方式,而是通過在現有通訊方式的基礎上稍微運用一點技巧實現的。下圖是典型的請求-應答方式的互動過程:

8926909-3d25b344ad304c86.png
請求應答

首先在生產者端配置了特定的監聽器(同消費者配置方式一致),監聽來自消費者的訊息,此處注意目的地tempqueue和引用物件ref的配置:

    <!--接收消費者應答的監聽器-->
    <jms:listener-container destination-type="queue" container-type="default"
                            connection-factory="connectionFactory" acknowledge="auto">
        <jms:listener destination="tempqueue" ref="getResponse"></jms:listener>
    </jms:listener-container>

實現該監聽器(即上述配置檔案裡對應的ref),並將該Bean物件宣告給Spring容器託管

@Component
public class GetResponse implements MessageListener {
    @Override
    public void onMessage(Message message) {
        String textMsg = null;
        try {
            textMsg = ((TextMessage) message).getText();
            System.out.println("GetResponse accept msg : " + textMsg);
        } catch (JMSException e) {
            e.printStackTrace();
        }
    }
}

在生產者傳送方法send()的程式碼裡配置應答的程式碼:

//配置,告訴消費者如何應答
Destination tempDst = session.createTemporaryQueue();
MessageConsumer responseConsumer = session.createConsumer(tempDst);
responseConsumer.setMessageListener(getResponse);
msg.setJMSReplyTo(tempDst);

同理在消費者這一方需要配置訊息生產的模板,方便收到訊息後傳送應答通知給訊息生產方,在Spring配置檔案中加入同樣的訊息傳送配置:

    <bean id="jmsConsumerQueueTemplate" class="org.springframework.jms.core.JmsTemplate">
        <constructor-arg ref="connectionFactory"></constructor-arg>
        <!-- 佇列模式-->
        <property name="pubSubDomain" value="false"></property>
    </bean>

實現應答傳送的方法,然後將該Bean物件交給Spring容器管理,此處需要注意在send()方法中宣告的兩個引數,引數一對應的是傳送的訊息內容,引數二封裝的時候訊息生產者的物件(方便從中獲取應答的物件資訊)。

@Component
public class ReplyTo {

    @Autowired
    @Qualifier("jmsConsumerQueueTemplate")
    private JmsTemplate jmsTemplate;

    public void send(final String consumerMsg, Message producerMessage)
            throws JMSException {
        jmsTemplate.send(producerMessage.getJMSReplyTo(),
                new MessageCreator() {
                    @Override
                    public Message createMessage(Session session)
                            throws JMSException {
                        Message msg
                                = session.createTextMessage("ReplyTo " + consumerMsg);
                        return msg;
                    }
                });
    }
}

於是在需要應答的訊息處理時引入該Bean物件,即可對收到的訊息進行應答處理:

    @Autowired
    private ReplyTo replyTo;

    @Override
    public void onMessage(Message message) {
        try {
            String textMsg = ((TextMessage) message).getText();
            // do business work;
            replyTo.send(textMsg,message);
        } catch (JMSException e) {
            e.printStackTrace();
        }
    }

上面步驟就完成了一個擴充套件的P2P請求-應答(Request-Response)模式,只是在原先的訊息生產者加入監聽、訊息的消費方加入了針對訊息的應答處理邏輯實現。

三、ActiveMQ高階應用

為了避免意外當機以後丟失資訊,MQ需要做到重啟後可以恢復,這裡就涉及到持久化機制。ActiveMQ的訊息持久化機制有JDBC,AMQ,KahaDB和LevelDB,無論使用哪種持久化方式,訊息的儲存邏輯都是一致的:在傳送者將訊息傳送出去後,訊息中心首先將訊息儲存到本地資料檔案、記憶體資料庫或者遠端資料庫等,然後試圖將訊息傳送給接收者,傳送成功則將訊息從儲存中刪除,失敗則繼續嘗試。訊息中心啟動以後首先要檢查指定的儲存位置,如果有未傳送成功的訊息,則需要把訊息傳送出去。

訊息的持久化機制

1、JDBC持久化(推薦)
使用JDBC持久化方式,資料庫會建立3個表:activemq_msgs,activemq_acksactivemq_lock
activemq_msgs用於儲存訊息,QueueTopic都儲存在這個表中。配置持久化的方式,都是修改安裝目錄下conf/acticvemq.xml檔案,首先定義一個mysql-ds的MySQL資料來源,然後在persistenceAdapter節點中配置jdbcPersistenceAdapter並且引用剛才定義的資料來源。

<beans>
    <broker brokerName="test-broker" persistent="true" xmlns="http://activemq.apache.org/schema/core">
        <persistenceAdapter>
            <jdbcPersistenceAdapter dataSource="#mysql-ds" createTablesOnStartup="false"/>
        </persistenceAdapter>
    </broker>

    <bean id="mysql-ds" class="org.apache.commons.dbcp.BasicDataSource" destroy-method="close">
        <property name="driverClassName" value="com.mysql.jdbc.Driver"/>
        <property name="url" value="jdbc:mysql://localhost/activemq?relaxAutoCommit=true"/>
        <property name="username" value="activemq"/>
        <property name="password" value="activemq"/>
        <property name="maxActive" value="200"/>
        <property name="poolPreparedStatements" value="true"/>
    </bean>
</beans>

2. AMQ方式(不推薦)
效能高於JDBC,寫入訊息時,會將訊息寫入日誌檔案,由於是順序追加寫,效能很高。為了提升效能,建立訊息主鍵索引,並且提供快取機制,進一步提升效能。每個日誌檔案的大小都是有限制的(預設32m,可自行配置)。
雖然AMQ效能略高於下面的Kaha DB方式,但是由於其重建索引時間過長,而且索引檔案佔用磁碟空間過大,所以已經不推薦使用。

3.KahaDB方式
KahaDB是從ActiveMQ 5.4開始預設的持久化外掛,KahaDb恢復時間遠遠小於其前身AMQ並且使用更少的資料檔案,所以可以完全代替AMQ。kahaDB的持久化機制同樣是基於日誌檔案,索引和快取。

4.LevelDB方式
從ActiveMQ 5.6版本之後,又推出了LevelDB的持久化引擎。目前預設的持久化方式仍然是KahaDB,不過LevelDB持久化效能高於KahaDB,可能是以後的趨勢。在ActiveMQ 5.9版本提供了基於LevelDB和Zookeeper的資料複製方式,用於Master-slave方式的首選資料複製方案。

《ActiveMQ的幾種訊息持久化機制》

訊息的持久化訂閱

在上述持久化機制中預設是對P2P模式開啟了,但是主題訂閱模式下如果需要持久化訂閱則還需要做一些額外的工作,主要是在消費端這邊進行一些特殊處理:
1、設定客戶端id:connection.setClientID("clientID");

connection.setClientID("Mark");

2、訊息的destination變為 Topic (原先是Destination)

Topic destination = session.createTopic("DurableTopic");

消費者型別變為TopicSubscriber

//任意名字,代表訂閱名
messageConsumer = session.createDurableSubscriber(destination,"durableSubscriber");

執行一次消費者,將消費者在ActiveMQ上進行一次註冊。在ActiveMQ的管理控制檯subscribers頁面可看見消費者。生產者端這邊是不需要做特殊處理,但是需要注意的是生產者可以對訊息是否持久化的處理,而這個配置就會影響到下游的消費者是否能進行持久化訂閱,配置是取的列舉值而來:

public interface DeliveryMode {
    int NON_PERSISTENT = 1;
    int PERSISTENT = 2;
}

訊息的可靠性保證

對於某些重要的涉及資金和交易業務的訊息傳輸需要有可靠保證,除了上述提到的訊息持久化,還包括兩個方面,一是生產者傳送的訊息可以被ActiveMQ收到,二是消費者收到了ActiveMQ傳送的訊息,這需要在兩端都進行配置。

生產端,建立會話的時候:

  • 未開啟事務,呼叫send()方法會以同步方式進行訊息的傳送,send()方法阻塞直到 broker(訊息中介軟體的例項) 收到訊息並返回確認訊息給傳送者。
  • 開啟事務,非同步傳送,send()方法不被阻塞。但是commit()方法會被阻塞,直到收到來自 broker 的確認訊息。
/* 建立訊息會話*/
Session session = connection.createSession(true, Session.AUTO_ACKNOWLEDGE);
/* 建立訊息生產者*/
messageProducer = session.createProducer(destination);
/* 非同步傳送*/
messageProducer.send(textMessage);
/* commit()阻塞直到確認方能提交*/
session.commit();

消費端:訊息的四種確認機制,同樣也是在會話建立之時進行配置定義

ACK機制 描述
AUTO_ACKNOWLEDGE = 1 自動確認
CLIENT_ACKNOWLEDGE = 2 客戶端手動確認
DUPS_OK_ACKNOWLEDGE = 3 自動批量確認
SESSION_TRANSACTED = 0 事務提交併確認
  • 當消費方配置為true事務時,預設為SESSION_TRANSACTED = 0。事務中的訊息可以確認時,呼叫commit方法將讓當前所有訊息被確認。在事務開始的任何時機呼叫rollback(),意味著當前事務的結束,事務中所有的訊息都將被重發。當然在commit之前丟擲異常,也會導致事務的rollback。

  • AUTO_ACKNOWLEDGE客戶端自動確認模式,這裡分兩種情況來看,如果採用“同步”messageConsumer.receive()方法,返回message給訊息時會立即確認。而在"非同步"(messageListener)方式中,如果onMessage方法正常結束,訊息將會正常確認。如果方法異常,將導致消費者要求ActiveMQ重發訊息。
    訊息的重發有次數限制,訊息中內建屬性“redeliveryCounter”計數器,記錄一條訊息的重發次數,一旦達到閥值,訊息將會被刪除或者遷移到死信通道中。最好在方法中用try catch處理,可記錄異常資訊。同時也需要注意onMessage方法中邏輯是否能夠相容對重複訊息的判斷。

  • CLIENT_ACKNOWLEDGE客戶端手動確認,需要在程式碼裡擇機確認,呼叫 message.acknowledge()方法。可以逐條確認訊息或者批量處理完後再確認訊息,自行權衡。

  • DUPS_OK_ACKNOWLEDGE自動批量確認機制,具有“延遲”確認的特點,由ActiveMQ決定批量自動進行確認。

死信佇列

DLQ-死信佇列(Dead Letter Queue)用來儲存處理失敗或者過期的訊息。出現以下情況時,訊息會被重發:

  • A transacted session is used and rollback() is called(使用一個事務session,並且呼叫了rollback()方法).
  • A transacted session is closed before commit is called(一個事務session,關閉之前呼叫了commit).
  • A session is using CLIENT_ACKNOWLEDGE and Session.recover() is called(在session中使用CLIENT_ACKNOWLEDGE簽收模式,並且呼叫了Session.recover()方法)。

當一個訊息被重發次數超過maximumRedeliveries(預設為6次)次數時,會給broker傳送一個"Poison ack",這個訊息被認為是a poison pill(毒丸),這時broker會將這個訊息傳送到DLQ,以便後續處理。在業務中可以單獨使用死信消費者處理這些死信,其處理方式和普通的訊息消費者是一樣的。綜合來看,死信佇列的訊息處理一般有如下解決方案:

  • 重發:對於安全性要求比較高的系統,那需要將傳送失敗的訊息進行重試傳送,甚至在訊息一直不能到達的情況下給予相關的郵件、簡訊等必要的告警措施以保證訊息的正確投遞。
  • 丟棄:在訊息不是很重要以及有其他通知手段的情況下,那麼對訊息做丟棄處理也不失為一種好辦法,畢竟如果大量不可抵達的訊息存在於訊息系統中會對我們的系統造成非常大的負荷,所以也會採用丟棄的方式進行處理。

虛擬Destinations實現消費者分組與簡單路由

ActiveMQ支援的虛擬Destinations分為有兩種,分別是

  • 虛擬主題(Virtual Topics)
  • 組合 Destinations(CompositeDestinations)

這兩種虛擬Destinations可以看做對簡單的topic和queue用法的補充,基於它們可以實現一些簡單有用的EIP功能,虛擬主題類似於1對多的分支功能+消費端的cluster+failover,組合Destinations類似於簡單的destinations直接的路由功能。

虛擬主題(Virtual Topics)
ActiveMQ中,topic只有在持久訂閱(durablesubscription)下是持久化的。存在持久訂閱時,每個持久訂閱者,都相當於一個持久化的queue的客戶端,它會收取所有訊息。這種情況下存在兩個問題:

  1. 同一應用內consumer端負載均衡的問題:同一個應用上的一個持久訂閱不能使用多個consumer來共同承擔訊息處理功能,因為每個都會獲取所有訊息。queue模式可以解決這個問題,broker端又不能將訊息傳送到多個應用端。所以,既要釋出訂閱,又要讓消費者分組,這個功能jms規範本身是沒有的。

  2. 同一應用內consumer端failover的問題:由於只能使用單個的持久訂閱者,如果這個訂閱者出錯,則應用就無法處理訊息了,系統的健壯性不高。

為了解決這兩個問題,ActiveMQ中實現了虛擬Topic的功能。使用起來非常簡單。對於訊息釋出者來說,就是一個正常的Topic,名稱以VirtualTopic.開頭。例如VirtualTopic.TEST

對於訊息接收端來說,是個佇列,不同應用裡使用不同的字首作為佇列的名稱,即可表明自己的身份即可實現消費端應用分組。 例如Consumer.A.VirtualTopic.TEST,說明它是名稱為A的消費端,同理Consumer.B.VirtualTopic.TEST說明是一個名稱為B的客戶端。可以在同一個應用裡使用多個consumer消費此queue,則可以實現上面兩個功能。又因為不同應用使用的queue名稱不同(字首不同),所以不同的應用中都可以接收到全部的訊息。每個客戶端相當於一個持久訂閱者,而且這個客戶端可以使用多個消費者共同來承擔消費任務。

ActiveMQ高階特性:虛擬Destinations實現消費者分組與簡單路由

相關文章