iOS开发-TFTP客户端和服务器的实现

TFTP(Trival File Transfer Protocal),简单文件传输协议,该协议在端口69上使用UDP服务。TFTP协议常用于无盘工作站或路由器从别的主机上获取引导配置文件,由于TFTP报文比较小,能个迅速复制这些文件

因为作者是做智能家居方向的开发,公司初期实现硬件的升级是先通过手机端从服务器下载固件,然后在通过某种协议把固件传输给硬件设备,硬件设备接收完成之后进行升级。这里就涉及到一个协议的选择,固件本身并不是很大,一般在1M以内,这时候TFTP协议无非是最好的选择之一,轻量级的传输不显得复杂,对系统的开销也小。但是网上找了很久都没发现有相关的Demo,所以今天就简单的基于OC搭建一个TFTP通信的客户端和服务器(Demo简单,暂不支持IPv6和数据包超时计时(Demo里面已经加上超时处理和错误抛出))。

1.TFTP概况

TFTP是一个传输文件的简单协议,它基于UDP协议而实现,但是我们也不能确定有些TFTP协议是基于其它传输协议完成的。此协议设计的时候是进行小文件传输的。因此它不具备通常的FTP的许多功能,它只能从文件服务器上获得或写入文件。
TFTP传输起自一个读取或写入文件的请求,这个请求也是连接请求。如果服务器批准此请求,则服务器打开连接,数据以定长传输(一般定在512字节以内)。每个数据包包括数据块号和一块数据,服务器发出下一个数据包以前必须得到客户对上一个数据包的确认。如果一个数据包的大小小于规定长度,则表示传输结束。通信的双方都是数据的发出者与接收者,一方传输数据接收应答,另一方发出应答接收数据。大部分的错误会导致连接中断,错误由一个错误的数据包引起。这个协议限制很多,这些都是为了实现起来比较方便而进行的。

2.TFTP协议

既然写TFTP通信,上来最重要的肯定是协议

TFTP协议.png

1:对于数据长度以字节来计算和标识。
2:对于TFTP数据包我这里面只写了其中五中,对于简单的开发已经足够了,操作码分别对应RRQ读请求、WRQ写请求、DATA数据包、ACK数据包确认、ERROR差错包。
3:文件名、模式、差错信息这些数据长度都是不固定的,文件分成数据块传输,数据块一般定在0-512个字节之间,太大可能传输的就不一定安全,因为TFTP是基于UDP来进行数据传输,在数据链路层有MTU的限制每个数据包的大小(1500字节)
MTU(1500 byte)-PPP的包头包尾的开销(8 byte)-IP头(20 byte)-UDP头(8 byte) = 1464(byte)
从上面看出实际每个UDP包数据是在1464字节以内,如果超过这个临界值,系统内部会对数据包进行分片传输,由于UDP数据包的发送和接收都是无确认,让系统分包去传送可能存在数据丢失,所以业界一般没有将数据块定太大,通用512个字节。
4:文件名和差错信息最好都用英文字母或者英文字符,一般硬件是低级的单片机,内部存储空间有限,所以一般里面不一定装有UTF-8数据编码表,一般都是ASCII编码表,掺杂其他字符可能解析不出来,这个编码方式具体看硬件

3.TFTP通信流程

TFTP通信流程.png

1.首先服务器绑定固定端口号开始监听客户端的连接
2.一切从客户端发送的第一个数据包开始,里面包含有读文件还是写文件的操作码,需要操作的文件名
3.服务器找到对应的文件,开始分成块传送给设备
4.设备收到对应的数据块后回应个服务器确认包,里面包含确认块号,告诉服务器这块我收到了你可以传下块了
5.直到最后一个分包传给设备,设备根据包的大小和规定分片的大小做对比,如果小于规定的分片则表示是最后一个数据包,向服务器发送一个ACK确认包,然后关闭连接,服务器收到最后一个确认的ACK后也关掉自己的Socket,本次传输完成;如果最后一个包正好也是分片的大小,服务器接下来还得传输一个操作码后面数据长度为0的数据包过去,这样客户端才知道没有数据了

4.代码实现部分

话不多说,先看Demo效果(效果图为GIF动态图,动画只执行一次,看不到效果可以刷新下页面重新播放)

