第7章 muduo编程示例(1)

本章将介绍如何用muduo网络库完成常见的TCP网络编程任务。内容如下:

1.[UNP]中的五个简单协议,包括echo、daytime、time、discard、chargen等。

2.文件传输,示范非阻塞TCP网络程序中如何完整地发送数据。

3.Boost.Asio中的示例,包括timer2~6、chat等。chat实现了TCP封包与拆包(codec)。

4.muduo Buffer class的设计与使用。

5.Protobuf编码解码器(codec)与消息分发器(dispatcher)。

6.限制服务器的最大并发连接数。

7.Java Netty中的示例,包括discard、echo、uptime等,其中的discard和echo带流量统计功能。

8.用于测试两台机器的往返延迟的roundtrip。

9.用timing wheel踢掉空闲连接。

10.一个基于TCP的应用层广播hub。

11.云风的串并转换连接服务器multiplexer,及其自动化测试。

12.socks4a代理服务器,包括简单的TCP中继(relay)。

13.一个提供短址服务的httpd服务器。

14.与其他库的集成,包括UDNS、c-ares DNS、curl等等。

这些例子都比较简单,逻辑不复杂,代码也很短,适合摘取关键部分放到博客上。其中一些有一定的代表性与针对性,比如“如何传输完整的文件”估计是网络编程的初学者经常遇到的问题。请注意,muduo是设计来开发内网的网络程序,它没有做任何安全方面的加强措施,如果用在公网上可能会受到攻击,在后面的例子中我会谈到这一点。

7.1 五个简单TCP示例

本节将介绍五个简单TCP网络服务程序,包括echo(RFC 862)、discard(RFC 863)、chargen(RFC 864)、daytime(RFC 867)、time(RFC 868)这五个协议,以及time协议的客户端。各程序的协议简介如下。

● discard:丢弃所有收到的数据。

● daytime:服务端accept连接之后,以字符串形式发送当前时间,然后主动断开连接。

● time:服务端accept连接之后,以二进制形式发送当前时间(从Epoch到现在的秒数),然后主动断开连接;我们需要一个客户程序来把收到的时间转换为字符串。

● echo:回显服务,把收到的数据发回客户端。

● chargen:服务端accept连接之后,不停地发送测试数据。

以上五个协议使用不同的端口,可以放到同一个进程中实现,且不必使用多线程。完整的代码见muduo/examples/simple。

discard

discard恐怕算是最简单的长连接TCP应用层协议,它只需要关注“三个半事件”中的“消息/数据到达”事件(6.4.1节),事件处理函数如下:

void DiscardServer::onMessage(const TcpConnectionPtr& conn,
                            	Buffer* buf, Timestamp time)
{
	string msg(buf->retrieveAllAsString());
	LOG_INFO << conn->name() << " discards " << msg.size()
		<< " bytes received at " << time.toString();
}

与前面p.139的echo服务相比,除了省略namespace外,关键的区别在于少了L40:将收到的数据发回客户端。

剩下的都是例行公事的代码,此处从略,读者可对比参考echo服务。

-------分隔线------------------------------------

笔记:

//DiscardServer.h    丢弃所有收到的数据
#ifndef EXAMPLE_SIMPLE_DISCARD_H
#define EXAMPLE_SIMPLE_DISCARD_H

#include "muduo/net/TcpServer.h"

using namespace muduo;
using namespace muduo::net;

//RFC 863
class DiscardServer
{
public:
	DiscardServer(EventLoop* loop, const InetAddress& listenAddr);
	void start();

private:
	void onConnection(const TcpConnectionPtr& conn);
	void onMessage(const TcpConnectionPtr& conn,
								Buffer* buf, Timestamp time);

	TcpServer server_;
};

#endif //EXAMPLE_SIMPLE_DISCARD_H

DiscardServer.cpp文件

//DiscardServer.cpp
#include "DiscardServer.h"

#include "muduo/base/Logging.h"

DiscardServer::DiscardServer(EventLoop* loop,
	const InetAddress& listenAddr)
	:server_(loop, listenAddr, "DiscardServer")
{
	server_.setConnectionCallback(std::bind(&DiscardServer::onConnection, this, _1));
	server_.setMessageCallback(std::bind(&DiscardServer::onMessage, this, _1, _2, _3));
}

void DiscardServer::start()
{
	server_.start();
}

void DiscardServer::onConnection(const TcpConnectionPtr& conn)
{
	LOG_INFO << "DiscardServer - " << conn->peerAddress().toIpPort() << " -> "
		<< conn->localAddress().toIpPort() << " is "
		<< (conn->connected() ? "UP" : "DOWN");
}

void DiscardServer::onMessage(const TcpConnectionPtr& conn,
	Buffer* buf, Timestamp time)
{
	string msg(buf->retrieveAllAsString());
	LOG_INFO << conn->name() << " discards " << msg.size()
		<< " bytes received at " << time.toString();
}

main.cpp主函数

//main.cpp DiscardServer的主函数
#include "DiscardServer.h"
#include "muduo/net/EventLoop.h"
#include "muduo/base/Logging.h"

#include <unistd.h>
#include <iostream>

int main()
{
	LOG_INFO << "pid = " << getpid();
	EventLoop loop;
	InetAddress listenAddr(2009);
	DiscardServer server(&loop, listenAddr);
	server.start();
	std::cout << "DiscardServer start" << std::endl;
	loop.loop();
	return 0;
}

编译运行:

客户端

-------分隔线------------------------------------

daytime

daytime是短连接协议,在发送完当前时间后,由服务端主动断开连接。它只需要关注“三个半事件”中的“连接已建立”事件,事件处理函数如下:

void DaytimeServer::onConnection(const TcpConnectionPtr& conn)
{
    LOG_INFO << "DaytimeServer - " << conn->peerAddress().toIpPort() << " -> "
             << conn->localAddress().toIpPort() << " is "
             << (conn->connected() ? "UP" : "DOWN");
    if(conn->connected())
    {
        conn->send(Timestamp::now().toFormattedString() + "\n");//L34发送时间字符串
        conn->shutdown();//L35主动断开连接
    }
}

L34发送时间字符串,L35主动断开连接。剩下的都是例行公事的代码,为节省篇幅,此处从略。

用netcat扮演客户端,运行结果如下:

-------分隔线------------------------------------

笔记:

//DaytimeServer.h   在发送完当前时间后,由服务端主动断开连接
#ifndef EXAMPLE_SIMPLE_DAYTIME_H
#define EXAMPLE_SIMPLE_DAYTIME_H

#include "muduo/net/TcpServer.h"

using namespace muduo;
using namespace muduo::net;

//RFC 867
class DaytimeServer
{
public:
	DaytimeServer(EventLoop* loop, const InetAddress& listenAddr);
	void start();

private:
	void onConnection(const TcpConnectionPtr& conn);
	void onMessage(const TcpConnectionPtr& conn,
								Buffer* buf, Timestamp time);

	TcpServer server_;
};
#endif // EXAMPLE_SIMPLE_DAYTIME_H

DaytimeServer.cpp文件:

//DaytimeServer.cpp
#include "DaytimeServer.h"

#include "muduo/base/Logging.h"

DaytimeServer::DaytimeServer(EventLoop* loop,
	const InetAddress& listenAddr)
	:server_(loop, listenAddr, "DaytimeServer")
{
	server_.setConnectionCallback(std::bind(&DaytimeServer::onConnection, this, _1));
	server_.setMessageCallback(std::bind(&DaytimeServer::onMessage, this, _1, _2, _3));
}

void DaytimeServer::start()
{
	server_.start();
}

void DaytimeServer::onConnection(const TcpConnectionPtr& conn)
{
	LOG_INFO << "DaytimeServer - " << conn->peerAddress().toIpPort() << " -> "
		<< conn->localAddress().toIpPort() << " is "
		<< (conn->connected() ? "UP" : "DOWN");
	if (conn->connected())
	{
		conn->send(Timestamp::now().toFormattedString() + "\n");
		conn->shutdown();
	}
}

void DaytimeServer::onMessage(const TcpConnectionPtr& conn,
	Buffer* buf, Timestamp time)
{
	string msg(buf->retrieveAllAsString());
	LOG_INFO << conn->name() << " discards " << msg.size()
		<< " bytes received at " << time.toString();
}

main.cpp文件

//main.cpp DaytimeServer的主函数
#include "DaytimeServer.h"
#include "muduo/net/EventLoop.h"
#include "muduo/base/Logging.h"

#include <unistd.h>
#include <iostream>

int main()
{
	LOG_INFO << "pid = " << getpid();
	EventLoop loop;
	InetAddress listenAddr(2013);
	DaytimeServer server(&loop, listenAddr);
	server.start();
	std::cout << "DaytimeServer start" << std::endl;
	loop.loop();
	return 0;
}

编译运行:

客户端连接成功后,马上就收到断开的信号

-------分隔线------------------------------------

time

