1. 程式人生 > 實用技巧 >基於c++和asio的網路程式設計框架asio2教程基礎篇:4、使用tcp客戶端傳送資料時,如何在傳送資料的程式碼處直接獲取到服務端返回的結果資料

基於c++和asio的網路程式設計框架asio2教程基礎篇:4、使用tcp客戶端傳送資料時,如何在傳送資料的程式碼處直接獲取到服務端返回的結果資料

目錄

問題描述

asio2::tcp_client client;

client.bind_recv([&](std::string_view data)
{
	// 第2步,會在這裡收到服務端返回的資料
		
	// 傳送資料和接收資料完全在兩個位置,使用者無法在傳送資料的程式碼處直接獲取到服務端返回的資料
});

client.start(host, port, "\r\n");

// 第1步,在這裡傳送資料
client.send("get_user\r\n");

我見過的網路框架的傳送資料和接收資料的處理都和上面的邏輯類似,這種流程有個問題是使用者沒有辦法在傳送資料的程式碼的地方,直接得到遠端返回的結果資料。

大家可能都聽過rpc這個詞,rpc就是“遠端過程呼叫”了,rpc的協議通常都是私有的,規定好的協議。所以,如果使用者的協議是自定義的,那rpc通常就也不能用了。

asio2框架從2.7版本後解決了這個問題,我給這個功能取了個名字叫rdc,即remote data call,即遠端資料呼叫,可以解決各種使用者自定義協議下的資料同步形式的獲取。這個功能對於一些簡單的通訊場景,比如發一條訊息後緊接著收一條訊息,還是很有用的。

使用步驟詳細描述如下:

最基礎的使用示例

服務端程式碼
// 假定你的協議格式為  id,content
// 協議全部採用字串形式,比如 1,get_user\r\n
// 其中1表示唯一id    get_user表示內容  id和內容之間用逗號隔開  
// 注意字串的結尾要加上\r\n表示按\r\n進行拆包

asio2::tcp_server server;

server.bind_recv([&](auto & session_ptr, std::string_view s)
{
	// 查詢資料中的逗號
	auto pos = s.find(',');

	// 如果找到逗號了
	if (pos != std::string_view::npos)
	{
		// 取出協議的內容
		// 如果內容是get_user
		// substr的第二個引數8表示取前8個字元,這樣做的目的是將結尾的\r\n去掉
		if (s.substr(pos + 1, 8) == "get_user")
		{
			// 重新構造一個結果字串準備返回給客戶端
			std::string str;
			// 首先將接收資料中的id拷貝到結果資料中
			// 必須這樣做,才能讓客戶端在接收到結果資料時,從結果資料中解析出id
			// 然後才能把解析出的id和客戶端傳送資料時的id對應起來
			str = s.substr(0, s.find(',') + 1);
			// 在結果資料中隨便新增一點內容
			str += "my name is hanmeimei";
			str += "\r\n";
			// 直接傳送結果資料
			session_ptr->send(str);
		}
	}
});

// 傳送資料的解析函式,當你傳送資料時,框架內部會自動呼叫這個函式來從傳送的資料中解析出
// 唯一id,asio2的tcp在傳送資料時,資料最終都會變為std::string_view格式,所以這個
// 解析函式的引數是(std::string_view data)這個引數就表示你真正傳送出去的資料
auto parser = [](std::string_view data)
{
	// 將協議中前面的字串形式的id取出來 然後轉化為int型別的id
	// 為什麼這樣轉化?請看前面介紹:假定你的協議格式為  id,content
	// 有人可能會問:這裡能不能直接返回字串形式的id呢?可以的,後面深入部分有介紹
	int id = std::stoi(std::string{ data.substr(0, data.find(',')) });

	return id;
};