TFTP-Demo效果.gif

话不多说,再上Demo代码地址(点击文字下载)

(1)服务器

① 首先初始化套接字,并绑定到指定端口(我这里用传进来的一个端口号,并没有写成固定的69),同时检查创建的套接字的读写能力(下面要用这个套接字监听设备数据返回和向设备发送数据)

//套接字初始化(Create Socket)
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (_sockfd <= 0) {
    [self throwErrorWithCode:errno reason:@"Failed to create socket"];
    return;
}
    
//绑定监听地址
struct sockaddr_in addr_server;
addr_server.sin_len = sizeof(struct sockaddr_in);
addr_server.sin_family = AF_INET;
addr_server.sin_port = htons(bindPort);
addr_server.sin_addr.s_addr = htonl(INADDR_ANY);
    
if (bind(_sockfd, (struct sockaddr*)&addr_server, addr_server.sin_len) < 0) {
    [self throwErrorWithCode:errno reason:@"Binding socket failed"];
    return;
}

②监听客户端的连接,直到有数据请求,同时设置等待超时时间为30s(30s内没有连接则关闭套接字) ->如果有连接则进入步骤③,数据解析

//申明一个接受客户端连接套接字的地址
struct sockaddr_in addr_clict;
socklen_t addr_clict_len = sizeof(struct sockaddr_in);
addr_clict.sin_len = addr_clict_len;
    
//设置接收请求连接超时时间为30s
struct timeval timeout = {30,0};
if (setsockopt(_sockfd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(struct timeval)) < 0) {
    printf("开始设置Socket服务器接收连接超时失败: %s\n",strerror(errno));
}

while (1) {
        
    if (_isOpen == NO) return; //服务器关闭直接退出
        
    char recv_buffer[1024];     //接收数据缓冲区
    ssize_t result_recv = recvfrom(_sockfd, recv_buffer, sizeof(recv_buffer), 0, (struct sockaddr*)&addr_clict, &addr_clict_len);
    if (result_recv < 0 && _isOpen) {
        [self throwErrorWithCode:errno reason:@"Read data error"];
        return;
    }
        
    if (result_recv < 4) continue; //数据包长度必须大于或等于4,否则不是我们想要的数据
        
   //有接收到数据,进入下面的数据解析部分 ->③
}

③有接收到数据,开始解析数据是否是客户端的连接请求,如果是客户端的连接请求则‘连接’该套接字,实际上叫注册更准确,这里的connect() 并不创建实质意义上的连接,只是向套接字中注册目的地址信息,方便后面数据的收发(不用每次发送和接收数据都注册地址信息),接着解析出请求文件名并将该文件加载到缓存,开始下面的数据传输

if (recv_buffer[1] == TFTP_RRQ) { //操作码是读请求 -> 有客户端连接
            
    //注册客户端地址信息
    if (connect(_sockfd, (struct sockaddr*)&addr_clict, sizeof(addr_clict)) != 0) {
        [self throwErrorWithCode:errno reason:@"Registration destination address failed"];
        return;
    }
            
    //1. 解析出文件名
    char* cFileName = &recv_buffer[2];
    NSLog(@"[TFTPServer] 收到第一个请求包IP: %s, 文件名: %s",inet_ntoa(addr_clict.sin_addr),cFileName);

    //2. 拼接路径
    _filepath = [_filepath stringByAppendingPathComponent:[NSString stringWithCString:cFileName encoding:NSUTF8StringEncoding]];
            
    //3. 初始化一些数据
     _fileTotalLen = self.fileData.length;
     NSLog(@"[TFTPServer] 文件长度: %lu",(unsigned long)_fileTotalLen);
     if (_fileTotalLen == 0) {
                
        char send_buffer[512];
        NSUInteger len = [TFTPServerPacket makeErrorDataWithCode:1000
                                                          reason:"Request file name error"
                                                      sendBuffer:send_buffer];
        sendto(_sockfd, send_buffer, len, 0, (struct sockaddr*)&addr_clict, addr_clict.sin_len);
                
        [self throwErrorWithCode:errno reason:@"Request file name error"];
        return;
    }

    //一切准备就绪,开始传输数据 ->④        
    [self beganToTransportData];
    return;
}

