創建一個簡單的tcp客戶端

因爲上一篇寫創建tcp服務端已經把很多重要接口分析過了,這一篇會寫的比較簡單。不過在分析代碼前,我想先說下非阻塞connect,因爲這是本篇博客的核心。

以下的文字摘抄自《UNIX網絡編程》:

當在一個非阻塞的TCP套接字上調用connect時,connect將立即返回一個EINPROGRESS錯誤,不過已經發起的TCP三路握手繼續進行。我們接着使用select檢測這個連接或成功或失敗的已建立條件。既然使用select等待連接的建立(在libhv中未必是select,也可能是poll、epoll等其他io事件監視器),我們可以給select指定一個時間限制,使得我們能夠縮短connect的超時(libhv使用定時器實現的該功能),許多實現有着從75秒鐘到數分鐘的connect超時時間,應用程序有時想要一個更短的超時時間。非阻塞connect雖然聽似簡單,卻有一些我們必須處理的細節。儘管套接字是非阻塞的,如果連接的服務器在同一個主機上,那麼當我們調用connect時,連接通常立刻建立(我在自己的環境測試,感覺即使是在一個主機,也無法立刻建立)。我們必須處理這種情況。源自Berkeley的實現(和POSIX)有關於select和非阻塞connect的以下兩個規則:(1)當連接成功建立時,描述符變爲可寫(2)當連接建立遇到錯誤時,描述符變爲即可讀又可寫。一個TCP套接字上發生某個錯誤時,這個待處理錯誤總是導致該套接字變爲既可讀又可寫。

如果描述符變爲可讀或可寫,我們就調用getsockopt取得套接字的待處理錯誤(使用SO_ERROR套接字選項)。如果連接成功建立,該值將爲0。如果連接建立發生錯誤,該值就是對應連接錯誤的errno值。我們之前說過,套接字的各種實現以及非阻塞connect會帶來移植性問題。首先,調用select之前有可能連接已經建立並有來自對端的數據到達。這種情況下即使套接字上不發生錯誤,套接字也是即可讀又可寫的,這和連接建立失敗情況下套接字的讀寫條件一樣。其次,我們不能假設套接字的可寫(而不可讀)條件是select返回套接字操作成功條件的唯一方法,下一個移植性問題就是怎麼判斷連接建立是否成功。張貼到Usenet上的解決方法各式各樣。這些方法可以取代getsockopt調用。

  1. 調用getpeername替代getsockopt。如果getpeername以ENOTCONN錯誤失敗返回,那麼連接建立已經失敗,我們必須接着以SO_ERROR調用getsockopt取得套接字上待處理的錯誤。
  2. 以值爲0的長度參數調用read。如果read失敗,那麼connect已經失敗,read返回的errno給出了連接失敗的原因。如果連接建立成功,那麼read應該返回0.
  3. 再調用connect一次。它應該失敗,如果錯誤是EISCONN,那麼套接字已經連接,也就是說第一次連接已經成功。

ok,開始創建客戶端了。。。。

與創建tcp服務端類似,創建一個客戶端的步驟如下:

    hloop_t* loop = hloop_new(HLOOP_FLAG_QUIT_WHEN_NO_ACTIVE_EVENTS); //創建一個loop
    hio_t* sockio = hloop_create_tcp_client(loop, host, port, on_connect);

    hio_setcb_close(sockio, on_close);
    hio_setcb_read(sockio, on_recv);
    hio_set_readbuf(sockio, recvbuf, RECV_BUFSIZE);

    hloop_run(loop);
    hloop_free(&loop);

hloop_new上一篇已經分析過了,創建一個loop,然後通過hloop_create_tcp_client,創建一個與服務端通信的io結構體。