// 引數"\r\n"表示將資料按照"\r\n"進行拆分(如果對tcp拆包知識不瞭解可通過網路搜尋去了解)
// 引數std::move(parser)表示將上面定義的傳送資料的解析函式傳到asio2框架內部
// 注意:這裡用"\r\n"來作為資料拆分標誌,並不意味著,你的協議也必須要用"\r\n"拆分,實際上
// 任何自定義的協議,都是支援的。比如按'\n'單個字元來拆分,那第3個引數就傳'\n'就行了,
// 如果是複雜的自定義協議,那第3個引數就傳match_role就行了,具體的自定義協議的解析和拆分,
// 請看本部落格中關於match condition相關的文章介紹。
// 另外,如果你要是不用“遠端資料呼叫”這個功能的話,下面的第4個引數你是不需要傳的,就傳前
// 3個引數就行了,也就是說,想用“遠端資料呼叫”這個功能,就傳parser,不想用就不傳,如果
// 沒有傳parser這個引數,那麼框架在編譯期就會把該功能禁用掉,所以用不用“遠端資料呼叫”
// 這個功能,對執行時的效能沒有任何損失。
server.start(host, port, "\r\n", std::move(parser));
客戶端程式碼
asio2::tcp_client client;
	
client.bind_connect([&](asio::error_code ec)
{
	if (!ec)
	{
		// 連線成功 演示非同步遠端呼叫

		// 為什麼字串是下面的樣子,請看上面服務端程式碼部分對協議的說明
		std::string s = "1,get_user\r\n";

		// async_call 表示非同步遠端呼叫
		// 第1個引數是要傳送的資料 這個引數和呼叫send函式傳送資料時第1個引數的性質完全一樣
		// 第2個引數表示回撥函式,當服務端返回資料時,或者超時了,這個回撥函式就會被呼叫
		// 第3個引數表示超時時間,這個引數可以為空,為空時會使用預設超時,預設超時可用
		//      client.default_timeout(...)來設定
		client.async_call(s, [](asio::error_code ec, std::string_view data)
		{
			// 回撥函式的第1個引數表示有沒有錯誤,比如超時了,那麼錯誤碼就是asio::error::timed_out
			if (!ec)
			{
				// 如果沒有錯誤 可以處理資料了
				// data 就是服務端返回的資料 這裡就不演示怎麼處理資料了
				// data的內容是服務端返回的"1,my name is hanmeimei\r\n"
				std::ignore = data;
			}
		}, std::chrono::seconds(3));
	}
});

// 傳送資料的解析函式 具體含義和上面服務端的傳送資料的解析函式相同
auto parser = [](std::string_view data)
{
	int id = std::stoi(std::string{ data.substr(0, data.find(',')) });

	return id;
};

// 
client.start(host, port, "\r\n", std::move(parser));

// 演示同步遠端呼叫
asio::error_code ec;
std::string s = "2,get_user\r\n";

// 注意call<std::string>這裡的這個模板引數,表示返回的資料型別,因為我們的協議用的
// 是一個字串,所以這裡我們就直接用std::string來接收返回的資料了,注意不能用std::string_view
// 因為一旦client.call這個同步遠端呼叫結束返回後,client物件的socket接收緩衝區的
// 內容可能就被銷燬無效了。那為什麼前面的非同步遠端呼叫async_call函式的回撥函式引數
// 可以是std::string_view呢?因為asio2框架內部是在接收到資料後直接呼叫了該回調
// 函式,在回撥函式未結束之前,client物件的socket接收緩衝區一直被佔用著的。

// 第1個引數表示要傳送的資料
// 第2個引數表示超時時間 此引數可以為空 為空時使用預設超時 (具體請看原始碼中的各個函式過載)
// 第3個引數是個一個錯誤碼,如果發生錯誤,錯誤資訊會儲存在ec中,此引數可以為空,為空時
//     如果出現異常會throw一個異常,所以此引數為空時,需要用try catch將下面這段程式碼包起來
std::string ret = client.call<std::string>(s, std::chrono::seconds(3), ec);
// 呼叫成功後,ret的內容就是服務端返回的"2,my name is hanmeimei\r\n"

深入功能1:使用自定義型別的返回資料

問:上面的async_call回撥函式的引數是(std::string_view data),其中data表示接收到的資料內容,那麼有人可能會問,我能用自定義的資料型別來儲存接收到的資料內容嗎?
答:可以的。

// 首先定義一個使用者自定義的型別 然後就是如何把接收到的資料內容轉換為自定義型別的問題了
struct userinfo
{
	std::string content;

	// 必須實現一個無引數的建構函式
	userinfo() = default;