time协议与daytime极为类似,只不过它返回的不是日期时间字符串,而是一个32-bit整数,表示从1970-01-0100:00:00Z到现在的秒数。当然,这个协议有“2038年问题”。服务端只需要关注“三个半事件”中的“连接已建立”事件,事件处理函数如下:

void TimeServer::onConnection(const TcpConnectionPtr& conn)
{
	LOG_INFO << "TimeServer - " << conn->peerAddress().toIpPort() << " -> "
		<< conn->localAddress().toIpPort() << " is "
		<< (conn->connected() ? "UP" : "DOWN");
	if (conn->connected())
	{
		time_t now = ::time(NULL);
		int32_t be32 = sockets::hostToNetwork32(static_cast<int32_t>(now));
		conn->send(&be32, sizeof be32);
		conn->shutdown();
	}
}

L34、L35取当前时间并转换为网络字节序(Big Endian),L36发送32-bit整数,L37主动断开连接。剩下的都是例行公事的代码,为节省篇幅,此处从略。

用netcat扮演客户端,并用hexdump来打印二进制数据,运行结果如下:

-------分隔线------------------------------------

笔记:

//TimeServer.h   在发送完当前时间戳(32位)后,由服务端主动断开连接
#ifndef EXAMPLE_SIMPLE_TIME_H
#define EXAMPLE_SIMPLE_TIME_H

#include "muduo/net/TcpServer.h"

using namespace muduo;
using namespace muduo::net;

//RFC 868
class TimeServer
{
public:
	TimeServer(EventLoop* loop, const InetAddress& listenAddr);
	void start();

private:
	void onConnection(const TcpConnectionPtr& conn);
	void onMessage(const TcpConnectionPtr& conn,
								Buffer* buf, Timestamp time);

	TcpServer server_;
};
#endif // EXAMPLE_SIMPLE_TIME_H

TimeServer.cpp文件:

//TimeServer.cpp
#include "TimeServer.h"

#include "muduo/base/Logging.h"
#include "muduo/net/Endian.h"

TimeServer::TimeServer(EventLoop* loop,
	const InetAddress& listenAddr)
	:server_(loop, listenAddr, "TimeServer")
{
	server_.setConnectionCallback(std::bind(&TimeServer::onConnection, this, _1));
	server_.setMessageCallback(std::bind(&TimeServer::onMessage, this, _1, _2, _3));
}

void TimeServer::start()
{
	server_.start();
}

void TimeServer::onConnection(const TcpConnectionPtr& conn)
{
	LOG_INFO << "TimeServer - " << conn->peerAddress().toIpPort() << " -> "
		<< conn->localAddress().toIpPort() << " is "
		<< (conn->connected() ? "UP" : "DOWN");
	if (conn->connected())
	{
		time_t now = ::time(NULL);
		int32_t be32 = sockets::hostToNetwork32(static_cast<int32_t>(now));
		conn->send(&be32, sizeof be32);
		conn->shutdown();
	}
}

void TimeServer::onMessage(const TcpConnectionPtr& conn,
	Buffer* buf, Timestamp time)
{
	string msg(buf->retrieveAllAsString());
	LOG_INFO << conn->name() << " discards " << msg.size()
		<< " bytes received at " << time.toString();
}

main.cpp

//main.cpp TimeServer的主函数
#include "TimeServer.h"
#include "muduo/net/EventLoop.h"
#include "muduo/base/Logging.h"

#include <unistd.h>
#include <iostream>

int main()
{
	LOG_INFO << "pid = " << getpid();
	EventLoop loop;
	InetAddress listenAddr(2037);
	TimeServer server(&loop, listenAddr);
	server.start();
	std::cout << "TimeServer start" << std::endl;
	loop.loop();
	return 0;
}

编译运行:

-------分隔线------------------------------------

time客户端

因为time服务端发送的是二进制数据,不便直接阅读,我们编写一个客户端来解析并打印收到的4个字节数据。这个程序只需要关注“三个半事件”中的“消息/数据到达”事件,事件处理函数如下:

void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp receiveTime)
{
    if (buf->readableBytes() >= sizeof(int32_t))
    {
        const void* data = buf->peek();
        int32_t be32 = *static_cast<const int32_t*>(data);
        buf->retrieve(sizeof(int32_t));//取数据
        time_t time = sockets::networkToHost32(be32);
        Timestamp ts(implicit_cast<uint64_t>(time) * Timestamp::kMicroSecondsPerSecond);
        LOG_INFO << "Server time = " << time << " , " << ts.toFormattedString();
    }
    else
    {
        LOG_INFO << conn->name() << " no enough data " << buf->readableBytes()
            << " at " << receiveTime.toFormattedString();
    }
}

注意其中考虑到了如果数据没有一次性收全,已经收到的数据会累积在Buffer里(在else分支里没有调用Buffer::retrieve*系列函数),以等待后续数据到达,程序也不会阻塞。这样即便服务器一个字节一个字节地发送数据,代码还是能正常工作,这也是非阻塞网络编程必须在用户态使用接收缓冲的主要原因。

这是我们第一次用到TcpClient class,完整的代码如下:

//timeclient.cpp 客户端
#include "muduo/base/Logging.h"
#include "muduo/net/Endian.h"
#include "muduo/net/EventLoop.h"
#include "muduo/net/InetAddress.h"
#include "muduo/net/TcpClient.h"

#include <utility>
#include <unistd.h>
#include <stdio.h>

using namespace muduo;
using namespace muduo::net;

class TimeClient : noncopyable
{
public:
	TimeClient(EventLoop* loop, const InetAddress& serverAddr)
		:loop_(loop),
		client_(loop, serverAddr, "TimeClient")
	{
		client_.setConnectionCallback(std::bind(&TimeClient::onConnection, this, _1));
		client_.setMessageCallback(std::bind(&TimeClient::onMessage, this, _1, _2, _3));
	}

	void connect()
	{
		client_.connect();
	}

private:
	EventLoop* loop_;
	TcpClient client_;

	void onConnection(const TcpConnectionPtr& conn)
	{
		LOG_INFO << conn->localAddress().toIpPort() << " -> "
			<< conn->peerAddress().toIpPort() << " is "
			<< (conn->connected() ? "UP" : "DOWN");
		if (!conn->connected())
		{
            loop_->quit();  //这行如果连接断开,就退出main()函数的loop.loop()事件循环,程序也就终止了,
		}
	}
	void onMessage(const TcpConnectionPtr& conn, Buffer* buf, Timestamp receiveTime)
	{
		if (buf->readableBytes() >= sizeof(int32_t))
		{
			const void* data = buf->peek();
			int32_t be32 = *static_cast<const int32_t*>(data);
			buf->retrieve(sizeof(int32_t));
			time_t time = sockets::networkToHost32(be32);
			Timestamp ts(implicit_cast<uint64_t>(time) * Timestamp::kMicroSecondsPerSecond);
			LOG_INFO << "Server time = " << time << " , " << ts.toFormattedString();
		}
		else
		{
			LOG_INFO << conn->name() << " no enough data " << buf->readableBytes()
				<< " at " << receiveTime.toFormattedString();
		}
	}
};

//以上L42表示如果连接断开,就退出事件循环(L75),程序也就终止了。
int main(int argc, char* argv[])
{
	LOG_INFO << "pid = " << getpid();
	if (argc > 1)
	{
		EventLoop loop;
		InetAddress serverAddr(argv[1], 2037);

		TimeClient timeClient(&loop, serverAddr);
		timeClient.connect();
		loop.loop();
	}
	else
	{
		printf("Usage: %s host_ip\n", argv[0]);
	}
	 
	return 0;
}

注意TcpConnection对象表示“一次”TCP连接,连接断开之后不能重建。TcpClient重试之后新建的连接会是另一个TcpConnection对象。

程序的运行结果如下(有折行),假设time server运行在本机:

-------分隔线------------------------------------

笔记:

QT测试端设置命令行参数:

运行结果:

-------分隔线------------------------------------

echo

前面几个协议都是单向接收或发送数据,echo是我们遇到的第一个双向的协议:服务端把客户端发过来的数据原封不动地传回去。它只需要关注“三个半事件”中的“消息/数据到达”事件,事件处理函数已在p.139列出,这里复制一遍。

void EchoServer::onMessage(const TcpConnectionPtr& conn,
	Buffer* buf, Timestamp time)
{
	muduo::string msg(buf->retrieveAllAsString());//取走多有字符数据
	LOG_INFO << conn->name() << " discards " << msg.size()
		<< " bytes received at " << time.toString();
	conn->send(msg); 
}

这段代码实现的不是行回显(line echo)服务,而是有一点数据就发送一点数据。这样可以避免客户端恶意地不发送换行字符,而服务端又必须缓存已经收到的数据,导致服务器内存暴涨。但这个程序还是有一个安全漏洞,即如果客户端故意不断发送数据,但从不接收,那么服务端的发送缓冲区会一直堆积,导致内存暴涨。解决办法可以参考下面的chargen协议,或者在发送缓冲区累积到一定大小时主动断开连接。一般来说,非阻塞网络编程中正确处理数据发送比接收数据要困难,因为要应对对方接收缓慢的情况。