hio_t* hloop_create_tcp_client (hloop_t* loop, const char* host, int port, hconnect_cb connect_cb) {
    sockaddr_u peeraddr;
    memset(&peeraddr, 0, sizeof(peeraddr));
    //填充服務端網絡地址結構體
    int ret = sockaddr_set_ipport(&peeraddr, host, port);
    if (ret != 0) {
        //printf("unknown host: %s\n", host);
        return NULL;
    }
    int connfd = socket(peeraddr.sa.sa_family, SOCK_STREAM, 0);
    if (connfd < 0) {
        perror("socket");
        return NULL;
    }
    //創建io結構體
    hio_t* io = hio_get(loop, connfd);
    assert(io != NULL);
    //設置對端地址信息
    hio_set_peeraddr(io, &peeraddr.sa, sockaddr_len(&peeraddr));
    hconnect(loop, connfd, connect_cb);
    return io;
}

除了hconnect,其他都比較簡單,hio_get在上一篇博客中也分析過了

hio_t* hconnect (hloop_t* loop, int connfd, hconnect_cb connect_cb) {
    hio_t* io = hio_get(loop, connfd);
    assert(io != NULL);
    //設置connect回調
    if (connect_cb) {
        io->connect_cb = connect_cb;
    }
    hio_connect(io);
    return io;
}
int hio_connect(hio_t* io) {
    //嘗試連接服務端
    int ret = connect(io->fd, io->peeraddr, SOCKADDR_LEN(io->peeraddr));
#ifdef OS_WIN
    if (ret < 0 && socket_errno() != WSAEWOULDBLOCK) {
#else
    if (ret < 0 && socket_errno() != EINPROGRESS) {
#endif
        perror("connect");
        hio_close(io);
        return ret;
    }
    //如果直接連接成功,調用回調函數
    if (ret == 0) {
        // connect ok
        __connect_cb(io);
        return 0;
    }
    //如果沒有直接成功,設置超時時間,等待連接成功
    int timeout = io->connect_timeout ? io->connect_timeout : HIO_DEFAULT_CONNECT_TIMEOUT;
    io->connect_timer = htimer_add(io->loop, __connect_timeout_cb, timeout, 1);
    io->connect_timer->privdata = io;
    io->connect = 1;
    return hio_add(io, hio_handle_events, HV_WRITE);
}

根據上一篇博客的說明,描述符已經被設置爲非阻塞的,根據博客開始的文字說明,調用connect很可能無法直接成功,需要等待一段時間。爲了防止一直不成功,這裏設置了一個connect的定時器,如果在定時時間內沒有連接成功,__connect_timeout_cb回調函數會被調用。因爲需要等待連接成功,所以將該事件加入到io事件監視器中,由loop等待connect成功(其實就是文字說明中的select實現的功能)。而connect的成功會使描述符成爲可寫的。所以這裏hio_add將該事件加入io事件監視器並設置感興趣的事件類型爲可寫HV_WRITE,註冊了回調函數hio_handle_events,等到可寫觸發時,該回調函數會被調用。設置完這些後,就從hloop_create_tcp_client返回。

調用hloop_create_tcp_client獲得了與服務端通信的io結構體,之後設置了一些回調函數,設置了可讀緩衝區。這個緩衝區用戶可以自己設置,如果不設置會使用在hloop_new中初始化的那個緩衝區,可以參考上一篇博客。

設置完後,調用hloop_run等待事件的發生。關於hloop_run的細節也參考之前的博客。根據上面的分析,其實已經有兩個事件加入到loop了,一個是嘗試建立連接的io事件,一個是定時器事件。那麼就有兩種可能,第一種是io事件觸發,第二種是定時器事件觸發。假設在定時器到期之前,連接建立成功,即可寫事件觸發,回調上面註冊的回調函數hio_handle_events:

static void hio_handle_events(hio_t* io) {
    if ((io->events & HV_READ) && (io->revents & HV_READ)) {
        if (io->accept) {
            nio_accept(io);
        }
        else {
            nio_read(io);
        }
    }

    if ((io->events & HV_WRITE) && (io->revents & HV_WRITE)) {
        // NOTE: del HV_WRITE, if write_queue empty
        if (write_queue_empty(&io->write_queue)) {
            iowatcher_del_event(io->loop, io->fd, HV_WRITE);
            io->events &= ~HV_WRITE;
        }
        if (io->connect) {
            // NOTE: connect just do once
            // ONESHOT
            io->connect = 0;

            nio_connect(io);
        }
        else {
            nio_write(io);
        }
    }

    io->revents = 0;
}

這個函數在上一篇也有分析,不過只分析了讀事件,這次是寫事件。假設該客戶端連接上一篇博客的服務端,這時候服務端也會觸發這個函數,不過服務端會調用這裏的nio_accept,而本客戶端調用的是nio_connect。有一個地方需要注意的是,在這裏調用iowatcher_del_event清除了前面註冊的HV_WRITE事件類型,原因是如果不清除HV_WRITE,之後會一直觸發可寫,造成busy-loop。

static void nio_connect(hio_t* io) {
    //printd("nio_connect connfd=%d\n", io->fd);
    socklen_t addrlen = sizeof(sockaddr_u);
    //獲取對端地址信息
    int ret = getpeername(io->fd, io->peeraddr, &addrlen);
    if (ret < 0) {
        io->error = socket_errno();
        printd("connect failed: %s: %d\n", strerror(socket_errno()), socket_errno());
        goto connect_failed;
    }
    else {
        addrlen = sizeof(sockaddr_u);
        getsockname(io->fd, io->localaddr, &addrlen);

        if (io->io_type == HIO_TYPE_SSL) {
            hssl_ctx_t ssl_ctx = hssl_ctx_instance();
            if (ssl_ctx == NULL) {
                goto connect_failed;
            }
            hssl_t ssl = hssl_new(ssl_ctx, io->fd);
            if (ssl == NULL) {
                goto connect_failed;
            }
            io->ssl = ssl;
            ssl_client_handshark(io);
        }
        else {
            // NOTE: SSL call connect_cb after handshark finished
            __connect_cb(io);
        }

        return;
    }

connect_failed:
    hio_close(io);
}

 根據博客開始的文字描述,可以知道即使套接字是可寫的,也有可能是因爲有錯誤發生,這裏的getpeername實際上也可以用來檢測connect是否成功。如果成功,調用__connect_cb,這裏先忽略ssl相關的內容。之前分析心跳的博客就涉及到了__connect_cb接口,當時提到這裏是設置心跳和keepalive的兩個位置之一,因爲心跳部分之前博客分析過了,這裏我把那部分代碼刪了,這裏主要分析與連接相關的內容。

static void __connect_cb(hio_t* io) {

    if (io->connect_timer) {
        htimer_del(io->connect_timer);
        io->connect_timer = NULL;
        io->connect_timeout = 0;
    }

    if (io->connect_cb) {
        io->connect_cb(io);
    }
}

在__connect_cb中,會關閉之前設置的定時器,因爲這個定時器就是防止connect長時間連接不成功的,這裏既然connect已經成功了,當然要把這個定時器刪了。刪除定時器後,調用用戶在調用hloop_create_tcp_client時註冊的回調函數。在本次tcp客戶端示例程序中,回調函數是這樣的:

void on_connect(hio_t* io) {
    //使能讀
    hio_read_start(io);
    //向服務端發送一條消息
    static char buf[] = "PING\r\n";
    hio_write(io, buf, 6);
}

這樣整個connect就成功了。

上面是io事件觸發的情況,還有一種就是io事件一直沒有觸發,可能一直在嘗試連接,那麼會發生超時,這時候設置的定時器被觸發,__connect_timeout_cb被調用:

static void __connect_timeout_cb(htimer_t* timer) {
    hio_t* io = (hio_t*)timer->privdata;
    if (io) {
        char localaddrstr[SOCKADDR_STRLEN] = {0};
        char peeraddrstr[SOCKADDR_STRLEN] = {0};
        hlogw("connect timeout [%s] <=> [%s]",
                SOCKADDR_STR(io->localaddr, localaddrstr),
                SOCKADDR_STR(io->peeraddr, peeraddrstr));
        io->error = ETIMEDOUT;
        hio_close(io);
    }
}

 在定時器回調中,會關閉這次的連接。連接服務器失敗。。。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章