	// 如何將接收到的資料轉換為userinfo型別呢?
	// 方法1:提供一個(std::string_view s)的建構函式
	userinfo(std::string_view s)
	{
		// s就是接收到到的資料內容,這裡就是簡單的把s的內容儲存到成員變數content中了
		content = s.substr(s.find(',') + 1);
	}

	// 方法2:提供一個operator=(std::string_view s)的等於操作符函式
	void operator=(std::string_view s)
	{
		content = s.substr(s.find(',') + 1);
	}

	// 方法3:提供一個operator<<(std::string_view s)的輸入操作符函式
	//void operator<<(std::string_view s)
	//{
	//	content = s.substr(s.find(',') + 1);
	//}
};

// 方法4:提供一個全域性的operator<<(...)的輸入操作符函式
// 注意方法3和4同時只能有一個 方法1到4只提供一個即可
void operator<<(userinfo& u, std::string_view s)
{
	u.content = s.substr(s.find(',') + 1);
}

然後直接按自定義型別進行呼叫:

// 非同步遠端呼叫
client.async_call(s, [](asio::error_code ec, userinfo u)
{
	// 注意回撥函式的第2個引數是userinfo u
});

// 同步遠端呼叫
userinfo info = client.call<userinfo>(s, std::chrono::seconds(3), ec);

深入功能2:使用自定義型別的唯一id

問:上面的傳送資料的解析函式(即上面程式碼中宣告的變數parser)返回的唯一id的型別是int型的,我能用自定義的資料型別作為唯一id返回嗎?
答:可以的。
除了用基本int型別作為唯一id,用字串std::string等各種c++標準庫自帶的型別都是可以的。只要這個c++標準庫自帶的型別能用作multimap的key就行(框架內部是用multimap來儲存id的)。
比如,最常見的,你可以用uuid作為唯一id,這時就需要用字串std::string型別來作為唯一id了。
除此之外,你也可以用你自己的自定義的型別作為id的型別,如下:


// 首先定義一個自定義的唯一id型別
struct custom_id
{
	std::string country;
	std::string name;

	// 由於框架內部使用的是multimap來儲存id的,所以自定義的型別,必須提供一個比較函式
	// 方法1:提供一個小於的比較操作符成員函式過載
	//bool operator<(const custom_id &p) const
	//{
	//	return (this->country + this->name < p.country + p.name);
	//}
};

// 方法2:提供一個std標準庫的std::less的全域性函式過載
template <>
struct std::less<custom_id>
{
public:
	bool operator()(const custom_id &p1, const custom_id &p2) const
	{
		return (p1.country + p1.name < p2.country + p2.name);
	}
};

然後在傳送資料的解析函式中去轉換即可,如下:

auto parser = [](std::string_view data)
{
	// 然後按照你的實際需求將傳送資料裡面的id相關的資訊取出來,轉化為
	// 自定義型別變數並返回即可
	// 至於怎麼轉換,下面只是簡單的舉例,你完全可以按照你自己的協議去做
	custom_id id;
	id.name = data.substr(0, data.find(','));

	return id;
};

深入功能3:鏈式呼叫

問:上面的非同步遠端呼叫函式async_call和同步遠端呼叫函式call裡面的引數比較多,容易忘記引數順序,有沒有好的辦法處理呢?
答:使用鏈式呼叫。

// 非同步鏈式呼叫 timeout async_call response的順序完全隨意
client.timeout(std::chrono::seconds(5)).async_call(s).response(
	[](asio::error_code ec, userinfo info)
{
});
// 同步鏈式呼叫 timeout errcode順序隨意,但call必須是最後一個呼叫節點
client.timeout(std::chrono::seconds(3)).errcode(ec).call<std::string>(s);

深入功能4:服務端和客戶端互相呼叫

問:上面演示的都是客戶端遠端呼叫服務端的,服務端能不能遠端呼叫客戶端呢?
答:可以。使用方法和前面的說明基本完全相同。

session_ptr->async_call(str, [](asio::error_code ec, std::string_view data)
{
}, std::chrono::seconds(3));

深入功能5:傳送資料和接收資料使用不同的解析函式

問:上面的演示程式碼中只使用了“傳送資料的解析函式parser”,如果傳送資料所使用的協議,和接收資料所使用的協議不同,無法用同一個解析函式解析出唯一id,該怎麼辦呢?
答:同時提供傳送資料的解析函式,和接收資料的解析函式即可。
實際上框架內部有兩個parser,一個傳送解析的parser,一個接收解析的parser。如果你只傳了一個parser,那麼這個parser既用來解析傳送資料,也用來解析接收資料。

