Californium 開源框架分析

wudashan.cn發表於2017-05-15

專案原始碼地址:https://github.com/eclipse/californium

引言

物聯網時代,所有裝置都可以接入我們的網際網路。想想看只要有一臺智慧手機,就可以操控所有的裝置,也可以獲取到所有裝置採集的資訊。不過,並不是所有裝置都支援HTTP協議的,而且讓裝置支援HTTP協議也不現實,因為對於裝置來說,這個協議太重了,會消耗大量的頻寬和電量。於是CoAP協議也就運應而生了,我們可以把它看為超簡化版的HTTP協議。而Californium框架,就是對CoAP協議的Java實現。

CoAP協議

在閱讀Californium框架之前,我們需要對CoAP協議有個大致的瞭解,已經懂得了的同學可以直接跳過本章節。

CoAP報文

首先讓我們看一下CoAP協議的報文是長啥樣的:

Version (Ver):長度為2位,表示CoAP協議的版本號。當前版本為01(二進位制表示形式)。

Type (T):長度為2位,表示報文型別。其中各型別及二進位制表示形式如下,Confirmable (00)、Non-confirmable (01)、Acknowledgement (10)、Reset (11)。在描述的時候為了簡便,會將Confirmable縮寫為CON,Non-confirmable縮寫為NON,Acknowledgement縮寫為ACK,Reset縮寫為RST。比如一個報文的型別為Confirmable,我們就會簡寫為CON報文。

Token Length (TKL):長度為4位,表示Token欄位的長度。

Code:長度為8位,表示響應碼。其中前3位代表一個數,後5位代表一個數。如010 00000,轉為十進位制就是2.00(表示時中間帶一個點),其意思可以理解為HTTP中200 OK響應碼。

Message ID:長度為16位,表示訊息id。用來表示是否為同一個的報文(重發場景下,去重會用到),或者CON請求報文和ACK響應報文的匹配。

Token:長度由TKL欄位決定,表示一次會話記錄。用來關聯請求和響應。有人可能有疑惑,Message ID不是可以將請求和響應關聯嗎?的確,CON型別的請求報文與ACK型別的響應報文可以用Message ID進行關聯,但NON型別的報文由於沒有要求是一對的,所以如果NON型別的報文想成對,那就只能通過相同的Token來匹配了。

Options:長度不確定,表示報文的選項。類似為HTTP的請求頭,內容包括Uri-Host、Uri-Path、Uri-Port等等。

1 1 1 1 1 1 1 1:Payload Marker,用來隔離Options欄位和Payload欄位。

Payload:長度由資料包決定,表示應用層需要的資料。

訊息傳輸模型

CoAP協議是雖然是建立在UDP之上的,但是它有可靠和不可靠兩種傳輸模型。

可靠傳輸模型:

如上圖,客戶端通過發起一個CON報文(Message ID = 0x7D34),服務端在收到CON報文之後,需要回復一個ACK報文(Message ID = 0x7d34)。通過Message ID將CON報文和ACK報文對應起來。

確保可靠傳輸的方法有倆:其一,通過服務端回覆ACK報文,客戶端可以確認CON報文已被服務端接收;其二,超時重傳機制。若客戶端在一定時間內未收到ACK報文,則認為CON報文已經在鏈路上丟失,這時候就會重傳CON報文,重傳時間和次數可配置。

不可靠傳輸模型:

如上圖,客戶端發起一個NON報文(Message ID = 0x01a0)之後,服務端無需回覆響應,客戶端也不會重發。

請求與響應模型

由於存在可靠與不可靠兩種傳輸模型,那麼對應的也會存在兩種請求與響應模型。

CON請求,ACK響應:

如上圖,客戶端發起了一個CON報文(Message ID = 0xbc90, Code = 0.01 GET, Options = {"Uri-Path":"/temperature"}, Token = 0x71),服務端在收到查詢溫度的請求之後,回覆ACK報文(Message ID = 0xbc90, Code = 2.05 Content, Payload = "22.5 C", Token = 0x71)。也就是說服務端可以在ACK報文中,就將客戶端查詢溫度的結果一起返回。

當然,還有一種情況,那就是服務端可能由於某些原因不馬上返回結果。如上圖,客戶端發起查詢溫度的CON報文之後,服務端先回復ACK報文。一段時間過後,服務端再發起CON報文給客戶端,並將溫度的結果一起攜帶,客戶端收到結果之後回覆ACK報文。

NON請求,NON響應:

如上圖,客戶端發起了一個NON報文(Message ID = 0x7a11, Code = 0.01 GET, Options = {"Uri-Path":"/temperature"}, Token = 0x74),服務端在收到查詢溫度的請求之後,回覆NON報文(Message ID = 0x23bc, Code = 2.05 Content, Payload = "22.5 C", Token = 0x74)

可以發現,CON型別的請求報文與ACK型別的響應報文是通過Message ID進行匹配,NON型別的請求報文與NON型別的響應報文則是通過Token進行匹配。

至此,我們們的CoAP協議初學之路已到了終點,如果還想詳細研究的同學,可以查閱RFC 7252,這裡就不再做詳述了!那麼,接下來就讓我們對Californium開源框架一探究竟吧!

分析入口

想要分析一個框架,最好的方法就是先使用它,再通過debug,一步步地瞭解它是如何執行的。

首先在pom.xml檔案裡引入Californium開源框架的依賴:

<dependency>
    <groupId>org.eclipse.californium</groupId>
    <artifactId>californium-core</artifactId>
    <version>2.0.0-M1</version>
</dependency>

其次,我們只要在Main函式裡敲兩行程式碼,服務端就啟動起來了:

public static void main(String[] args) {

        // 建立服務端
        CoapServer server = new CoapServer();
        // 啟動服務端
        server.start();

}

那麼,接下來就讓我們從CoapServer這個類開始,對整個框架進行分析。首先讓我們看看構造方法CoapServer()裡面做了哪些事:

public CoapServer(final NetworkConfig config, final int... ports) {

    // 初始化配置	
    if (config != null) {
        this.config = config;
    } else {
        this.config = NetworkConfig.getStandard();
    }

    // 初始化Resource
    this.root = createRoot();

    // 初始化MessageDeliverer
    this.deliverer = new ServerMessageDeliverer(root);

    CoapResource wellKnown = new CoapResource(".well-known");
    wellKnown.setVisible(false);
    wellKnown.add(new DiscoveryResource(root));
    root.add(wellKnown);

    // 初始化EndPoints
    this.endpoints = new ArrayList<>();

    // 初始化執行緒池
    this.executor = Executors.newScheduledThreadPool(this.config.getInt(NetworkConfig.Keys.PROTOCOL_STAGE_THREAD_COUNT), new NamedThreadFactory("CoapServer#")); 

    // 新增Endpoint
    for (int port : ports) {
        addEndpoint(new CoapEndpoint(port, this.config));
    }
}

構造方法初始化了一些成員變數。其中,Endpoint負責與網路進行通訊,MessageDeliverer負責分發請求,Resource負責處理請求。接著讓我們看看啟動方法start()又做了哪些事:

public void start() {

    // 如果沒有一個Endpoint與CoapServer進行繫結,那就建立一個預設的Endpoint
    ...

    // 一個一個地將Endpoint啟動
    int started = 0;
    for (Endpoint ep:endpoints) {
        try {
            ep.start();
            ++started;
        } catch (IOException e) {
            LOGGER.log(Level.SEVERE, "Cannot start server endpoint [" + ep.getAddress() + "]", e);
        }
    }
    if (started==0) {
        throw new IllegalStateException("None of the server endpoints could be started");
    }
}

啟動方法很簡單,主要是將所有的Endpoint一個個啟動。至此,服務端算是啟動成功了。讓我們稍微總結一下幾個類的關係:

如上圖,訊息會從Network模組傳輸給對應的Endpoint節點,所有的Endpoint節點都會將訊息推給MessageDeliverer,MessageDeliverer根據訊息的內容傳輸給指定的Resource,Resource再對訊息內容進行處理。

接下來,將讓我們再模擬一個客戶端發起一個GET請求,看看服務端是如何接收和處理的吧!客戶端程式碼如下:

public static void main(String[] args) throws URISyntaxException {

    // 確定請求路徑
    URI uri = new URI("127.0.0.1");

    // 建立客戶端
    CoapClient client = new CoapClient(uri);

    // 發起一個GET請求
    client.get();

}

通過前面分析,我們知道Endpoint是直接與網路進行互動的,那麼客戶端發起的GET請求,應該在服務端的Endpoint中收到。框架中Endpoint介面的實現類只有CoapEndpoint,讓我們深入瞭解一下CoapEndpoint的內部實現,看看它是如何接收和處理請求的。

CoapEndpoint類

CoapEndpoint類實現了Endpoint介面,其構造方法如下:

public CoapEndpoint(Connector connector, NetworkConfig config, ObservationStore store) {
    this.config = config;
    this.connector = connector;
    if (store == null) {
        this.matcher = new Matcher(config, new NotificationDispatcher(), new InMemoryObservationStore());
    } else {
        this.matcher = new Matcher(config, new NotificationDispatcher(), store);
    }
    this.coapstack = new CoapStack(config, new OutboxImpl());
    this.connector.setRawDataReceiver(new InboxImpl());
}

