May 5

iOS实践http分块传输(URLSession+NSXMLParser)

Lrdcq , 2019/05/05 21:08 , 程序 , 閱讀(2487) , Via 本站原創
restful接口时代来临后,我们很少会提到http分块传输这件事情。http分块传输是指的服务端以数据流的方式分段输出数据到客户端,并且客户端分段接收数据并展示,此时返回体的header会有标示Transfer-Encoding: chunked,当然因为是分端返回的,就不存在Content-Length了(参考https://www.jianshu.com/p/d9941adfe58f)。
目前随着轻客户端重服务端的原理,服务端处理返回的数据量是越来越大,并且一些重业务的场景,restful过于庞大并且越来越缓慢。为了保持接口一致性的前提下提升用户体验,http分块传输不失为一种可以讨论方案。

前期设定

如果是分块传输,目前常用的restful传输接口实现——json就不可用了,json本身一般是不支持流式阅读的。如果一定要一整个阅读,分块传输就没意义了。场景的流式阅读的文件格式是:xml。比如可以想象html网页就是从上到下一边加载一边渲染的。同时考虑对于复杂的字段,xml的字段确实也太繁琐了,还是json精简一些。因此可以设定为以xml来包裹最小阅读分块数据,每个数据块里通过json去表达。

- 设定1:通过xml来构成返回体结构,通过json去填充每个返回数据块的内容。

同时由于是一边输出一边计算,因此它不想restful接口一样结果有一个明确的状态成功或者失败,有可能输出着输出着突然发生了异常并且传输中断了。为了异常处理,至少期望传输的数据中有客户端的格式即xml中可以捕获的异常。因此异常输出需要商定一个xml可以处理的异常格式,并且保证在遇到异常标签为止,xml格式符合预期。

- 设定2:异常处理需要在xml数据流中返回并通过xml处理。

基于以上设定,我们按现代iOS开发模式,选择关键类:网络请求URLSession(支持分端接收数据),xml解析NSXMLParser(支持流式输出数据)

同时编写服务端demo:http://lrdcq.com/test/oc_xml_reader.php
<?php
header('Content-type: application/json; charset=utf-8');
echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>";
echo '<root>';
for ($i = 0; $i < 10; $i++) {
    echo "<node>{\"name\":\"你好啊,$i!\",\"desc\":\"我是第 $i 个lrdcq的描述。\"}</node>";
    flush();
    ob_flush();
    sleep(1);
    if ($_GET["exception"] && $i == 6) {
      throw new Exception('<error>数据解析异常</error>');
    }
}
echo '</root>';
?>

正常数据如下:
點擊在新視窗中瀏覽此圖片

使用URLSession接收分块传输数据

URLSession有NSURLSessionDelegate中可以处理类似的处理方法,主要包括:
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
    //接收到部分数据(data)
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
    completionHandler(NSURLSessionResponseAllow);
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(nullable NSError *)error {
    //请求完成,处理流关闭
}

这个有几个坑:

1. NSURLSession的创建要指明delegate和delegateQueue(默认主线程)

2. 数据传输用NSURLSessionDataTask即可,不需要downloadTask之类的
_session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:self delegateQueue:nil];
NSURLSessionDataTask *dataTask = [_session dataTaskWithRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"http://lrdcq.com/test/oc_xml_reader.php"]]];
[dataTask resume];

3. didReceiveResponse的触发比较奇怪,会在buffer接收一定数据之后才会触发。但是如果服务端返回的是json格式,就会立刻触发。这可能是苹果的一个bug(future):https://developer.apple.com/forums/thread/64875

4. task创建时不要用带completionHandler的构造方法,相关方法不会调用delegate:These methods create tasks that bypass the normal delegate calls for response and data delivery, and provide a simple cancelable asynchronous interface to receiving data.

xml解析

NSXMLParser可以以一个NSInputStream的方式进行输入,并且一边输出一边处理。

这里我们按demo的数据,假设我们要处理一个叫node和error的节点,并且不考虑嵌套。其中node中是一个json数据,储存到一个array里。error是错误处理专用数据。那么最粗暴的处理代码为:
_xml = [[NSXMLParser alloc] initWithStream:_streamInput];
_xml.delegate = self;
dispatch_async(dispatch_queue_create("parser.queue", NULL), ^{
    [_xml parse];
    NSLog(@"parse end");
});