④开始向客户端地址传送数据,发送第一个数据包,记录下发送文件的长度并且判断是否是最后一个数据包(数据包拼接部分很简单,按照上面的协议来拼接数据,这里就不贴代码了,具体的自己可以下载下面的demo看),并且设置套接字接收数据超时时间为6s,防止中间传输失败重传->进入步骤⑤,循环监听客户端数据返回

//1. 局部变量的声明
char recv_buffer[1024];     //接收数据缓冲区
char send_buffer[1024];     //发送数据缓冲区
NSUInteger sendLen = 0;     //发送数据的长度
    
//2. 初始化一些数据
_blocknum = 1;
_alreadySendLen = 0;
int retry = 0;                  //同一个包重传次数
BOOL isLastPacket = false;      //记录是否是最后一个数据包
    
//3. 第一个数据包的发送
sendLen = [TFTPServerPacket makeDataWithTotalData:self.fileData
                                       sendBuffer:send_buffer
                                         location:_alreadySendLen
                                           length:TFTP_BlockSize
                                         blocknum:_blocknum];
if (sendLen < (TFTP_BlockSize + 4)) isLastPacket = YES; //记录下是发送的最后一个数据包
    
if (send(_sockfd, send_buffer, sendLen, 0) < 0 && _isOpen) {
    [self throwErrorWithCode:errno reason:@"Send data error"];
    return;
}
_alreadySendLen = sendLen - 4;
    
//开始传输数据时,定个数据包接收超时时间段为6s
struct timeval timeout = {6,0};
if (setsockopt(_sockfd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(struct timeval)) < 0) {
    printf("设置Socket通信过程中,接收客户端数据超时失败:%s\n",strerror(errno));
}

//开始下面的监听ACK返回,发送接下来的数据包 ->⑤

⑤循环持续读取输入缓冲中的数据,如果读取超时则重发上次的数据包,连续三次超时则抛出错误关闭套接字,若收到客户端的数据 -> 进入步骤⑥,解析数据包

//4. while循环监听数据包的返回
while (1) {
    
    if (_isOpen == NO) return; //服务器关闭直接退出监听
    
    ssize_t result_recv = recv(_sockfd, recv_buffer, sizeof(recv_buffer), 0);
    if (result_recv < 0 && _isOpen) {
        if (errno == EAGAIN) { //接收超时重传
            retry ++;
            if (retry >= MAX_RETRY) {
                NSLog(@"[TFTPServer] 接收ACK超时,发送差错包给客户端");
                sendLen = [TFTPServerPacket makeErrorDataWithCode:1001
                                                           reason:"The maximum number of retransmissions"
                                                       sendBuffer:send_buffer];
                send(_sockfd, send_buffer, sendLen, 0);
                [self throwErrorWithCode:1001 reason:@"The maximum number of retransmissions"];
                return;
            }else {
                NSLog(@"[TFTPServer] 接收客户端确认包超时 -> 重传上次的包(块号:%u)",_blocknum);
                if (send(_sockfd, send_buffer, sendLen, 0) < 0 && _isOpen) {
                    [self throwErrorWithCode:errno reason:@"Send data error"];
                    return;
                }
                continue;
            }
        }else {
            [self throwErrorWithCode:errno reason:@"Read data error"];
            return;
        }
    }
    
    //数据包长度小于4不要
    if (result_recv < 4) continue;

    //接下来解析客户端发送回来的数据和对应数据发送还有服务器端操作 ->⑥
}

⑥解析客户端发送回来的数据,首先确认操作码(是否是ACK数据包,此时ACK才是我们需要的,错误包做下解析和对应的操作,其他的数据包可以忽略),如果是ACK数据包 ->进行步骤⑦,解析ACK数据包并判断