從構造方法可以瞭解到,其內部結構如下所示:

那麼,也就是說客戶端發起的GET請求將被InboxImpl類接收。InboxImpl類實現了RawDataChannel介面,該介面只有一個receiveData(RawData raw)方法,InboxImpl類的該方法如下:

public void receiveData(final RawData raw) {

    // 引數校驗
    ...

    // 啟動執行緒處理收到的訊息
    runInProtocolStage(new Runnable() {
        @Override
        public void run() {
            receiveMessage(raw);
        }
    });

}

再往receiveMessage(RawData raw)方法裡看:

private void receiveMessage(final RawData raw) {

    // 解析資料來源
    DataParser parser = new DataParser(raw.getBytes());

    // 如果是請求資料
    if (parser.isRequest()) {
        // 一些非關鍵操作
        ...

        // 訊息攔截器接收請求
        for (MessageInterceptor interceptor:interceptors) {
            interceptor.receiveRequest(request);
        }

        // 匹配器接收請求,並返回Exchange物件
        Exchange exchange = matcher.receiveRequest(request);

        // Coap協議棧接收請求
        coapstack.receiveRequest(exchange, request);
    }

    // 如果是響應資料,則與請求資料一樣,分別由訊息攔截器、匹配器、Coap協議棧接收響應
    ...

    // 如果是空資料,則與請求資料、響應資料一樣,分別由訊息攔截器、匹配器、Coap協議棧接收空資料
    ...

    // 一些非關鍵操作
    ...

}

接下來,我們分別對MessageInterceptor(訊息攔截器)、Matcher(匹配器)、CoapStack(Coap協議棧)進行分析,看看他們接收到請求後做了什麼處理。

MessageInterceptor介面

框架本身並沒有提供該介面的任何實現類,我們可以根據業務需求實現該介面,並通過CoapEndpoint.addInterceptor(MessageInterceptor interceptor)方法新增具體的實現類。

Matcher類

我們主要看receiveRequest(Request request)方法,看它對客戶端的GET請求做了哪些操作:

public Exchange receiveRequest(Request request) {

    // 根據Request請求,填充並返回Exchange物件
    ...

}

CoapStack類

CoapStack的類圖比較複雜,其結構可以簡化為下圖:

有人可能會疑惑,這個結構圖是怎麼來,答案就在構造方法裡:

public CoapStack(NetworkConfig config, Outbox outbox) {

    // 初始化棧頂
    this.top = new StackTopAdapter();

    // 初始化棧底
    this.bottom = new StackBottomAdapter();

    // 初始化出口
    this.outbox = outbox;

    // 初始化ReliabilityLayer
    ...

    // 初始化層級
    this.layers = 
        new Layer.TopDownBuilder()
        .add(top)
        .add(new ObserveLayer(config))
        .add(new BlockwiseLayer(config))
        .add(reliabilityLayer)
        .add(bottom)
        .create();

}

迴歸正題,繼續看CoapStack.receiveRequest(Exchange exchange, Request request)方法是怎麼處理客戶端的GET請求:

public void receiveRequest(Exchange exchange, Request request) {
    bottom.receiveRequest(exchange, request);
}

CoapStack在收到請求後,交給了StackBottomAdapter去處理,StackBottomAdapter處理完後就會依次向上傳遞給ReliabilityLayer、BlockwiseLayer、ObserveLayer,最終傳遞給StackTopAdapter。中間的處理細節就不詳述了,直接看StackTopAdapter.receiveRequest(Exchange exchange, Request request)方法:

public void receiveRequest(Exchange exchange, Request request) {

    // 一些非關鍵操作
    ...

    // 將請求傳遞給訊息分發器
    deliverer.deliverRequest(exchange);

}

可以看到,StackTopAdapter最後會將請求傳遞給MessageDeliverer,至此CoapEndpoint的任務也就算完成了,我們可以通過一張請求訊息流程圖來回顧一下,一個客戶端GET請求最終是如何到達MessageDeliverer的:

MessageDeliverer介面

框架有ServerMessageDeliverer和ClientMessageDeliverer兩個實現類。從CoapServer的構造方法裡知道使用的是ServerMessageDeliverer類。那麼就讓我們看看ServerMessageDeliverer.deliverRequest(Exchange exchange)方法是如何分發GET請求的:

public void deliverRequest(final Exchange exchange) {

    // 從exchange裡獲取request
    Request request = exchange.getRequest();

    // 從request裡獲取請求路徑
    List<String> path = request.getOptions().getUriPath();

    // 找出請求路徑對應的Resource
    final Resource resource = findResource(path);

    // 一些非關鍵操作
    ...

    // 由Resource來真正地處理請求
    resource.handleRequest(exchange);

    // 一些非關鍵操作
    ...

}

當MessageDeliverer找到Request請求對應的Resource資源後,就會交由Resource資源來處理請求。(是不是很像Spring MVC中的DispatcherServlet,它也負責分發請求給對應的Controller,再由Controller自己處理請求)

Resource介面

還記得CoapServer構造方法裡建立了一個RootResource嗎?它的資源路徑為空,而客戶端發起的GET請求預設也是空路徑。那麼ServerMessageDeliverer就會把請求分發給RootResource處理。RootResource類沒有覆寫handleRequest(Exchange exchange)方法,所以我們看看CoapResource父類的實現:

public void handleRequest(final Exchange exchange) {
    Code code = exchange.getRequest().getCode();
    switch (code) {
        case GET:	handleGET(new CoapExchange(exchange, this)); break;
        case POST:	handlePOST(new CoapExchange(exchange, this)); break;
        case PUT:	handlePUT(new CoapExchange(exchange, this)); break;
        case DELETE: handleDELETE(new CoapExchange(exchange, this)); break;
    }
}

由於我們客戶端發起的是GET請求,那麼將會進入到RootResource.handleGET(CoapExchange exchange)方法:

public void handleGET(CoapExchange exchange) {
    // 由CoapExchange返回響應
    exchange.respond(ResponseCode.CONTENT, msg);
}

再接著看CoapExchange.respond(ResponseCode code, String payload)方法:

public void respond(ResponseCode code, String payload) {

    // 生成響應並賦值
    Response response = new Response(code);
    response.setPayload(payload);
    response.getOptions().setContentFormat(MediaTypeRegistry.TEXT_PLAIN);

    // 呼叫同名函式
    respond(response);

}

看看同名函式裡又做了哪些操作:

public void respond(Response response) {

    // 引數校驗
    ...

    // 設定Response屬性
    ...

    // 檢查關係
    resource.checkObserveRelation(exchange, response);

    // 由成員變數Exchange傳送響應
    exchange.sendResponse(response);

}

那麼Exchange.sendResponse(Response response)又是如何傳送響應的呢?

public void sendResponse(Response response) {

    // 設定Response屬性
    response.setDestination(request.getSource());
    response.setDestinationPort(request.getSourcePort());
    setResponse(response);

    // 由Endpoint傳送響應
    endpoint.sendResponse(this, response);

}

原來最終還是交給了Endpoint去傳送響應了啊!之前的GET請求就是從Endpoint中來的。這真是和達康書記一樣,從人民中來,再到人民中去。

在CoapEndpoint類一章節中我們有介紹它的內部結構。那麼當傳送響應的時候,將與之前接收請求相反,先由StackTopAdapter處理、再是依次ObserveLayer、BlockwiseLayer、ReliabilityLayer處理,最後由StackBottomAdapter處理,中間的細節還是老樣子忽略,讓我們直接看StackBottomAdapter.sendResponse(Exchange exchange, Response response)方法:

public void sendResponse(Exchange exchange, Response response) {
    outbox.sendResponse(exchange, response);
}

請求入口是CoapEndpoint.InboxImpl,而響應出口是CoapEndpint.OutboxImpl,簡單明瞭。最後,讓我們看看OutboxImpl.sendResponse(Exchange exchange, Response response)吧:

public void sendResponse(Exchange exchange, Response response) {

    // 一些非關鍵操作
    ...

    // 匹配器傳送響應
    matcher.sendResponse(exchange, response);

    // 訊息攔截器傳送響應
    for (MessageInterceptor interceptor:interceptors) {
        interceptor.sendResponse(response);
    }

    // 真正地傳送響應到網路裡
    connector.send(Serializer.serialize(response));

}

通過一張響應訊息流程圖來回顧一下,一個服務端響應最終是如何傳輸到網路裡去:

總結

通過服務端的建立和啟動,客戶端發起GET請求,服務端接收請求並返回響應流程,我們對Californium框架有了一個整體的瞭解。俗話說,師父領進門,修行看個人。在分析這個流程的過程中,我省略了很多的細節,意在讓大家對框架有個概念上的理解,在以後二次開發或定位問題時更能抓住重點,著重針對某個模組。最後,也不得不讚嘆一下這款開源框架程式碼邏輯清晰,模組職責劃分明確,靈活地使用設計模式,非常值得我們學習!

相關文章