- (void)parser:(NSXMLParser *)parser didStartElement:(NSString *)elementName namespaceURI:(nullable NSString *)namespaceURI qualifiedName:(nullable NSString *)qName attributes:(NSDictionary<NSString *, NSString *> *)attributeDict {
    if ([elementName isEqualToString:@"node"] || [elementName isEqualToString:@"error"]) {
    _elementString = [NSMutableString string];
    }
}

- (void)parser:(NSXMLParser *)parser foundCharacters:(NSString *)string {
    [_elementString appendString:string];
}

- (void)parser:(NSXMLParser *)parser didEndElement:(NSString *)elementName namespaceURI:(nullable NSString *)namespaceURI qualifiedName:(nullable NSString *)qName {
    if ([elementName isEqualToString:@"node"]) {
        NSError *err;
        NSDictionary *node = [NSJSONSerialization JSONObjectWithData:[[_elementString copy] dataUsingEncoding:NSUTF8StringEncoding] options:NSJSONReadingMutableContainers error:&err];
        [_nodes addObject:node];
    } else if ([elementName isEqualToString:@"error"]) {
        self.error = [_elementString copy];
        [parser abortParsing];
    }
    _elementString = nil;
}

需要节点开始/节点结束,和节点内容处理的3个方法实现。其中也有几个坑:

1. NSXMLParser的开始方法是同步方法,Stream有多久它就会卡多久,因此务必单独开现场去启动。

2. 因为是流式处理,foundCharacters方法有可能分段的返回节点数据,因此用了一个NSMutableString来储存拼装。

3. NSXMLParser的delegate返回是在异步现场的并且在多个线程中跳转,所以用上面这种写法其实是不安全的,需要添加额外的保护。

stream流动起来

如何从URLSession拿到一个输出的stream并且塞给NSXMLParser呢?通过一个输入输出Stream绑定即可:
NSInputStream *inputStream = nil;
NSOutputStream *outputStream = nil;
[NSStream getBoundStreamsWithBufferSize:2048 inputStream:&inputStream outputStream:&outputStream];
[inputStream open];
[outputStream open];
_streamInput = inputStream;
_streamOutput = outputStream;

//把streamInput丢给NSXMLParser
_xml = [[NSXMLParser alloc] initWithStream:_streamInput];

//往streamOutput写入didReceiveData的数据
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
    [self.streamOutput write:[data bytes] maxLength:data.length];
}

还是有几个小坑:

1. getBoundStreamsWithBufferSize:需要一个buffer,即每次streamOutput写入时可能存在的最大size,否则write会写入不完整,为了保险起见还是尽量大吧。反正数据网络请求再大大不了多少。要么,write部分做相关保护逻辑。

2. streamOutput记得创建即open,并且在网络请求完成时关闭streamOutput(inputStream也会自动关闭)。这样也才能出发NSXMLParser的解析完成。

3. NSXMLParser解析到错误的时候,也可以主动关闭网络请求与stream,避免存在内存泄漏。错误的情况如下:
點擊在新視窗中瀏覽此圖片

其他

1. 虽然是分块传输,但是视网络通信情况,客户端收到的块和服务端发出的块并不完全一致,可能存在风险。

2. 同样,node在异常处理的情况下会有截断风险,error处理除了xml标签也可以通过特殊符号在网络层处理而不是xml解析层处理。渲染数据同理。

3. 分块http通信时间过长有中断的风险,不宜使用到适合长链接解决的场景里(比如聊天室)。同时现代客户端通信方式也有不少替代方案,怀旧党酌情使用,梦回1998。

other:如上图demo代码如下:
@interface DemoViewController () <NSXMLParserDelegate, NSURLSessionDataDelegate, UITableViewDelegate, UITableViewDataSource>

//数据加载
@property (strong, nonatomic) NSXMLParser *xml;
@property (strong, nonatomic) NSURLSession *session;
@property (strong, nonatomic) NSInputStream *streamInput;
@property (strong, nonatomic) NSOutputStream *streamOutput;