//先解析操作码
char opCode = recv_buffer[1];
if (opCode == TFTP_RRQ || opCode == TFTP_WRQ || opCode == TFTP_DATA) {
    NSLog(@"[TFTPServer] 客户端发错了数据包(操作码: %d), 不理",opCode);
}else if (opCode == TFTP_ACK) { //收到ACK数据包

     //收到设备端的ACK确认包->进行ACK块号确认,发送对应数据包 ->⑦

}else if (opCode == TFTP_ERROR) {
    
    //客户端那边发送过来了错误包
    NSString *errStr = [[NSString alloc] initWithBytes:&recv_buffer[4] length:result_recv-4 encoding:NSUTF8StringEncoding];
    NSLog(@"[TFTPServer] 客户端传送过来差错信息:  错误码 -> %u 错误信息 -> %@",(((recv_buffer[2] & 0xff) << 8) | (recv_buffer[3] & 0xff)),errStr);
    [self throwErrorWithCode:(((recv_buffer[2] & 0xff) << 8) | (recv_buffer[3] & 0xff)) reason:errStr];
    return;
}else {
    NSLog(@"[TFTPServer] 客户端发错了数据包(操作码: %d), 不理",opCode);
}

⑦收到客户端的ACK确认包,首先判断是否是最后一个数据包的确认块号,如果是,则关闭服务器(断掉Socket),本次传输完成,如果不是则进行步骤⑧,解析块号判断,并做对应的操作

//①. 解析出确认块号
uint clict_sureblocknum = ((recv_buffer[2]&0xff)<<8)|((recv_buffer[3]&0xff));
//NSLog(@"[TFTPServer] 收到客户端的ACK数据包,块号:%d",clict_sureblocknum);

//②. 判断是否是最后一个包的确认
if (isLastPacket == YES && _blocknum == clict_sureblocknum) { //是最后一个包了
    
    [self sendComplete];
    return;
    
}else {
    
    //不是最后一个数据包的确认块号,解析出块号并判断 ->⑧
}

⑧解析出块号,与自己已经发送的块号做对比,如果是刚发的块号,则确认进行下一个包的发送,如果块号是上一个数据包的块号,则进入步骤⑨(数据包重发),否则则为块号错乱,退出重新发送

if (_blocknum == clict_sureblocknum) {
    _blocknum ++;
    retry = 0;
    
    sendLen = [TFTPServerPacket makeDataWithTotalData:self.fileData
                                           sendBuffer:send_buffer
                                             location:_alreadySendLen
                                               length:TFTP_BlockSize
                                             blocknum:_blocknum];
    
    if (sendLen < (TFTP_BlockSize + 4)) isLastPacket = YES; //记录下是发送的最后一个数据包
    
    if (send(_sockfd, send_buffer, sendLen, 0) < 0 && _isOpen) {
        [self throwErrorWithCode:errno reason:@"Send data error"];
        return;
    }
    _alreadySendLen += (sendLen - 4);
    
}else if (clict_sureblocknum == (_blocknum - 1)) {
    //ACK块号不对,进入重发机制
    
    //上一个数据包客户端接收有误, 重传 ->⑨
}else {
    
    NSLog(@"[TFTPServer] 客户端返回的确认块号不对 _blocknum:%u clict_sureblocknum:%u",_blocknum,clict_sureblocknum);
    [self throwErrorWithCode:1002 reason:@"Request block number error"];
    return;
}

⑨如果收到的是上次发送包的确认块号(数据丢失可能设备没有收到),则判断对上个包的重发次数有没有达到上限,如果达到上限,则向客户端发送一个差错包,告诉客户端此次传输有问题,服务器要断开连接了,如果没有达到上限则将上次发送的数据包重新发送

retry ++;
if (retry >= MAX_RETRY) {
    NSLog(@"[TFTPServer] 接收ACK错误次数达到上限,发送差错包给客户端");
    sendLen = [TFTPServerPacket makeErrorDataWithCode:1001
                                               reason:"The maximum number of retransmissions"
                                           sendBuffer:send_buffer];
    
    send(_sockfd, send_buffer, sendLen, 0);
    [self throwErrorWithCode:1001 reason:@"The maximum number of retransmissions"];
    return;
}else {
    NSLog(@"[TFTPServer] 客户端发送ACK块号有误(块号:%u), 重传上次的包(块号:%u)",_blocknum,clict_sureblocknum);
    if (send(_sockfd, send_buffer, sendLen, 0) < 0 && _isOpen) {
        [self throwErrorWithCode:errno reason:@"Send data error"];
        return;
    }
}
(2)客户端

①创建Socket套接字,绑定固定端口并检查套接字的读写情况,用来接收服务器数据返回

//初始化套接字
_sockfd = socket(AF_INET, SOCK_DGRAM, 0);