练习1:修改EchoServer::onMessage(),实现大小写互换。

练习2:修改EchoServer::onMessage(),实现ROT13加密[注1:http://en.wikipedia.org/wiki/ROT13]。

-------分隔线------------------------------------

笔记:

//EchoServer.h   服务端把客户端发过来的数据原封不动地传回去。
#ifndef EXAMPLE_SIMPLE_ECHO_H
#define EXAMPLE_SIMPLE_ECHO_H

#include "muduo/net/TcpServer.h"

using namespace muduo;
using namespace muduo::net;

//RFC 862
class EchoServer
{
public:
	EchoServer(EventLoop* loop, const InetAddress& listenAddr);
	void start();			// calls server_.start();

private:
	void onConnection(const TcpConnectionPtr& conn);
	void onMessage(const TcpConnectionPtr& conn,
								Buffer* buf, Timestamp time);

	TcpServer server_;
};
#endif // EXAMPLE_SIMPLE_ECHO_H

EchoServer.cpp文件:

//EchoServer.cpp
#include "EchoServer.h"

#include "muduo/base/Logging.h"
#include "muduo/net/Endian.h"

EchoServer::EchoServer(EventLoop* loop,
	const InetAddress& listenAddr)
	:server_(loop, listenAddr, "TimeServer")
{
	server_.setConnectionCallback(std::bind(&EchoServer::onConnection, this, _1));
	server_.setMessageCallback(std::bind(&EchoServer::onMessage, this, _1, _2, _3));
}

void EchoServer::start()
{
	server_.start();
}

void EchoServer::onConnection(const TcpConnectionPtr& conn)
{
	LOG_INFO << "EchoServer - " << conn->peerAddress().toIpPort() << " -> "
		<< conn->localAddress().toIpPort() << " is "
		<< (conn->connected() ? "UP" : "DOWN");
}

void EchoServer::onMessage(const TcpConnectionPtr& conn,
	Buffer* buf, Timestamp time)
{
	muduo::string msg(buf->retrieveAllAsString());//取走多有字符数据
	LOG_INFO << conn->name() << " discards " << msg.size()
		<< " bytes received at " << time.toString();
	//把收到的数据再发回客户端
	conn->send(msg); //如果客户端不接收,只发送,服务端的发送缓冲区就一直占着?
}

main.cpp文件:

//main.cpp  EchoServer的主函数
#include "EchoServer.h"
#include "muduo/net/EventLoop.h"
#include "muduo/base/Logging.h"

#include <unistd.h>
#include <iostream>

int main()
{
	LOG_INFO << "pid = " << getpid();
	EventLoop loop;
	InetAddress listenAddr(2007);
	EchoServer server(&loop, listenAddr);
	server.start();
	std::cout << "EchoServer start" << std::endl;
	loop.loop();
	return 0;
}

编译运行:

-------分隔线------------------------------------

chargen

Chargen协议很特殊,它只发送数据,不接收数据。而且,它发送数据的速度不能快过客户端接收的速度,因此需要关注“三个半事件”中的半个“消息/数据发送完毕”事件(onWriteComplete),事件处理函数如下:

//ChargenServer.cpp
#include "ChargenServer.h"

#include "muduo/base/Logging.h"
#include "muduo/net/EventLoop.h"

#include <stdio.h>

using namespace muduo;
using namespace muduo::net;

ChargenServer::ChargenServer(EventLoop* loop,
	const InetAddress& listenAddr, 
	bool print)
	:server_(loop, listenAddr, "ChargenServer"),
	transferred_(0),
	startTime_(Timestamp::now())
{
	server_.setConnectionCallback(std::bind(&ChargenServer::onConnection, this, _1));
	server_.setMessageCallback(std::bind(&ChargenServer::onMessage, this, _1, _2, _3));
	server_.setWriteCompleteCallback(std::bind(&ChargenServer::onWriteComplete, this, _1));

	if (print)
	{
		// 每3秒打印吞吐量
		loop->runEvery(3.0, std::bind(&ChargenServer::printThroughput, this));
	}

	// 初始化要发送的数据
	string line;
	for (int i = 33; i < 127; ++i)
	{
		line.push_back(char(i));
	}

	line += line;
	for (size_t i = 0; i < 127 - 33; ++i)
	{
		message_ += line.substr(i, 72) + '\n';
	}
}

void ChargenServer::start()
{
	server_.start();
}

void ChargenServer::onConnection(const TcpConnectionPtr& conn)
{
	LOG_INFO << "ChargenServer - " << conn->peerAddress().toIpPort() << " -> "
		<< conn->localAddress().toIpPort() << " is "
		<< (conn->connected() ? "UP" : "DOWN");
	if (conn->connected())
	{
		// 开始发送数据
		conn->setTcpNoDelay(true);
		conn->send(message_);
	}
}

void ChargenServer::onMessage(const TcpConnectionPtr& conn,
	Buffer* buf, Timestamp time)
{
	muduo::string msg(buf->retrieveAllAsString());//取走多有字符数据
	LOG_INFO << conn->name() << " discards " << msg.size()
		<< " bytes received at " << time.toString();
}

void ChargenServer::onWriteComplete(const TcpConnectionPtr& conn)
{
	// 当发送缓冲区清空时,继续发送数据
	transferred_ += message_.size();
	conn->send(message_);
}

void ChargenServer::printThroughput()
{
	Timestamp endTime = Timestamp::now();
	double time = timeDifference(endTime, startTime_);
	printf("%4.3f MiB/s\n", static_cast<double>(transferred_) / time / 1024 / 1024);
	transferred_ = 0;
	startTime_ = endTime;
}

L57在连接建立时发送第一次数据;L73继续发送数据。剩下的都是例行公事的代码,为节省篇幅,此处从略。

完整的chargen服务端还带流量统计功能,用到了定时器,我们会在§ 7.8介绍定时器的使用,到时候再回头来看相关代码。

用netcat扮演客户端,运行结果如下:

$ nc localhost 2019|head

-------分隔线------------------------------------

笔记:

//ChargenServer.h   服务端accept连接之后,不停地发送测试数据。
#ifndef EXAMPLE_SIMPLE_CHARGEN_H
#define EXAMPLE_SIMPLE_CHARGEN_H

#include "muduo/net/TcpServer.h"

using namespace muduo;
using namespace muduo::net;

//RFC 864
class ChargenServer
{
public:
	ChargenServer(EventLoop* loop, const InetAddress& listenAddr,
						bool print = false);
	void start();		 

private:
	void onConnection(const TcpConnectionPtr& conn);
	void onMessage(const TcpConnectionPtr& conn,
								Buffer* buf, Timestamp time);
	void onWriteComplete(const TcpConnectionPtr& conn);
	void printThroughput();

	TcpServer server_;
	muduo::string message_;
	int64_t transferred_;
	Timestamp startTime_;
};
#endif // EXAMPLE_SIMPLE_CHARGEN_H

main.cpp文件:

//main.cpp  ChargenServer的主函数
#include "ChargenServer.h"
#include "muduo/net/EventLoop.h"
#include "muduo/base/Logging.h"

#include <unistd.h>
#include <iostream>

int main()
{
	LOG_INFO << "pid = " << getpid();
	EventLoop loop;
	InetAddress listenAddr(2019);
	ChargenServer server(&loop, listenAddr, true);
	server.start();
	std::cout << "ChargenServer start" << std::endl;
	loop.loop();
	return 0;
}

编译运行:

-------分隔线------------------------------------

五合一

前面五个程序都用到了EventLoop。这其实是个Reactor,用于注册和分发IO事件。muduo遵循one loop per thread模型,多个服务端(TcpServer)和客户端(TcpClient)可以共享同一个EventLoop,也可以分配到多个EventLoop上以发挥多核多线程的好处。这里我们把五个服务端用同一个EventLoop跑起来,程序还是单线程的,功能却强大了很多:

//main.cpp   5合一服务器
#include "chargen/ChargenServer.h"
#include "daytime/DaytimeServer.h"
#include "discard/DiscardServer.h"
#include "echo/EchoServer.h"
#include "time/TimeServer.h"
#include "muduo/base/Logging.h"
#include "muduo/net/EventLoop.h"

#include <unistd.h>
#include <iostream>

int main()
{
	LOG_INFO << "pid = " << getpid();
	EventLoop loop;  // one loop shared by multiple servers

	ChargenServer chargenServer(&loop, InetAddress(2019));
	chargenServer.start();

	DaytimeServer daytimeServer(&loop, InetAddress(2013));
	daytimeServer.start();

	DiscardServer discardServer(&loop, InetAddress(2009));
	discardServer.start();

	EchoServer echoServer(&loop, InetAddress(2007));
	echoServer.start();

	TimeServer timeServer(&loop, InetAddress(2037));
	timeServer.start();

	std::cout << "All in One Server start" << std::endl;
	loop.loop();
	return 0;
}

这个例子充分展示了Reactor模式复用线程的能力,让一个单线程程序同时具备多个网络服务功能。一个容易想到的例子是httpd同时侦听80端口和443端口,另一个例子是程序中有多个TcpClient,分别和数据库、Redis、Sudoku Solver等后台服务打交道。对于初次接触这种编程模型的读者,值得跟踪代码运行的详细过程,弄清楚每个事件每个回调发生的时机与条件

以上几个协议的消息格式都非常简单,没有涉及TCP网络编程中常见的分包处理,在后文§ 7.3讲Boost.Asio的聊天服务器时我们再来讨论这个问题。

7.2 文件传输

本节用发送文件的例子来说明TcpConnection::send()的使用。到目前为止,我们用到了TcpConnection::send()的两个重载,分别是send(const string&)[注2:p.139 L40.]和send(const void*message,size_t len)[注3:p.179,time示例中的L36.]。

TcpConnection目前提供了三个send()重载函数,原型如下。

// Tcp connection, for both client and server usage.

class TcpConnection:boost::noncopyable,
                    public boost:enable_shared_from_this<TcpConnection>
{
public:
    void send(const void* message, size_t len);
    void send(const StringPiece& message);
    void send(Buffer* message); // this one might swap data without copying 
    // void send(Buffer&& message); //C++11
    // void send(string&& message); //C++11

在非阻塞网络编程中,发送消息通常是由网络库完成的,用户代码不会直接调用write(2)或send(2)等系统调用。原因见p.205“TcpConnection必须要有output buffer”。在使用TcpConnection::send()时值得注意的有几点:

● send()的返回类型是void,意味着用户不必关心调用send()时成功发送了多少字节,muduo库会保证把数据发送给对方。

● send()是非阻塞的。意味着客户代码只管把一条消息准备好,调用send()来发送,即便TCP的发送窗口满了,也绝对不会阻塞当前调用线程。

● send()是线程安全、原子的。多个线程可以同时调用send(),消息之间不会混叠或交织。但是多个线程同时发送的消息(s)的先后顺序是不确定的,muduo只能保证每个消息本身的完整性[注4:假设两个线程同时各自发送了一条任意长度的消息,那么这两条消息a、b的发送顺序要么是先a后b,要么是先b后a,不会出现“a的前一半,b,a的后一半”这种交织情况。]。另外,send()在多线程下仍然是非阻塞的。

● send(const void*message,size_t len)这个重载最平淡无奇,可以发送任意字节序列。

● send(const StringPiece&message)这个重载可以发送std::string和const char*,其中StringPiece [注5:代码位于muduo/base/StringPiece.h.]是Google发明的专门用于传递字符串参数的class,这样程序里就不必为const char*和const std::string&提供两份重载了。

● send(Buffer*)有点特殊,它以指针为参数,而不是常见的const引用,因为函数中可能用Buffer::swap()来高效地交换数据,避免内存拷贝[注6:目前的实现尚未照此办理。],起到类似C++右值引用的效果。

● 如果将来支持C++11,那么可以增加对右值引用的重载,这样可以用move语义来避免内存拷贝。

下面我们来实现一个发送文件的命令行小工具,这个工具的协议很简单,在启动时通过命令行参数指定要发送的文件,然后在2021端口侦听,每当有新连接进来,就把文件内容完整地发送给对方。

如果不考虑并发,那么这个功能用netcat加重定向就能实现。这里展示的版本更加健壮,比方说发送100MB的文件,支持上万个并发客户连接;内存消耗只与并发连接数有关,跟文件大小无关;任何连接可以在任何时候断开,程序不会有内存泄漏或崩溃[注7:我用Java实现了压力测试,代码位于examples/filetransfer/loadtest.]。

我一共写了三个版本,代码位于examples/filetransfer。

1.一次性把文件读入内存,一次性调用send(const string&)发送完毕。这个版本满足除了“内存消耗只与并发连接数有关,跟文件大小无关”之外的健壮性要求。

2.一块一块地发送文件,减少内存使用,用到了WriteCompleteCallback。这个版本满足了上述全部健壮性要求。

3.同2,但是采用shared_ptr来管理FILE*,避免手动调用::fclose(3)。

版本一

在建立好连接之后,把文件的全部内容读入一个string,一次性调用TcpConnection::send()发送。不用担心文件发送不完整。也不用担心send()之后立刻shutdown()会有什么问题,见下一节的说明。

-------分隔线------------------------------------

笔记:

//download.cpp  文件传输版本一
#include "muduo/base/Logging.h"
#include "muduo/net/EventLoop.h"
#include "muduo/net/TcpServer.h"

#include <stdio.h>
#include <unistd.h>

using namespace muduo;
using namespace muduo::net;

const char* g_file = NULL;

//FIXME: use FileUtil::readFile()
string readFile(const char* filename)
{
	string content;
	FILE* fp = ::fopen(filename, "rb");
	if (fp)
	{
		// inefficient!!!   低效
		const int kBufSize = 1024 * 1024;
		char iobuf[kBufSize];
		::setbuffer(fp, iobuf, sizeof(iobuf));

		char buf[kBufSize];
		size_t nread = 0;
		while ((nread = ::fread(buf, 1, sizeof(buf), fp)) > 0)
		{
			content.append(buf, nread);
		}
		::fclose(fp);
	}
	return content;
}

void onHighWaterMark(const TcpConnectionPtr& conn, size_t len)
{
	LOG_INFO << "HighWaterMark " << len;
}

void onConnection(const TcpConnectionPtr& conn)
{
	LOG_INFO << "FileServer - " << conn->peerAddress().toIpPort() << " -> "
		<< conn->localAddress().toIpPort() << " is "
		<< (conn->connected() ? "UP" : "DOWN");
	if (conn->connected())
	{
		LOG_INFO << "FileServer - Sending file " << g_file
			<< " to " << conn->peerAddress().toIpPort();
		conn->setHighWaterMarkCallback(onHighWaterMark, 64 * 1024);
		string fileContent = readFile(g_file);
		conn->send(fileContent);
		conn->shutdown();//发送完成后,断开连接
		LOG_INFO << "FileServer - done";
	}
}
int main(int argc, char* argv[])
{
	LOG_INFO << "pid = " << getpid();
	if (argc > 1)
	{
		g_file = argv[1];

		EventLoop loop;
		InetAddress listenAddr(2021);
		TcpServer server(&loop, listenAddr, "FileServer");
		server.setConnectionCallback(onConnection);
		server.start();
		printf("File Server start\n");
		loop.loop();
	}
	else
	{
		fprintf(stderr, "Usage:%s file_for _downloading\n", argv[0]);
	}
	return 0;
}

编译运行,这里用test1文件测试,只有一行数据,服务端发送完数据后,断开连接

$ ./chapter7.out /home/scott/trunk/test1

客户端接收数据,然后收到断开信号

-------分隔线------------------------------------

注意每次建立连接的时候我们都去重新读一遍文件,这是考虑到文件有可能被其他程序修改。如果文件是immutable的,整个程序就可以共享同一个fileContent对象。

这个版本有一个明显的缺陷,即内存消耗与(并发连接数×文件大小)成正比,文件越大内存消耗越多,如果文件大小上GB,那几乎就是灾难了。只需要建立少量并发连接就能把服务器的内存耗尽,因此我们有了版本二。

版本二

为了解决版本一占用内存过多的问题,我们采用流水线的思路,当新建连接时,先发送文件的前64KiB数据,等这块数据发送完毕时再继续发送下64KiB数据,如此往复直到文件内容全部发送完毕。代码中使用了TcpConnection::setContext()和getContext()来保存TcpConnection的用户上下文(这里是FILE*),因此不必使用额外的std::map<TcpConnectionPtr,FILE*>来记住每个连接的当前文件位置。

-------分隔线------------------------------------

笔记:

//download2.cpp  文件传输版本二
#include "muduo/base/Logging.h"
#include "muduo/net/EventLoop.h"
#include "muduo/net/TcpServer.h"

#include <stdio.h>
#include <unistd.h>

using namespace muduo;
using namespace muduo::net;

const int kBufSize = 64 * 1024;  //版本2
const char* g_file = NULL;

void onHighWaterMark(const TcpConnectionPtr& conn, size_t len)
{
	LOG_INFO << "HighWaterMark " << len;
}

void onConnection(const TcpConnectionPtr& conn)
{
	LOG_INFO << "FileServer2 - " << conn->peerAddress().toIpPort() << " -> "
		<< conn->localAddress().toIpPort() << " is "
		<< (conn->connected() ? "UP" : "DOWN");
	if (conn->connected())
	{
		LOG_INFO << "FileServer - Sending file " << g_file
			<< " to " << conn->peerAddress().toIpPort();
		conn->setHighWaterMarkCallback(onHighWaterMark, 64 * 1024);
		FILE* fp = ::fopen(g_file, "rb");
		if (fp)
		{
			conn->setContext(fp);
			char buf[kBufSize];
			size_t nread = ::fread(buf, 1, sizeof(buf), fp);
			conn->send(buf, static_cast<int>(nread));
		}
		else
		{
			conn->shutdown();
			LOG_INFO << "FileServer2 - no such file";
		}
	}
	else
	{
		if (!conn->getContext().empty())
		{
			FILE* fp = boost::any_cast<FILE*>(conn->getContext());
			if (fp)
			{
				::fclose(fp);
			}
		}
	}
}
// 在onWriteComplete()回调函数中读取下一块文件数据,继续发送。
void onWriteComplete(const TcpConnectionPtr& conn)
{
	FILE* fp = boost::any_cast<FILE*>(conn->getContext());
	char buf[kBufSize];
	size_t nread = ::fread(buf, 1, sizeof(buf), fp);
	if (nread > 0)
	{
		conn->send(buf, static_cast<int>(nread));
	}
	else
	{
		::fclose(fp);
		fp = NULL;
		conn->setContext(fp);
		conn->shutdown();
		LOG_INFO << "FileServer2 - done";
	}
}
int main(int argc, char* argv[])
{
	LOG_INFO << "pid = " << getpid();
	if (argc > 1)
	{
		g_file = argv[1];

		EventLoop loop;
		InetAddress listenAddr(2021);
		TcpServer server(&loop, listenAddr, "FileServer2");
		server.setConnectionCallback(onConnection);
		server.setWriteCompleteCallback(onWriteComplete);
		server.start();
		printf("File Server start\n");
		loop.loop();
	}
	else
	{
		fprintf(stderr, "Usage:%s file_for _downloading\n", argv[0]);
	}
	return 0;
}

Qt写的客户端接收数据保存到文件。

void MainWindow::slotRecvSaveAsFile()
{
    if (!outputFile.isOpen()) {
        // 第一次接收,创建文件
        outputFile.setFileName("received_file.txt");
        if (!outputFile.open(QIODevice::WriteOnly)) {
            qDebug() << "Failed to open file for writing";
            return;
        }
        // 读取文件大小(假设前8字节是文件大小)
        // if (socket->bytesAvailable() >= 8) {
        //     QByteArray sizeData = socket->read(8);
        //     totalSize = *reinterpret_cast<const qint64*>(sizeData.constData());
        // }
    }
    QByteArray data = socket->readAll();
    outputFile.write(data);
    receivedSize += data.size();
    qDebug() << "slotRecvSaveAsFile =======================" << receivedSize;
    outputFile.close();
    // if (receivedSize >= totalSize && totalSize > 0) {
    //     // 文件接收完成
    //     outputFile.close();
    //     qDebug() << "File received successfully";
    // }
}

编译运行:

客户端接保存为文件:

-------分隔线------------------------------------

注意每次建立连接的时候我们都去重新打开那个文件,使得程序中文件描述符的数量翻倍(每个连接占一个socket fd和一个file fd),这是考虑到文件有可能被其他程序修改。如果文件是immutable的,一种改进措施是:整个程序可以共享同一个文件描述符,然后每个连接记住自己当前的偏移量,在onWriteComplete()回调函数里用pread(2)来读取数据。

这个版本也存在一个问题,如果客户端故意只发起连接,不接收数据,那么要么把服务器进程的文件描述符耗尽,要么占用很多服务端内存(因为每个连接有64KiB的发送缓冲区)。解决办法可参考后文§ 7.7“限制服务器的最大并发连接数”和§ 7.10“用timing wheel踢掉空闲连接”。必须说明的是,muduo并不是设计来编写面向公网的网络服务程序,这种服务程序需要在安全性方面下很多工夫,我个人对此不在行,我更关心实现内网(不一定是局域网)的高效服务程序。

版本三

用shared_ptr的custom deleter来减轻资源管理负担,使得FILE*的生命期和TcpConnection一样长,代码也更简单了。

-------分隔线------------------------------------

笔记:

//download3.cpp  文件传输版本三
#include "muduo/base/Logging.h"
#include "muduo/net/EventLoop.h"
#include "muduo/net/TcpServer.h"

#include <stdio.h>
#include <unistd.h>

using namespace muduo;
using namespace muduo::net;

const int kBufSize = 64 * 1024; 
const char* g_file = NULL;
typedef std::shared_ptr<FILE> FilePtr;   //版本3

void onHighWaterMark(const TcpConnectionPtr& conn, size_t len)
{
	LOG_INFO << "HighWaterMark " << len;
}

void onConnection(const TcpConnectionPtr& conn)
{
	LOG_INFO << "FileServer3 - " << conn->peerAddress().toIpPort() << " -> "
		<< conn->localAddress().toIpPort() << " is "
		<< (conn->connected() ? "UP" : "DOWN");
	if (conn->connected())
	{
		LOG_INFO << "FileServer3 - Sending file " << g_file
			<< " to " << conn->peerAddress().toIpPort();
		conn->setHighWaterMarkCallback(onHighWaterMark, 64 * 1024);
		FILE* fp = ::fopen(g_file, "rb");
		if (fp)
		{
			FilePtr ctx(fp, ::fclose);
			conn->setContext(ctx);
			char buf[kBufSize];
			size_t nread = ::fread(buf, 1, sizeof(buf), fp);
			conn->send(buf, static_cast<int>(nread));
		}
		else
		{
			conn->shutdown();
			LOG_INFO << "FileServer3 - no such file";
		}
	}
}
// 在onWriteComplete()回调函数中读取下一块文件数据,继续发送。
void onWriteComplete(const TcpConnectionPtr& conn)
{
	const FilePtr& fp = boost::any_cast<const FilePtr&>(conn->getContext());
	char buf[kBufSize];
	size_t nread = ::fread(buf, 1, sizeof(buf), get_pointer(fp));
	if (nread > 0)
	{
		conn->send(buf, static_cast<int>(nread));
	}
	else
	{
		conn->shutdown();
		LOG_INFO << "FileServer3 - done";
	}
}
int main(int argc, char* argv[])
{
	LOG_INFO << "pid = " << getpid();
	if (argc > 1)
	{
		g_file = argv[1];

		EventLoop loop;
		InetAddress listenAddr(2021);
		TcpServer server(&loop, listenAddr, "FileServer3");
		server.setConnectionCallback(onConnection);
		server.setWriteCompleteCallback(onWriteComplete);
		server.start();
		printf("File Server3 start\n");
		loop.loop();
	}
	else
	{
		fprintf(stderr, "Usage:%s file_for _downloading\n", argv[0]);
	}
	return 0;
}

编译运行:

客户端接收保存文件

-------分隔线------------------------------------

以上代码体现了现代C++的资源管理思路,即无须手动释放资源,而是通过将资源与对象生命期绑定,在对象析构的时候自动释放资源,从而把资源管理转换为对象生命期管理,而后者是早已解决了的问题。这正是C++最重要的编程技法:RAII。

为什么TcpConnection::shutdown()没有直接关闭TCP连接

我曾经收到一位网友的来信:“在simple的daytime示例中,服务端主动关闭时调用的是如下函数序列,这不是只是关闭了连接上的写操作吗,怎么是关闭了整个连接?”

void DaytimeServer::onConnection(const muduo::net::TcpConnectionPtr& conn)
{
    if(conn->connected())
    {
        conn->send(Timestamp::now().toFormattedString() + "\n);
        conn->shutdown();  //调用TcpConnection::shutdown()
    }
}

void TcpConnection::shutdown()
{
    if(state_ == kConnected)
    {
        setState(kDisconnecting);
        // 调用 TcpConnection::shutdownInLoop()
        loop_->runInLoop(boost::bind(&TcpConnection::shutdownInLoop, this));
    }
}

void TcpConnection::shutdownInLoop()
{
    loop_->assertInLoopThread();
    if(!channel_->isWriting())  //如果当前没有发送数据
    {
        // we are not writing
        socket_->shutdownWrite(); //调用Socket::shutdownWrite()
    }
}

void Socket::shutdownWrite()
{
    sockets::shutdownWrite(sockfd_);
}

void sockets::shutdownWrite(int sockfd)
{
    int ret = ::shutdown(sockfd, SHUT_WR);
    // 检查错误
}

笔者答复如下:

muduo TcpConnection没有提供close(),而只提供shutdown(),这么做是为了收发数据的完整性。

TCP是一个全双工协议,同一个文件描述符既可读又可写,shutdownWrite()关闭了“写”方向的连接,保留了“读”方向,这称为TCP half-close。如果直接close(socket_fd),那么socket_fd就不能读或写了。

用shutdown而不用close的效果是,如果对方已经发送了数据,这些数据还“在路上”,那么muduo不会漏收这些数据。换句话说,muduo在TCP这一层面解决了“当你打算关闭网络连接的时候,如何得知对方是否发了一些数据而你还没有收到?”这一问题。当然,这个问题也可以在上面的协议层解决,双方商量好不再互发数据,就可以直接断开连接。

也就是说muduo把“主动关闭连接”这件事情分成两步来做,如果要主动关闭连接,它会先关本地“写”端,等对方关闭之后,再关本地“读”端。

练习:阅读代码,回答“如果被动关闭连接,muduo的行为如何?”[注8:提示:在read(2)返回0的时候会回调connection callback,这样客户代码就知道对方断开连接了。]

另外,如果当前output buffer里还有数据尚未发出的话,muduo也不会立刻调用shutdownWrite,而是等到数据发送完毕再shutdown,可以避免对方漏收数据。

void TcpConnection::handleWrite()
{
    loop_->assertInLoopThread();
    if(channel_->isWriting())
    {
        ssize_t n = sockets::write(channel_->fd(),  //思考:为什么只写一次?
                                    outputBuffer_.peer(),
                                    outputBuffer_.readableBytes());
        if(n > 0)
        {
            outputBuffer_.retrieve(n);
            if(outputBuffer_.readableBytes() == 0)
            {
                channel_->disableWriting();
                if(writeCompleteCallback_)
                {
                    loop_->queueInLoop(boost::bind(writeCompleteCallback_, 
                                            shared_from_this()));
                }
                if(state_ == kDisconnecting)
                {
                    shutdownInLoop();
                }
            }
        }
    }
}

muduo这种关闭连接的方式对对方也有要求,那就是对方read()到0字节之后会主动关闭连接(无论shutdownWrite()还是close()),一般的网络程序都会这样,不是什么问题。当然,这么做有一个潜在的安全漏洞,万一对方故意不关闭连接,那么muduo的连接就一直半开着,消耗系统资源。必要时可以调用TcpConnection::handleClose()来强行关闭连接,这需要将handleClose()改为public成员函数。

完整的流程见图7-1。我们发完了数据,于是shutdownWrite,发送TCP FIN分节,对方会读到0字节,然后对方通常会关闭连接。这样muduo会读到0字节,然后muduo关闭连接。(思考题:在shutdown()之后,muduo回调connection callback的时间间隔大约是一个round-trip time,为什么?)

                                                                 图7-1

如果有必要,对方可以在read()返回0之后继续发送数据,这是直接利用了half-close TCP连接。muduo不会漏收这些数据。

那么muduo什么时候真正close socket呢?在TcpConnection对象析构的时候。TcpConnection持有一个Socket对象,Socket是一个RAII handler,它的析构函数会close(sockfd_)。这样,如果发生TcpConnection对象泄漏,那么我们从/proc/pid/fd/就能找到没有关闭的文件描述符,便于查错。

muduo在read()返回0的时候会回调connection callback,TcpServer或Tcp-Client把TcpConnection的引用计数减一。如果引用计数降到零,则表明用户代码也不持有TcpConnection,它就会析构了。

参考:《TCP/IP详解》[TCPv1]18.5节“TCP Half-Close”和《UNIX网络编程(第3版)》[UNP]6.6节“shutdown()函数”。

在网络编程中,应用程序发送数据往往比接收数据简单(实现非阻塞网络库正相反,发送比接收难),下一节我们再谈接收并解析消息的要领。

7.3 Boost.Asio的聊天服务器

本节将介绍一个与Boost.Asio的示例代码中的聊天服务器功能类似的网络服务程序,包括客户端与服务端的muduo实现。这个例子的主要目的是介绍如何处理分包,并初步涉及muduo的多线程功能。本文的代码位于examples/asio/chat/。

7.3.1 TCP分包

§ 7.1“五个简单TCP示例”中处理的协议没有涉及分包,在TCP这种字节流协议上做应用层分包是网络编程的基本需求。分包指的是在发生一个消息(message)或一帧(frame)数据时,通过一定的处理,让接收方能从字节流中识别并截取(还原)出一个个消息。“粘包问题”是个伪问题。

对于短连接的TCP服务,分包不是一个问题,只要发送方主动关闭连接,就表示一条消息发送完毕,接收方read()返回0,从而知道消息的结尾。例如§ 7.1里的daytime和time协议。

对于长连接的TCP服务,分包有四种方法:

1.消息长度固定,比如muduo的roundtrip示例就采用了固定的16字节消息。

2.使用特殊的字符或字符串作为消息的边界,例如HTTP协议的headers以“\r\n”为字段的分隔符。

3.在每条消息的头部加一个长度字段,这恐怕是最常见的做法,本文的聊天协议也采用这一办法。

4.利用消息本身的格式来分包,例如XML格式的消息中<root>...</root>的配对,或者JSON格式中的{...}的配对。解析这种消息格式通常会用到状态机(state machine)。

在后文的代码讲解中还会仔细讨论用长度字段分包的常见陷阱。

聊天服务

本节实现的聊天服务非常简单,由服务端程序和客户端程序组成,协议如下:

● 服务端程序在某个端口侦听(listen)新的连接。

● 客户端向服务端发起连接。

● 连接建立之后,客户端随时准备接收服务端的消息并在屏幕上显示出来。

● 客户端接受键盘输入,以回车为界,把消息发送给服务端。

● 服务端接收到消息之后,依次发送给每个连接到它的客户端;原来发送消息的客户端进程也会收到这条消息。

● 一个服务端进程可以同时服务多个客户端进程。当有消息到达服务端后,每个客户端进程都会收到同一条消息,服务端广播发送消息的顺序是任意的,不一定哪个客户端会先收到这条消息。

●(可选)如果消息A先于消息B到达服务端,那么每个客户端都会先收到A再收到B。

这实际上是一个简单的基于TCP的应用层广播协议,由服务端负责把消息发送给每个连接到它的客户端。参与“聊天”的既可以是人,也可以是程序。在后文§7.11中,我将介绍一个稍微复杂一点的例子hub,它有“聊天室”的功能,客户端可以注册特定的topic(s),并往某个topic发送消息,这样代码更有意思。

我在“谈一谈网络编程学习经验”(附录A)中把聊天服务列为“最主要的三个例子”之一,其与前面的“五个简单TCP协议”不同,聊天服务的特点是“连接之间的数据有交流,从a连接收到的数据要发给b连接。这样对连接管理提出了更高的要求:如何用一个程序同时处理多个连接?fork()-per-connection似乎是不行的。如何防止串话?b有可能随时断开连接,而新建立的连接c可能恰好复用了b的文件描述符,那么a会不会错误地把消息发给c?”muduo的这个例子充分展示了解决以上问题的手法。

7.3.2 消息格式

本聊天服务的消息格式非常简单,“消息”本身是一个字符串,每条消息有一个4字节的头部,以网络序存放字符串的长度。消息之间没有间隙,字符串也不要求以'\0'结尾。比方说有两条消息“hello”和“chenshuo”,那么打包后的字节流共有21字节:

打包的代码 这段代码把string message打包为muduo::net::Buffer,并通过conn发送。由于这个codec的代码位于头文件中,因此反复出现了muduo::net namespace。

void send(muduo::net::TcpConnection* conn,
            const muduo::StringPiece& message)
{
    muduo::net::Buffer buf;
    buf.append(message.data(), message.size());
    int32_t len = static_cast<int32_t>(message.size());
    int32_t be32 = muduo::net::sockets::hostToNetwork32(len);
    buf.prepend(&be32, sizeof(be32)); // L62行
    conn->send(&buf);
}

muduo Buffer有一个很好的功能,它在头部预留了8个字节的空间,这样L62的prepend()操作就不需要移动已有的数据,效率较高。

分包的代码 解析数据往往比生成数据更复杂,分包、打包也不例外。

void onMessage(const muduo::net::TcpConnectionPtr& conn,
                muduo::net::Buffer* buf,
                muduo::Timestamp receiveTime)
{
    while(buf->readableBytes() >= kHeaderLen)  // kHeaderLen == 4
    {
        //FIXME: use Buffer::peekInt32()
        const void* data = buf->peek();
        int32_t be32 = *static_cast<const int32_t*>(data); //SIGBUS  L32
        const int32_t len = muduo::net::sockets::networkToHost32(be32);
        if(len > 65536 || len < 0)
        {
            LOG_ERROR << "Invalid length " << len;
            conn->shutdown(); // FIXME: disable reading 
            break;
        }
        else if(buf->readableBytes() >= len + kkHeaderLen)
        {
            buf->retrieve(kHeaderLen);
            muduo::string message(buf->peek(), len);  // L43
            messageCallback_(conn, message, receiveTime); //L44
            buf->retrieve(len);
        }
        else
        {
            break;
        }
    }
}

onMessage()中L43构造完整的消息,L44通过messageCallback_回调用户代码。L32有潜在的问题,在某些不支持非对齐内存访问的体系结构上会造成SIGBUS core dump,读取消息长度应该改用Buffer::peekInt32()。上面这段代码的L28用了while循环来反复读取数据,直到Buffer中的数据不够一条完整的消息。请读者思考,如果换成if(buf->readableBytes()>=kHeaderLen)会有什么后果。

以前面提到的两条消息的字节流为例:

假设数据最终都全部到达,onMessage()至少要能正确处理以下各种数据到达的次序,每种情况下messageCallback_都应该被调用两次:

1.每次收到一个字节的数据,onMessage()被调用21次;

2.数据分两次到达,第一次收到2个字节,不足消息的长度字段;

3.数据分两次到达,第一次收到4个字节,刚好够长度字段,但是没有body;

4.数据分两次到达,第一次收到8个字节,长度完整,但body不完整;

5.数据分两次到达,第一次收到9个字节,长度完整,body也完整;

6.数据分两次到达,第一次收到10个字节,第一条消息的长度完整、body也完整,第二条消息长度不完整;

7.请自行移动和增加分割点,验证各种情况;一共有超过100万种可能(221−1)。

8.数据一次就全部到达,这时必须用while循环来读出两条消息,否则消息会堆积在Buffer中。

请读者验证onMessage()是否做到了以上几点。这个例子充分说明了non-blocking read必须和input buffer一起使用。而且在写decoder的时候一定要在收到完整的消息(L40)之后再retrieve整条消息(L42和L45),除非接收方使用复杂的状态机来解码。

7.3.3 编解码器LengthHeaderCodec

有人评论muduo的接收缓冲区不能设置回调函数的触发条件[注9:http://www.cnblogs.com/Solstice/archive/2011/02/02/1948839.html#2022206],确实如此。每当socket可读时,muduo的TcpConnection会读取数据并存入input buffer,然后回调用户的函数。不过,一个简单的间接层就能解决问题,让用户代码只关心“消息到达”而不是“数据到达”,如本例中的LengthHeaderCodec所展示的那样。

class LengthHeaderCodec:boost::noncopyable
{
public:
    typedef boost::function<void(const muduo::net::TcpConnectionPtr&,
                                const muduo::string& message,
                                muduo::Timestamp)> StringMessageCallback;
    explicit LengthHeaderCodec(const StringMessageCallback& cb)
        :mesageCallback_(cb)
    {
    }

onMessage()和send()同前
private:
    StringMessageCallback messageCallback_;
    const static size_t kHeaderLen = sizeof(int32_t);
};

这段代码把以Buffer*为参数的MessageCallback转换成了以const string&为参数的StringMessageCallback,让用户代码不必关心分包操作,具体的调用时序图见p.231图7-29。如果编程语言相同,客户端和服务端可以(应该)共享同一个codec,这样既节省工作量,又避免因对协议理解不一致而导致的错误。

7.3.4 服务端的实现

聊天服务器的服务端代码小于100行,不到asio的一半。

请先阅读p.200 L65~L69的数据成员的定义。除了经常见到的EventLoop和TcpServer,ChatServer还定义了codec_和connections_作为成员,后者存放目前已建立的客户连接。在收到消息之后,服务器会遍历整个容器,把消息广播给其中的每一个TCP连接(onStringMessage())。

首先,在构造函数里注册回调:

class ChatServer : noncopyable
{
public:
	ChatServer(EventLoop* loop,
		const InetAddress& listenAddr)
		:server_(loop, listenAddr, "ChatServer"),
		codec_(std::bind(&ChatServer::onStringMessage, this, _1, _2, _3))
	{
		server_.setConnectionCallback(std::bind(&ChatServer::onConnection, this, _1));
		server_.setMessageCallback(std::bind(&LengthHeaderCodec::onMessage, &codec_, _1, _2, _3));
	}

	void start()
	{
		server_.start();
	}

这里有几点值得注意,在以往的代码里是直接把本class的onMessage()注册给server_;这里我们把LengthHeaderCodec::onMessage()注册给server_,然后向codec_注册了ChatServer::onStringMessage(),等于说让codec_负责解析消息,然后把完整的消息回调给ChatServer。这正是我前面提到的“一个简单的间接层”,在不增加muduo库的复杂度的前提下,提供了足够的灵活性让我们在用户代码里完成需要的工作。

另外,server_.start()绝对不能在构造函数里调用,这么做将来会有线程安全的问题,见§ 1.2的论述。

以下是处理连接的建立和断开的代码,注意它把新建的连接加入到connections_容器中,把已断开的连接从容器中删除。这么做是为了避免内存和资源泄漏,TcpConnectionPtr是boost::shared_ptr<TcpConnection>,是muduo里唯一一个默认采用shared_ptr来管理生命期的对象。§ 4.7谈了这么做的原因。

private:
	void onConnection(const TcpConnectionPtr& conn)
	{
		LOG_INFO << conn->peerAddress().toIpPort() << " -> "
			<< conn->localAddress().toIpPort() << " is "
			<< (conn->connected() ? "UP" : "DOWN");
		if (conn->connected())
		{
			connections_.insert(conn);
		}
		else
		{
			connections_.erase(conn);
		}
	}

以下是服务端处理消息的代码,它遍历整个connections_容器,把消息打包发送给各个客户连接。

	void onStringMessage(const TcpConnectionPtr&,
		const string& message, Timestamp)
	{
		for (ConnectionList::iterator it = connections_.begin();
			it != connections_.end(); ++it)
		{
			codec_.send(get_pointer(*it), message);
		}
	}

数据成员:

	typedef std::set<TcpConnectionPtr> ConnectionList;
	EventLoop* loop_;
	TcpServer server_;
	LengthHeaderCodec codec_;
	ConnectionList connections_;

main()函数中是例行公事的代码:

int main(int argc, char* argv[])
{
	LOG_INFO << "pid = " << getpid();
	if (argc > 1)
	{
		EventLoop loop;
		uint16_t port = static_cast<uint16_t>(atoi(argv[1]));
		InetAddress serverAddr(port);
		ChatServer server(&loop, serverAddr);
		server.start();
		loop.loop();
	}
	else
	{
		printf("Usage: %s port\n", argv[0]);
	}
	return 0;
}

如果你读过asio的对应代码,会不会觉得Reactor往往比Proactor容易使用?

7.3.5 客户端的实现

我有时觉得服务端的程序常常比客户端的更容易写,聊天服务器再次验证了我的看法。客户端的复杂性来自于它要读取键盘输入,而EventLoop是独占线程的,所以我用了两个线程:main()函数所在的线程负责读键盘,另外用一个EventLoopThread来处理网络IO。[注10:我暂时没有把标准输入输出融入Reactor的想法,因为服务器程序很少用到stdin和stdout.]

现在来看代码,首先,在构造函数里注册回调,并使用了跟前面一样的Length-HeaderCodec作为中间层,负责打包、分包。

public:
ChatClient(EventLoop* loop, const InetAddress& serverAddr)
    :loop_(loop),
    client_(loop, serverAddr, "ChatClient"),
    codec_(std::bind(&ChatClient::onStringMessage, this, _1, _2, _3))
    {
    client_.setConnectionCallback(
        boost::bind(&ChatClient::onConnection, this, _1));
    client_.setMessageCallback(
        boost::bind(&LengthHeaderCodec::onMessage, &codec_, _1, _2, _3));
    client_.enableRetry();
}

void connect()
{
    client_.connect();
}

disconnect()目前为空,客户端的连接由操作系统在进程终止时关闭。

void disconnect()
{
    //client_.disconnect();
}

write()会由main线程调用,所以要加锁,这个锁不是为了保护TcpConnection,而是为了保护shared_ptr。

void write(const StringPiece& message)
{
    MutexLockGuard lock(mutex_);
    if (connection_)
    {
        codec_.send(get_pointer(connection_), message);
    }
}

onConnection()会由EventLoop线程调用,所以要加锁以保护shared_ptr。

private:
	void onConnection(const TcpConnectionPtr& conn)
	{
		LOG_INFO << conn->localAddress().toIpPort() << " -> "
			<< conn->peerAddress().toIpPort() << " is "
			<< (conn->connected() ? "UP" : "DOWN");
		MutexLockGuard lock(mutex_);
		if (conn->connected())
		{
			connection_ = conn;
		}
		else
		{
			connection_.reset();
		}
	}

把收到的消息打印到屏幕,这个函数由EventLoop线程调用,但是不用加锁,因为printf()是线程安全的。注意这里不能用std::cout<<,它不是线程安全的。

void onStringMessage(const TcpConnectionPtr&,
		const string& message, Timestamp)
{
    printf("<<< %s\n", message.c_str());
}

数据成员:

EventLoop* loop_;
TcpClient client_;
LengthHeaderCodec codec_;
MutexLock mutex_;
TcpConnectionPtr connection_ GUARDED_BY(mutex_);

main()函数里除了例行公事,还要启动EventLoop线程和读取键盘输入。

int main(int argc, char* argv[])
{
	LOG_INFO << "pid = " << getpid();
	if (argc > 2)
	{
		EventLoopThread loopThread;
		uint16_t port = static_cast<uint16_t>(atoi(argv[2]));
		InetAddress serverAddr(argv[1], port);
		ChatClient client(loopThread.startLoop(), serverAddr);
		client.connect();
		std::string line;
		while (std::getline(std::cin, line))
		{
			client.write(line);
		}
		client.disconnect();
		CurrentThread::sleepUsec(1000 * 1000); //wait for disconnect, see ace/logging/client.cc
	}
	else
	{
		printf("Usage: %s host_ip port\n", argv[0]);
	}
	return 0;
}

L92 ChatClient使用EventLoopThread的EventLoop,而不是通常的主线程的EventLoop。L97发送数据行。

简单测试

打开三个命令行窗口,在第一个窗口运行:

$ ./asio_chat_server 3000

在第二个窗口运行:

$ ./asio_chat_client 127.0.0.1 3000

在第三个窗口运行同样的命令:

$ ./asio_chat_client 127.0.0.1 3000

这样就有两个客户端进程参与聊天。在第二个窗口里输入一些字符并回车,字符会出现在本窗口和第三个窗口中。

代码示例中还有另外三个server程序,都是多线程的,详细介绍在p.260。

● server_threaded.cc使用多线程TcpServer,并用mutex来保护共享数据。

● server_threaded_efficient.cc对共享数据以§ 2.8“借shared_ptr实现copy-on-write”的手法来降低锁竞争。

● server_threaded_highperformance.cc采用thread local变量,实现多线程高效转发,这个例子值得仔细阅读理解。

§ 7.8会介绍muduo中的定时器,并实现Boost.Asio教程中的timer2~5示例,以及带流量统计功能的discard和echo服务器(来自Java Netty)。流量等于单位时间内发送或接收的字节数,这要用到定时器功能。

-------分隔线------------------------------------

笔记:

//msg_codec.h   消息的编码与解码
#ifndef EXAMPLE_ASIO_CHAT_MSG_CODEC_H
#define EXAMPLE_ASIO_CHAT_MSG_CODEC_H

#include "muduo/base/Logging.h"
#include "muduo/net/Buffer.h"
#include "muduo/net/Endian.h"
#include "muduo/net/TcpConnection.h"

class LengthHeaderCodec : muduo::noncopyable
{
public:
    typedef std::function<void(const muduo::net::TcpConnectionPtr&,
                                                const muduo::string& message,
                                                muduo::Timestamp)> StringMessageCallback;

    explicit LengthHeaderCodec(const StringMessageCallback& cb)
        :messageCallback_(cb)
    {
    }
    void onMessage(const muduo::net::TcpConnectionPtr& conn,
                                muduo::net::Buffer* buf,
                                muduo::Timestamp receiveTime)
    {
        while (buf->readableBytes() >= kHeaderLen) //kHeaderLen == 4
        {
            // FIXME: use Buffer::peekInt32()   在某些不支持非对齐内存访问的体系结构上会造成SIGBUS core dump
            const void* data = buf->peek();
            int32_t be32 = *static_cast<const int32_t*>(data); //SIGBUS  
            const int32_t len = muduo::net::sockets::networkToHost32(be32);
            if (len > 65536 || len < 0)
            {
                LOG_ERROR << "Invalid length " << len;
                conn->shutdown(); //FIXME: disable reading
                break;
            }
            else if (buf->readableBytes() >= len + kHeaderLen)
            {
                buf->retrieve(kHeaderLen);
                muduo::string message(buf->peek(), len);
                messageCallback_(conn, message, receiveTime);
                buf->retrieve(len);
            }
            else
            {
                break;
            }
        }
    }

    //FIXME: TcpConnectionPtr 
    void send(muduo::net::TcpConnection* conn,
        const muduo::StringPiece& message)
    {
        muduo::net::Buffer buf;
        buf.append(message.data(), message.size());
        int32_t len = static_cast<int32_t>(message.size());
        int32_t be32 = muduo::net::sockets::hostToNetwork32(len);
        buf.prepend(&be32, sizeof(be32));
        conn->send(&buf);
    }
private:
    StringMessageCallback messageCallback_;
    const static size_t kHeaderLen = sizeof(int32_t);
};

#endif // EXAMPLE_ASIO_CHAT_MSG_CODEC_H
      

服务端文件

//server.cpp
#include "msg_codec.h"
#include "muduo/base/Logging.h"
#include "muduo/base/Mutex.h"
#include "muduo/net/EventLoop.h"
#include "muduo/net/TcpServer.h"

#include <set>
#include <stdio.h>
#include <unistd.h>

using namespace muduo;
using namespace muduo::net;

class ChatServer : noncopyable
{
public:
	ChatServer(EventLoop* loop,
		const InetAddress& listenAddr)
		:loop_(loop),
		server_(loop, listenAddr, "ChatServer"),
		codec_(std::bind(&ChatServer::onStringMessage, this, _1, _2, _3))
	{
		server_.setConnectionCallback(std::bind(&ChatServer::onConnection, this, _1));
		server_.setMessageCallback(std::bind(&LengthHeaderCodec::onMessage, &codec_, _1, _2, _3));
	}

	void start()
	{
		server_.start();
	}

private:
	void onConnection(const TcpConnectionPtr& conn)
	{
		LOG_INFO << conn->peerAddress().toIpPort() << " -> "
			<< conn->localAddress().toIpPort() << " is "
			<< (conn->connected() ? "UP" : "DOWN");
		if (conn->connected())
		{
			connections_.insert(conn);
		}
		else
		{
			connections_.erase(conn);
		}
	}

	void onStringMessage(const TcpConnectionPtr&,
		const string& message, Timestamp)
	{
		for (ConnectionList::iterator it = connections_.begin();
			it != connections_.end(); ++it)
		{
			codec_.send(get_pointer(*it), message);
		}
	}
	typedef std::set<TcpConnectionPtr> ConnectionList;
	EventLoop* loop_;
	TcpServer server_;
	LengthHeaderCodec codec_;
	ConnectionList connections_;
};

int main(int argc, char* argv[])
{
	LOG_INFO << "pid = " << getpid();
	if (argc > 1)
	{
		EventLoop loop;
		int nPort = atoi(argv[1]);
		printf("port ======= %d\n", nPort);
		uint16_t port = static_cast<uint16_t>(atoi(argv[1]));
		InetAddress serverAddr(port);
		ChatServer server(&loop, serverAddr);
		server.start();
		loop.loop();
	}
	else
	{
		printf("Usage: %s port\n", argv[0]);
	}
	return 0;
}

客户端文件:

//client.cpp
#include "msg_codec.h"
#include "muduo/base/Logging.h"
#include "muduo/base/Mutex.h"
#include "muduo/net/EventLoopThread.h"
#include "muduo/net/TcpClient.h"

#include <iostream>
#include <stdio.h>
#include <unistd.h>

using namespace muduo;
using namespace muduo::net;

class ChatClient : noncopyable
{
public:
	ChatClient(EventLoop* loop, const InetAddress& serverAddr)
        :client_(loop, serverAddr, "ChatClient"),
		codec_(std::bind(&ChatClient::onStringMessage, this, _1, _2, _3))
	{
		client_.setConnectionCallback(std::bind(&ChatClient::onConnection, this, _1));
        client_.setMessageCallback(std::bind(&LengthHeaderCodec::onMessage, &codec_, _1, _2, _3));
		client_.enableRetry();
	}

	void connect()
	{
		client_.connect();
	}
	void disconnect()
	{
		client_.disconnect();
	}
	void write(const StringPiece& message)
	{
		MutexLockGuard lock(mutex_);
		if (connection_)
		{
			codec_.send(get_pointer(connection_), message);
		}
	}

private:
	void onConnection(const TcpConnectionPtr& conn)
	{
		LOG_INFO << conn->localAddress().toIpPort() << " -> "
			<< conn->peerAddress().toIpPort() << " is "
			<< (conn->connected() ? "UP" : "DOWN");
		MutexLockGuard lock(mutex_);
		if (conn->connected())
		{
			connection_ = conn;
		}
		else
		{
			connection_.reset();
		}
	}

	void onStringMessage(const TcpConnectionPtr&,
		const string& message, Timestamp)
	{
		printf("<<< %s\n", message.c_str());
	}

	TcpClient client_;
	LengthHeaderCodec codec_;
	MutexLock mutex_;
	TcpConnectionPtr connection_ GUARDED_BY(mutex_);
};

int main(int argc, char* argv[])
{
	LOG_INFO << "pid = " << getpid();
	if (argc > 2)
	{
		EventLoopThread loopThread;
		uint16_t port = static_cast<uint16_t>(atoi(argv[2]));
		InetAddress serverAddr(argv[1], port);
		ChatClient client(loopThread.startLoop(), serverAddr);
		client.connect();
		std::string line;
		while (std::getline(std::cin, line))
		{
			client.write(line);
		}
		client.disconnect();
		CurrentThread::sleepUsec(1000 * 1000); //wait for disconnect, see ace/logging/client.cc
	}
	else
	{
		printf("Usage: %s host_ip port\n", argv[0]);
	}
	return 0;
}

编译运行,先启动服务端

启动客户端连接并发送消息

-------分隔线------------------------------------

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值