auto send_parser = [](std::string_view data)
{
	// 然後按照你的實際需求將傳送資料裡面的id相關的資訊取出來,轉化為
	// 自定義型別變數並返回即可
	// 至於怎麼轉換,下面只是簡單的舉例,你完全可以按照你自己的協議去做
	custom_id id;
	id.name = data.substr(0, data.find(','));

	return id;
};

auto recv_parser = [](std::string_view data)
{
	// 注意:雖然可以同時提供傳送資料的解析函式,和接收資料的解析函式
	// 但是返回的id的型別必須相同,因為框架內部是根據id來查找回調函式的
	// 如果id型別不一樣,就沒法處理了。
	// 還有,這裡只是示意怎麼使用,並沒有按照協議的不同做不同的解析,這個
	// 使用者根據自己的實際需求去做就行了。
	custom_id id;
	id.country = data.substr(0, data.find(','));

	return id;
};

server.start(host, port, "\r\n", std::move(send_parser), recv_parser);

深入功能6:兩次傳送的資料的唯一id相同可以嗎?

答:可以的。

auto parser = [](std::string_view data)
{
	// 實際上,你可以在這裡直接返回0 (返回其它的數字或字串,等等,都可以)
	// 只要每次返回的id都相同,這就表示所有傳送出去的的資料,對方在返回結果
	// 資料後,會按照發送時的順序,依次呼叫你的回撥函式。
	return 0;
};

client.start(host, port, "\r\n", std::move(parser));
auto parser = [](std::string_view data)
{
	// 如果協議中沒有包含有效的id,那麼就返回0,這樣也是可以的
	// 當接收到資料時,會按照發送時的順序,依次呼叫id是0的回撥函式
	// 當然,傳送資料解析出的id如果是0,那麼接收資料解析出的id也
	// 一定要是0,只不過可以有多個數據都返回了0這個id
	if (data.find(',') == std::string_view::npos)
		return 0;
	// 如果協議中包含了有效的id 那麼就返回該id作為唯一id
	// 返回了id的,當接收到資料時,就會按照接收資料解析出來的id
	// 來呼叫你對應的回撥函式
	return std::stoi(std::string{ data.substr(0, data.find(',')) });
};

client.start(host, port, "\r\n", std::move(parser));

但是:如果兩次傳送的資料的id相同的話,一旦網路不穩定,是會出現問題的。我用下面的流程舉例:
1、client呼叫call或async_call,傳送資料,假如id是0,超時是3秒,框架會把0存到multimap中;
2、server在3秒內一直不回覆;
3、client的超時到了,你的回撥函式被呼叫,同時框架內部會把multimap中id是0的元素刪掉(實際只刪第1個id是0的元素,假如有多個id是0的話)
4、client再次呼叫call或async_call,傳送資料,id還是0,框架會把0再存到multimap中;
5、此時server回覆了,但回覆的資訊是針對第1步中那個id為0的訊息進行回覆的;
6、client中,你的回撥函式被呼叫,但在這個回撥函式中接收的資料實際上是第1次call(或async_call)的結果,而不是第2次call的結果,因為兩次id都是0,框架是無法識別哪個回覆對應哪個請求的,只能按照順序取出multimap中儲存的回撥函式進行呼叫。

但是如果每個資料的id都是不同的,那就不會存在上面的問題。

所以如果你的協議中,有id相同的情況的話,而且對資料的正確性要求非常嚴格,那就不能這樣用了,網路的延遲和波動等等問題很難說,長時間執行的話,是一定會出問題的。當然如果對資料的正確性要求不那麼嚴格,那id相同也沒什麼問題。

深入功能7:服務端是別人寫的不能改,只有客戶端是我寫的能用這個功能嗎?

答:可以的。
實際上這個功能只和你的協議以及響應模式有關(就是說你傳送一條,對方就給你回一條,而不是給你回多條,也就是隻要是一發一收,一對一的關係就能用)

深入功能8:call,async_call 和 send可以混用嗎?