if (_sockfd <= 0) {
    [self throwErrorWithCode:errno reason:@"Failed to create socket"];
    return ;
}

struct sockaddr_in addr_bind;
addr_bind.sin_len = sizeof(struct sockaddr_in);
addr_bind.sin_family = AF_INET;
addr_bind.sin_port = htons(port);
addr_bind.sin_addr.s_addr = htonl(INADDR_ANY);

if (bind(_sockfd, (struct sockaddr*)&addr_bind, addr_bind.sin_len) < 0) {
    [self throwErrorWithCode:errno reason:@"Binding socket failed"];
    return;
}

②初始化服务器地址信息,并注册服务器地址信息,方便后面直接接收数据和发送数据,并且设置接读取输入缓冲超时时间为6s,防止传输过程中出现异常

//注册套接字目的地址
struct sockaddr_in addr_server;
addr_server.sin_len = sizeof(struct sockaddr_in);
addr_server.sin_family = AF_INET;
addr_server.sin_port = htons(port);
inet_pton(AF_INET, host.UTF8String, &addr_server.sin_addr);

if (connect(_sockfd, (struct sockaddr*)&addr_server, addr_server.sin_len) < 0) {
    [self throwErrorWithCode:errno reason:@"Registration destination address failed"];
    return;
}

//设置读取数据超时
struct timeval timeout = {6, 0};
if (setsockopt(_sockfd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(struct timeval)) < 0) {
    printf("[TFTPClient] 设置接收数据超时失败:%s",strerror(errno));
}

③开始向服务器发送第一个数据包,文件请求数据包

//1. 初始化一些变量
char sendBuffer[1024];      //发送数据缓存区
NSUInteger sendLen;         //发送数据长度
char recvBuffer[1024];      //接收数据缓存区
_blocknum = 0;              //接收块号记录
self.fileData.length = 0;   //接收文件缓存区
int retry = 0;              //记录同一个包的请求次数
BOOL isLastPacket = false;  //记录是否是最后一个数据包

//2. 发送文件请求包
sendLen = [TFTPClientPacket makeRRQWithFileName:filename
                                     sendBuffer:sendBuffer];
if (send(_sockfd, sendBuffer, sendLen, 0) < 0 && _isOpen) {
    [self throwErrorWithCode:errno reason:@"Read data error"];
    return;
}

//开始监听服务器数据发送了 -> ④

④循环监听服务器发送数据,首先判断是否读取超时,如果超时则重发上一次的确认块,连续三次超时则抛出错误关闭套接字。如果收到数据则判断数据长度是否满足自己的需求,都满足则进行接下来的解析->步骤⑤,不满足跳过本地循环读取,进行下次读取

//3. 开始监听数据返回
while (1) {
    
    ssize_t result_recv = recv(_sockfd, recvBuffer, sizeof(recvBuffer), 0);
    if (result_recv < 0 && _isOpen) {
        
        if (errno == EAGAIN) { //读取数据超时
            retry++;
            if (retry >= MAX_RETRY) {
                NSLog(@"[TFTPClient] 请求超时,发送差错包给服务器");
                sendLen = [TFTPClientPacket makeErrorDataWithCode:1001
                                                           reason:"The maximum number of retransmissions"
                                                       sendBuffer:sendBuffer];
                send(_sockfd, sendBuffer, sendLen, 0);
                [self throwErrorWithCode:1001 reason:@"The maximum number of retransmissions"];
                return;
            }else {
                //重发上一个ACK确认包
                NSLog(@"[TFTPClient] 客户端请求数据块超时,重发上个ACK(块号:%u)",_blocknum);
                if (send(_sockfd, sendBuffer, sendLen, 0) < 0 && _isOpen) {
                    [self throwErrorWithCode:errno reason:@"Send data error"];
                    return;
                }
                continue;
            }
        }else {
            [self throwErrorWithCode:errno reason:@"Read data error"];
            return;
        }
    }
    
    //数据长度过短或不是我们需要服务器地址发送过来的数据都不是我们想要的数据, 直接丢掉
    if (result_recv < 4) continue;
    
    //是自己需要的数据,解析出操作码,进行相应的操作 ->⑤
}

⑤首先解析出接收到数据包的操作码,判断是否是DATA对应的操作码,如果不是抛出对应的信息和进行对应的操作,如果是则进行接下来的操作->步骤⑥,块号的确认

