许多iOS应用使用HTTP和web server通信,因为这个很简单,方便。
然而,在某些情况,你可能发现需要往网络协议栈下面逛逛,体恤一下民情,使用TCP sockets和你自己的服务端通信。
这么做有好几个优点
- 你只需要发送需要的数据-使得你的协议简洁有效
- 你可以在任何时段发送数据给连接的用户,而不需要用户轮询
- 你可以不依赖web server写socket servers,而且使用你想要的语言
- 有些时候你必须使用sockets
在这个网络教程里,你会体验到如何创建一个iOS应用和TCP socket server通信,使用的是NSStream和CFStream。你会使用Python构造一个简单的socket server。
这个iOS应用和聊天server会实现聊天功能,你可以实时地在多个设备间聊天。
这个教程假设你已经对Python和iOS编程有基本了解。如果你是Python编程新手,查看这个官方Python教程。如果你是iOS编程新手,查看这个网站的一些iOS tutorials。
让我们开始做一些socket编程。
什么是socket?
socket是一个允许你双向传输数据的工具。所以socket有两边,你可以看下面的图。你占领一边,某个人占领另一边-通常是另一个进程,无论在同一个机器或者不同的机器。
每一边都是由两个元素结合标识,IP地址和端口号。IP地址标识计算机,端口号标识进程。
存在不同类型的sockets,这些sockets的不同在于传输数据的协议不同。最流行的类型是TCP和UDP。在这篇教程中,我们处理TCP sockets。
客户端和服务端
讨论socket的时候不带上客户端和服务端是不可能的。你可以将这个视作一个餐馆。
服务端是一个进程,该进程侦听从客户端来的连接。你可以把这个看作餐馆的服务员,当客户来的时候,服务员招呼他们。
因为存在很多客户同时想要连接或者订单的可能,一个服务端通常开始为每一个连接建立一个子进程。你可以把这个视作大堂经理为每个客户分配一个服务员。
一旦连接建立,客户和服务端可以开始通过他们的socket发送和接收数据。你可以把这个视作客户和服务员之间侃侃而谈。
当客户关闭连接或者服务端停止的时候,这个socket关闭了。接下来企图使用这个socket都会导致错误。你可以将这个视作服务员把无理的客户踢出餐馆。
创作一个TCP服务端:综述
在我们开始构建iOS应用前,我们要开始创造我们的TCP服务端。这个服务端需要
- 侦听接入连接
- 追踪连接的用户(由socket和name标识)
派遣事件(一个新的用户加入了聊天)
为了派遣事件,服务端需要定义协议,这个协议定义了期望的数据格式,可以在客户和服务端之间来往传送。
对于这个应用,我们准备使用一个非常简单的字符串协议。
例如,当一个用户加入聊天时,这个iPhone会发送一个特殊的字符串给服务端,类似
ima:cesare
服务端看到 iam 字符串,会将其对待为”用户加入聊天”并且恰当地进行处理。对于其余地命令,例如用户发送消息或者离开聊天,会有类似的相同过程。
你可以使用很多不同的语言构造简单的TCP server。然而,对于这个教程来说,我们准备使用Python语言,因为它广为人知,而且在各个平台都能用。
如果你想,你可以使用内置的Python API制造一个TCP server,但是为了让事情更简单,我们准备使用Twisted功能,这是一个聚焦于网络的Python框架。
什么是Twisted?
Twisted是一个基于事件的引擎,让使用TCP,UDP,SSH,IRC或者FTP构建网络应用变得更加简单。
如果我们要从头开始构建一个TCP,我们需要学习Python类,学习怎么样进行socket和网络通信。Twisted具有很方便的集合管理连接和派遣,我们主要定睛于我们应用的具体事项-广播消息。
Twisted是基于一个所谓reactor pattern的设计模式构造的。这个模式很简单但是很强大-它开始一个循环,等待事件,对事件作出反应。
如果你没有使用过Twisted,不要忧虑0我们会在这个网络教程中一步一步引导你使用Twisted。
安装Twisted
如果你工作在MacOSX,已经安装好了Python。打开终端,用‘python -V’查出版本号。Mac OSX 10.6也包含了Twisted,所以你已经预备好了。
如果你的Mac是10.5,从它的官方网站下载和安装Twisted。
一个简单的TCP Server实现
我们会将所有的代码放在一个server.py文件中,打开你最喜欢的文本编辑器创建这个文件。
为了使用Twisted特色,我们需要导入一些Python类。最重要的是reactor
from twisted.internet import reactor
然后生成一个Twisted run loop
reactor.run()
这两行已经使我们拥有了应用的主心骨。是不是很简单?
你可以从命令行运行这个文件
python server.py
但是你会看到,目前为止没有任何事发生,它有对程序的控制,但是不具有任何反应的指令。
正如我们之前提到的客户端的任务,它使管理和客户的连接的,类似多个客户进入一个餐馆订餐。
所以我们需要一个机制来创造对象,当新的连接建立时,这些对象处理数据传输。这个连接需要实现一个协议。
我们不久就会看到协议的实现。我们先来看看连接管理。
侦听连接
如果我们想要使用raw Python来处理连接,我们需要为每一个连接生成子进程。
但是Twisted使得这一切变得更容易!我们可以使用内置的Factory方法,这个方法生成一个管理机器,处理和客户建立的每一个连接。让我们更新我们的代码。
from twisted.internet.protocol import Factory
from twisted.internet import reactor
factory = Factory()
reactor.listenTCP(80, factory)
reactor.run()
看到吗?仅仅5行代码,我们已经有了一个reactor模式的实现,有一个相关的factory处理连接。这很棒!我们现在可以专注于服务端的核心逻辑,就是对事件的反应。
定义协议
协议就是服务应用的逻辑。也是我们陈述当一个客户连接的时候,发送消息等等的时候做什么。
我们将这个逻辑放在一个类里面,这扩展了Twisted的Protocol类。
让我们修改我们的server.py
from twisted.internet.protocol import Factory, Protocol
from twisted.internet import reactor
class IphoneChat(Protocol):
def connectionMade(self):
print "a client connected"
factory = Factory()
factory.protocol = IphoneChat
reactor.listenTCP(80, factory)
print "Iphone Chat server started"
reactor.run()
我们创建了一个新的类”IphoneChat”,这个类扩展了”Protocol”,扩展了connectionMade,当有一个连接时,打印这个消息。我们把我们的类分配给factory作为protocol。
我们准备开始运行客户端。
进到server.py文件夹,输入”sudo python server.py”。你应该会看到”Iphone Chat server started”消息。
在这节骨眼上,服务端已经开始了,侦听在我们主机的端口80上。我们选择端口89,因为默认情况下它就打开了,因为它是http连接的标准端口。
我们需要sudo因为在机器上侦听某个端口需要管理者权限。
我们可以使用telnet来测试连接。这是一个装载每一个操作系统上的工具,它也是测试服务端的客户替代品。打开另一个终端输入
telnet localhost 80
如果一切顺利,会显示”a client connected”
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
恭喜,你已经建立了一个连接!如果你喜欢,你可以打开另一个shell,测试服务端的多任务功能。你会看到和上面一样的行为。
追踪客户
服务端的任务之一是追踪连接。每一个客户有一个分配的socket,所以我们需要将这信息存储在数组里面。
将connectionMade修改为下面
def connectionMade(self):
self.factory.clients.append(self)
print "clients are ", self.factory.clients
在生成factory后初始化clients数组
factory.clients = []
这样的话,当我们有两个客户连接的时候,服务端会打印出下面信息
clients are [<__main__.IphoneChat instance at 0x101300830>, <__main__.IphoneChat instance at 0x101300a28>]
这些是我们的协议类的实例。每一个实例有透过连接发送和从客户接收数据的能力。
为了完整,我们也要管理当一个用户断开连接的情况。所以代码如下
from twisted.internet.protocol import Factory, Protocol
from twisted.internet import reactor
class IphoneChat(Protocol):
def connectionMade(self):
self.factory.clients.append(self)
print "clients are ", self.factory.clients
def connectionLost(self, reason):
self.factory.clients.remove(self)
factory = Factory()
factory.protocol = IphoneChat
factory.clients = []
reactor.listenTCP(80, factory)
print "Iphone Chat server started"
reactor.run()
对聊天事件作出反应
现在我们允许连接和追踪客户,是时候捣鼓一些有趣的东东了,响应都客户来的聊天命令。
对于这个网络教程,我们会使用非常简单的格式交换数据。我们会使用以冒号分割的字符串。在冒号之前是命令,可以是”iam”或者”msg”。
- iam消息当某人加入这聊天使用,后面跟着的是这个人的绰号
message命令发送一个消息给所有的客户。msg没有必要携带发送者的名字,因为这是由服务端管理的,在self.factory.clients。
当你制作你自己的app时,你可以制作别的协议,你可以使用JSON,XML,自定制的二进制格式,随你心愿。
为了接收数据,我们需要重载的回调函数有下面的签名
def dataReceived(self, data):
在这个回调函数中,data是通过socket收到的消息。在这个代码里面,我们可以做下列事情
- 将字符串拆开找到命令
- 如果是iam,存储客户名字
- 在两种情况下,都构造定制消息,例如”Cesare has joined”或者”Ray says hello”
在两种情况下,广播这个消息到其他客户
让我们看看如何做到!将下面的代码加到connectionLost方法后面
def dataReceived(self, data):
a = data.split(':')
print a
if len(a) > 1:
command = a[0]
content = a[1]
msg = ""
if command == "iam":
self.name = content
msg = self.name + " has joined"
elif command == "msg":
msg = self.name + ": " + content
print msg
for c in self.factory.clients:
c.message(msg)
当我们广播这个消息时,我们呼叫message方法。添加下面的代码
def message(self, message):
self.transport.write(message + '\n')
加上’\n’很重要,因为socket看到这个就知道传输结束了。
不管相不相信,到现在,你已经有了一个完整的聊天服务端了。
你可以使用telnet连接到聊天服务端测试它的功能,发送命令例如”iam:cesare”和”msg:hi”。如果你打开多个telnet会话会更有趣。
iPhone客户端
现在服务端已经就绪,我们可以聚焦于客户端。客户端需要管理三个操作
- 加入聊天室
- 发送消息
接收消息
我们会用分层的方式组织视图。会有一个一直可见的主视图。会包含两个子视图,一个为了注册,一个为了聊天,这个视图你可以发送和接收消息。如下图所示
让我们开始。启动Xcode。工程名字为ChatClient
让我们开始工作在视图上,生成上面所阐释的结构。
自动产生的代码包含了通常的应用代理加上一个视图控制器。
text field用来键入绰号,button用来引发加入聊天。然后将text field和button跟你的视图控制器连接起来。
textfield作为outlet命名为inputNameField,button作为action命名为joinChat。这个view以outlet命名为joinView。
此刻如果你编译并且运行你的代码,你应该看到这个view,但它不会做任何别的事情。
下面我们需要编写代码加入聊天室,这是通过和服务端建立连接达到的。为了完成这个,我们初始化一些变量,但是我们先介绍一点东西。
流编程
我们使用streams在iOS中建立流连接。一个流是对发送接收数据机制的抽象。数据可以包含在不同的地方,例如一个文件,一个C缓冲器或者一个网络连接。一个stream可以有一个相关的代理,允许对特定的事件作出反应,这些事件可以是 “连接打开” “已经收到数据” “连接关闭”等等。
在Cocoa框架里有三个重要的和stream有关的类
- NSStream 这是超类,定义了抽象特色例如打开,关闭和代理
- NSInputStream NSStream的子类用来读输入
- NSOutputStream NSStream的子类,用来写输出
这些类构建在CFStream之上,CFStream是Core Foundation层的成员。如果你感觉自己是一个特别勇敢的人,你可以使用CoreFoundation的类重建这个应用。
但是为了让事情简单点,特别是在这个教程里面。我们准备使用NSStream类,这些类更容易使用。NSStrem类的唯一问题就是不能连接到远程host,而这正是我们在应用中需要的。
不过不要担心-NSStream和CFStream是桥接的,所以从CFStream获得NSStream很容易,只要调用一个魔法函数就可以。
好了,让我们开始。打开ChatClientViewController.h ,添加下面的实例变量
NSInputStream *inputStream;
NSOutputStream *outputStream;
然后打开ChatClientViewController.m,添加下面的方法
- (void)initNetworkCommunication {
CFReadStreamRef readStream;
CFWriteStreamRef writeStream;
CFStreamCreatePairWithSocketToHost(NULL, (CFStringRef)@"localhost", 80, &readStream, &writeStream);
inputStream = (NSInputStream *)readStream;
outputStream = (NSOutputStream *)writeStream;
}
这个有魔力的函数CFStreamCreatePairWithSocketToHost帮助我们绑定两个stream到一个主机和一个端口。一旦你呼叫过这个函数,你可以自由地将CFStreams投掷为NSStreams,这是唯一OC境外的代码。
目前为止我们准备好了这个连接,但是没有建立通信。你必须要在两个streams上呼叫open方法来打开,但是还有一些工作需要先完成。
我们说NSStreams有一个代理,我们在打开连接之前必须设置这个代理,否则我们不会接收任何通知。我们会使用目前的类作为代理,所以将下面的代码放在这个initNetworkCommunication的后面。
[inputStream setDelegate:self];
[outputStream setDelegate:self];
当然,为了取悦我们的编译器,我们必须标记这个控制器实现了NSStreamDelegate,所以修改这个控制器的接口如下
@interface ChatClientViewController : UIViewController <NSStreamDelegate>
我们的streams必须持续不断地准备好发送和接收数据。我们必须在run loop中调度这个stream来接收事件。如果我们不分配一个run loop,代理会阻塞代码地执行直到没有数据用以读写,这是我们想要避免的。
应用必须要对stream事件作出反应。Run-loop调度允许我们允许我们执行其它的代码,但是确保当stream发生某些事件的时候得到通知。
调度我们的streams让其在run-loop中,添加下面的代码到initNetworkCommunication的末尾
[inputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
[outputStream scheduleInRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
现在我们准备好打开这个连接了!加上下面的代码
[inputStream open];
[outputStream open];
还有一件事情要做,拿掉viewDidLoad方法的注释,呼叫initNetworkCommunication
[self initNetworkCommunication];
目前为止就是这些!编译并且运行这个app,看看你的服务端输出。服务端的shell应该会显示有一个client连接,如下图所示
Joining the Chat
现在我们已经连接了,我们准备开始加入聊天!
加入消息的格式是”iam:name”。所以我们需要构建一个字符串,把它写入到outputStream中。
注意我们不能将字符串直接写入到stream中-我们需要将它转换为NSData,所以打开ChatClientViewController.m
- (IBAction)joinChat:(id)sender {
NSString *response = [NSString stringWithFormat:@"iam:%@", inputNameField.text];
NSData *data = [[NSData alloc] initWithData:[response dataUsingEncoding:NSASCIIStringEncoding]];
[outputStream write:[data bytes] maxLength:[data length]];
}
不管你相信与否,就是这样-发送消息相当简单,对么?
编译并且运行,在text field中键入名字,然后点击join。切换到你的服务端shell。你会看到一个消息验证你的iam命令,如下面所示
生成聊天视图
下面我们要生成一个用户界面来发送和显示聊天消息。
打开 ChatClientViewController.xib,拖拉一个新的UIView,让它成为main UIView的子孙(是带有Join按钮的UIView的兄弟)。改变这两个view的顺序,让新的View是main UIView的第二个子孙(在最上面,现实中也是,后生的孩子比较疼爱)
拽拉一个textfield,button和table view到这个view,按照下图所示匹配排版。
下一步你需要将这些UI元素和你的类连接起来。遵循下面的步骤
- textfield 作为inputMessageField
- table view作为tView
- button action sendMessage
- table view配备datasource和delegate
- view作为chatView
- 将这个view放到main uiview的第一个位置,使得joinView成为最先印入眼帘的view。
然后你需要修改ChatClientViewController.h的接口如下所示
@interface ChatClientViewController : UIViewController <NSStreamDelegate, UITableViewDelegate, UITableViewDataSource> {
然后切换到ChatClientViewController.m,添加下面的新方法
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
static NSString *CellIdentifier = @"ChatCellIdentifier";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
if (cell == nil) {
cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier] autorelease];
}
return cell;
}
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
return 1;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return 0;
}
最后,你需要添加一些代码当用户参与到聊天中后,将聊天视图转换到上面。仅仅需要在joinChat的末尾添加这行
[self.view bringSubviewToFront:chatView];
编译并且运行你的代码,键入你的名字,点击join,现在你的新用户界面会显露出来。
发送消息
现在剩下的就是我们应用的核心功能,发送和接收消息。
我们会从实现发送消息开始。
NSString *response = [NSString stringWithFormat:@"msg:%@", inputMessageField.text];
NSData *data = [[NSData alloc] initWithData:[response dataUsingEncoding:NSASCIIStringEncoding]];
[outputStream write:[data bytes] maxLength:[data length]];
这就完了,编译运行你的应用,注册并且发送一个消息。如果你切换到你的服务端shell。你应该会立即看到这个消息。
接收消息
告诉你事实,这个应用也在从服务端接收消息,但是我们没有编写显示它们的代码,让我们来完成这部分。
首先,在ChatClientViewController.h添加一个实例变量
NSMutableArray * messages;
然后,切换到 ChatClientViewController.m,调整下面的变化
// At bottom of viewDidLoad
messages = [[NSMutableArray alloc] init];
// In dealloc
[messages release];
// At bottom of tableView:cellForRowAtIndexPath:, right before return cell
NSString *s = (NSString *) [messages objectAtIndex:indexPath.row];
cell.textLabel.text = s;
// Replace return 0 in tableView:numberOfRowsInSection: with the following
return messages.count;
这是相当标准的东西-我们只是建立了一个NSMutableArray来包含一系列字符串,然后用我们的table view来显示它们。
现在我们剩下的就是实现NSStream代理。
这个代理包括了stream:handleEvent:让我们的应用能够对streams上发生的活动作出反应。我们已经设置好了代理,所以我们只需要实现下面的方法。校验事件类型。
- (void)stream:(NSStream *)theStream handleEvent:(NSStreamEvent)streamEvent {
NSLog(@"stream event %i", streamEvent);
}
NSStream定义的常量如下
typedef enum {
NSStreamEventNone = 0,
NSStreamEventOpenCompleted = 1 << 0,
NSStreamEventHasBytesAvailable = 1 << 1,
NSStreamEventHasSpaceAvailable = 1 << 2,
NSStreamEventErrorOccurred = 1 << 3,
NSStreamEventEndEncountered = 1 << 4
};
在我们这种情形,我们特别感兴趣的是
- NSStreamEventOpenCompleted 校验连接打开
- NSStreamEventHasBytesAvailable 接收消息吧,亲
- NSStreamEventErrorOccurred 在连接的时候校验问题事项
NSStreamEventEndEncountered 当服务端关闭的时候,关闭这个stream
修改这个实现如下面的代码
- (void)stream:(NSStream *)theStream handleEvent:(NSStreamEvent)streamEvent {
switch (streamEvent) {
case NSStreamEventOpenCompleted:
NSLog(@"Stream opened");
break;
case NSStreamEventHasBytesAvailable:
break;
case NSStreamEventErrorOccurred:
NSLog(@"Can not connect to the host!");
break;
case NSStreamEventEndEncountered:
break;
default:
NSLog(@"Unknown event");
}
}
这个关键点是NSStreamEventHasBytesAvailable,这里我们要
- 从stream读取bytes
- 将它们收集在buffer
- 将buffer转换为字符串
- 将字符串添加到message数组
- 告诉tableview重新装载
所以让我们开始!
case NSStreamEventHasBytesAvailable:
if (theStream == inputStream) {
uint8_t buffer[1024];
int len;
while ([inputStream hasBytesAvailable]) {
len = [inputStream read:buffer maxLength:sizeof(buffer)];
if (len > 0) {
NSString *output = [[NSString alloc] initWithBytes:buffer length:len encoding:NSASCIIStringEncoding];
if (nil != output) {
NSLog(@"server said: %@", output);
}
}
}
}
break;
首先我们想要确定这个事件是从inputStream来的。然后我们准备了一个buffer(1024对于这个教程来说够用了)
最后我们开始了一个循环来收集stream中的bytes。这个read方法当没有数据可读的时候返回0.所以当结果比0大,我们将buffer转换为字符串并且打印这个结果。
我们做了最困难的部分。现在我们只需要将字符串添加到消息数组然后重载table view。
- (void) messageReceived:(NSString *)message {
[messages addObject:message];
[self.tView reloadData];
}
然后在NSLog语句之后加上下面这句
[self messageReceived:output];
我们完成了,让我们运行这个服务端,用iPhone应用连接,然后用telnet连接。你应该可以注册并且发送接收消息。祝贺!
调整
有一些可以改进应用的调整。首先,当消息发送后我们可以清空信息框。
inputMessageField.text = @""; // clean the field
第二,当消息的数量增加时,table隐藏了位于键盘下面的消息。为了讷讷狗启用自动滚动,添加下面的代码到messageReceived的底部
NSIndexPath *topIndexPath =
[NSIndexPath indexPathForRow:messages.count-1
inSection:0];
[self.tView scrollToRowAtIndexPath:topIndexPath
atScrollPosition:UITableViewScrollPositionMiddle
animated:YES];
对于streams,当服务端关闭连接后,我们应该记住关闭stream,并且将它从run-loop中移除。如下所示
case NSStreamEventEndEncountered:
[theStream close];
[theStream removeFromRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
break;
在设备上运行app
如果你想要在你的设备上运行这个应用,如果你在你的本机上运行的你服务端,你可以会遇到路由问题。
如果你遇到了,这里有一些小技巧。首先,记住将localhost字符串换成你的计算机ip。到系统偏爱-网络中可以发现你的当前ip
你的设备应该通过无线和你的计算机接到同一个路由器。如果你想要使用3G连接,你需要配置你的路由。