答:可以的。
如果你想在傳送資料的程式碼的地方直接取得對方返回的結果資料,那就用call或async_call。
如果只是想傳送資料,並不想立即取得結果,那就用send即可。
不管是send還是call或async_call,都會發送資料,區別在於,send傳送資料時,框架內部沒有從你傳送的資料中解析出id,當然也就沒有儲存該id了。send函式把資料發出去之後就不管了。但call或async_call把資料發出去之後,如果收到對方回覆的資料了,框架內部還會解析接收到資料,然後呼叫你提供的回撥函式等。

但是如果你在啟動某元件時沒有傳入parser資料解析器(比如這樣client.start(host, port, "\r\n");沒有第4個引數,也就是說沒有傳parser),那麼call和async_call是用不了。

還有一點是用來發送資料的send函式有個過載版本,也可以傳入一個回撥函式,如
client.send("abc",[](std::size_t bytes_sent)
{
});
這個回撥函式只是用來通知你,這次傳送的資料,是傳送成功了還是傳送失敗了。並不是收到了遠端給你的回覆資料的通知。所以這點不要搞混淆了,只有call和async_call的回撥函式才是“收到了遠端的回覆資料的通知”。

實現流程簡要說明

流程是這樣的:

1、客戶端呼叫call或async_call傳送資料
2、asio2框架內部 呼叫 parser 解析傳送的資料,得到id 存起來(存到一個multimap中了)
3、服務端返回資料,框架內部 呼叫 parser 解析接收的資料,得到id ,到multimap中找前面存起來的那個id,找到了,就呼叫該id對應的回撥函式

服務端遠端呼叫客戶端流程是一樣的。

上面是用tcp通訊來舉例的,實際上asio2框架裡的所有元件tcp udp http websocket ssl 串列埠,全部支援遠端資料呼叫功能。

其中udp websocket元件中的使用方式和tcp中的使用方式完全一樣,因為這幾個協議的形為一致,而且內部都是用std::string_view來傳遞資料的。這裡就不再為udp websocket列舉程式碼來介紹怎麼用了。

但http的使用方式是不一樣的,下面用程式碼說明:

http下遠端資料呼叫步驟

asio2::http_client client;

// http client 的遠端資料呼叫必須同時提供傳送資料的解析函式和接收資料的解析函式
// 因為http client在傳送資料時,框架底層的資料格式是request型別
// 而http client在接收資料時,框架底層的資料格式是response型別
// 傳送和接收資料的型別不一樣,所以必須提供兩個parser

// 為什麼都是return 0;呢?這是由http 1.1協議的特性決定的,http client先發送請求,
// 然後http server再回復請求,而且http協議通常是一對一的,即一次請求對應
// 一次回覆,所以再用id就沒有多大意義了,但又必須得返回個id,所以就return 0了
auto send_parser = [](http::request&) { return 0; };

auto recv_parser = [](http::response&) { return 0; };

if (client.start(host, port, send_parser, recv_parser))
{
	http::request req(http::verb::get, "/", 11);

	asio::error_code ec;

	// 同步呼叫
	http::response rep = client.call<http::response>(req, ec);

	// 非同步呼叫
	// 注意有個細節:上面的同步呼叫時返回的是http::response物件,而這裡的非同步
	// 呼叫的回撥函式的引數http::response&是個引用型別,因為同步呼叫函式如果
	// 返回引用會不安全,這個和上面介紹的tcp下同步呼叫不能返回std::string_view
	// 是相同的原因。
	client.async_call(std::move(req), [](asio::error_code ec, http::response& rep)
	{
		std::ignore = rep;
	});
}

上面是http client使用遠端資料呼叫的方法,那http server能遠端呼叫http client嗎?

不能的,這還是由http協議的特性決定的,只能由http client主動向http server傳送請求,然後http server回覆,而不能由http server傳送請求,http client來回復。(目前asio2框架裡只支援到http 1.1,http 2.0協議上的不同暫不考慮)

其實把http元件也做上遠端資料呼叫這個功能,我感覺意義並不大,但還是做上吧,反正即使不用這個功能的話,對效能沒有任何損失(都是模板實現的,如果不用這個功能的話,編譯期已經去掉該功能了)。

但是,http session如果升級到websocket之後,是支援向websocket client主動發出遠端資料呼叫的。