//解析操作码
char opCode = recvBuffer[1];
if (opCode == TFTP_RRQ || opCode == TFTP_WRQ || opCode == TFTP_ACK) {
    
    NSLog(@"[TFTPClient] 服务器发送了错误数据包(操作码: %d),不理",opCode);
    
}else if (opCode == TFTP_DATA) {
    /* 服务器发送过来数据包 */
    
    //进行接下来的数据解析和拼接,发送给确认包 ->⑥
    
}else if (opCode == TFTP_ERROR) {
    
    NSString *errStr = [[NSString alloc] initWithBytes:&recvBuffer[4] length:result_recv-4 encoding:NSUTF8StringEncoding];
    NSLog(@"[TFTPClient] 服务器传送过来差错信息: 错误码 -> %u 错误信息 -> %@",(((recvBuffer[2] & 0xff) << 8) | (recvBuffer[3] & 0xff)),errStr);
    [self throwErrorWithCode:(((recvBuffer[2] & 0xff) << 8) | (recvBuffer[3] & 0xff)) reason:errStr];
    return;
}else {
    NSLog(@"[TFTPClient] 服务器传过来不知名的数据包(操作码: %d)",opCode);
}

⑥解析服务器发送过来的数据包,拿到块号和自己这边记录的块号做对比,如果块号正确则向服务器发送本次的块号确认ACK包,并把数据拼接到缓存,如果收到数据包块号不正确,则进行步骤⑦重确认操作

//解析出块号, 与自己的块号作比较, 看看服务器有没有发错
uint blocknum = (recvBuffer[2]&0xff)<<8 | (recvBuffer[3]&0xff);
//NSLog(@"[TFTPClient] 服务器发送过来数据包,块号:%u",blocknum);

if (blocknum == (_blocknum + 1)) {
    
    retry = 0;
    _blocknum = blocknum;
    
    //解析数据包, 并且判断是否是最后一个数据包
    NSData *data = [NSData dataWithBytes:&recvBuffer[4] length:result_recv-4];
    [self.fileData appendData:data];
    
    if (data.length < TFTP_BlockSize) isLastPacket = YES;
    
    //发送ACK确认包
    sendLen = [TFTPClientPacket makeACKWithBlockNum:_blocknum sendBuffer:sendBuffer];
    if (send(_sockfd, sendBuffer, sendLen, 0) < 0 && _isOpen) {
        [self throwErrorWithCode:errno reason:@"Send data error"];
        return;
    }
}else {
    //块号不对,进入重发机制
    //传送数据块号不对,进行重新确认 -> ⑦
}

⑦服务器发送的数据块号和自己将要接收的块号对不上,拿到自己最后一个接收到的数据包块号,先判断对该数据包的确认次数有没有达到上限,达到上限则向服务器发送差错包,表示本次传输出错,并关掉套接字,如果没有达到上限,那么重发这个确认块号,向服务器确认得到正确的数据块

retry++;
if (retry >= MAX_RETRY) {
    NSLog(@"[TFTPClient] 接收数据包错误次数达到上限,发送差错包给客户端");
    sendLen = [TFTPClientPacket makeErrorDataWithCode:1001
                                               reason:"The maximum number of retransmissions"
                                           sendBuffer:sendBuffer];
    send(_sockfd, sendBuffer, sendLen, 0);
    [self throwErrorWithCode:1001 reason:@"The maximum number of retransmissions"];
    return;
}else {
    NSLog(@"[TFTPClient] 服务器发送块号不对(块号:%u), 重发送上个ACK确认包(块号:%u)",blocknum,_blocknum);
    if (send(_sockfd, sendBuffer, sendLen, 0) < 0 && _isOpen) {
        [self throwErrorWithCode:errno reason:@"Send data error"];
        return;
    }
}

⑧确认包发送完或者确认包重发完,判断是否是最后一个数据包的确认,如果是则关闭Socket,本次传输完成,不是则跳过接着监听

if (isLastPacket) { //就收完成
     [self recevComplete];
     return;
}

5.结语

上面的代码部分只是一个大概的思路讲解,主要为了方便理解demo里面的代码逻辑,具体的还是要看demo。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容