//数据处理
@property (strong, nonatomic) NSMutableString *elementString;//临时储存节点数据

//渲染数据
@property (strong, nonatomic) NSString *error;
@property (strong, nonatomic) NSMutableArray<NSDictionary *> *nodes;
@property (strong, nonatomic) UITableView *table;

@end

@implementation DemoViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    _nodes = [NSMutableArray array];
    
    //数据加载
    _session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:self delegateQueue:nil];
    NSURLSessionDataTask *dataTask = [_session dataTaskWithRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"http://lrdcq.com/test/oc_xml_reader.php?exception=1"]]];
    
    NSInputStream *inputStream = nil;
    NSOutputStream *outputStream = nil;
    [NSStream getBoundStreamsWithBufferSize:2048 inputStream:&inputStream outputStream:&outputStream];
    [inputStream open];
    [outputStream open];
    _streamInput = inputStream;
    _streamOutput = outputStream;
    
    _xml = [[NSXMLParser alloc] initWithStream:_streamInput];
    _xml.delegate = self;
    
    [dataTask resume];
    
    dispatch_async(dispatch_queue_create("parser.queue", NULL), ^{
        [_xml parse];
        NSLog(@"parse end");
    });
    
    //界面组成
    
    _table = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStylePlain];
    _table.delegate = self;
    _table.dataSource = self;
    [_table registerClass:[UITableViewCell class] forCellReuseIdentifier:@"TableCellId"];
    [self.view addSubview:_table];
}

#pragma mark - TableView

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    if (indexPath.row < _nodes.count) {
        NSDictionary *node = _nodes[indexPath.row];
        UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"TableCellId"];
        [cell.textLabel setText:[NSString stringWithFormat:@"%@ - %@", node[@"name"], node[@"desc"]]];
        [cell.textLabel setFont:[UIFont systemFontOfSize:16]];
        [cell.textLabel setTextColor:[UIColor grayColor]];
        cell.backgroundColor = [UIColor whiteColor];
        return cell;
    } else {
        UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"TableCellId"];
        [cell.textLabel setText:_error];
        [cell.textLabel setFont:[UIFont systemFontOfSize:20]];
        [cell.textLabel setTextColor:[UIColor redColor]];
        cell.backgroundColor = [UIColor whiteColor];
        return cell;
    }
}

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return _nodes.count + (_error.length ? 1 : 0);
}

#pragma mark - NSURLSession

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
    NSLog(@"didReceiveData = %@", [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]);
    const uint8_t *dataBytes = [data bytes];
    [self.streamOutput write:dataBytes maxLength:data.length];
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
    completionHandler(NSURLSessionResponseAllow);
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(nullable NSError *)error {
    [self.streamOutput close];
}

#pragma mark - NSXMLParser

- (void)parser:(NSXMLParser *)parser didStartElement:(NSString *)elementName namespaceURI:(nullable NSString *)namespaceURI qualifiedName:(nullable NSString *)qName attributes:(NSDictionary<NSString *, NSString *> *)attributeDict {
    if ([elementName isEqualToString:@"node"] || [elementName isEqualToString:@"error"]) {
    _elementString = [NSMutableString string];
    }
}

- (void)parser:(NSXMLParser *)parser foundCharacters:(NSString *)string {
    [_elementString appendString:string];
}

- (void)parser:(NSXMLParser *)parser didEndElement:(NSString *)elementName namespaceURI:(nullable NSString *)namespaceURI qualifiedName:(nullable NSString *)qName {
    if ([elementName isEqualToString:@"node"]) {
        NSError *err;
        NSDictionary *node = [NSJSONSerialization JSONObjectWithData:[[_elementString copy] dataUsingEncoding:NSUTF8StringEncoding] options:NSJSONReadingMutableContainers error:&err];
        [_nodes addObject:node];
        dispatch_async(dispatch_get_main_queue(), ^{
            [_table reloadData];
        });
    } else if ([elementName isEqualToString:@"error"]) {
        self.error = [_elementString copy];
        [parser abortParsing];
        dispatch_async(dispatch_get_main_queue(), ^{
            [_table reloadData];
        });
    }
    _elementString = nil;
}

@end
关键词:oc , xml , nsxmlparser
logo