天天看點

【最佳實踐】OSS移動端斷點下載下傳簡單實作

  • 概要
  • 技術點
  • 最佳實踐
  • 改進
  • 參考

所謂斷點下載下傳,其實是用戶端在從網絡上下載下傳資源時,由于某種原因中斷下載下傳。再次開啟下載下傳時可以從已經下載下傳的部分開始繼續下載下傳未完成的部分,進而節省時間和流量。

應用場景:當我們在手機端使用視訊軟體下載下傳視訊時,下載下傳期間網絡模式從WIFI切換到移動網絡,預設App都會中斷下載下傳。當再次切換到WIFI網絡時,由使用者手動重新開啟下載下傳任務,此時就用到了斷點下載下傳。

優點:節省時間和流量。

HTTP1.1中新增了Range頭的支援,用于指定擷取資料的範圍。Range的格式一般分為以下幾種:

  • Range: bytes=100-

    從 101 bytes 之後開始傳,一直傳到最後。
  • Range: bytes=100-200

    指定開始到結束這一段的長度,記住 Range 是從 0 計數 的,是以這個是要求伺服器從 101 位元組開始傳,一直到 201 位元組結束。這個一般用來特别大的檔案分片傳輸,比如視訊。
  • Range: bytes=-100

    如果範圍沒有指定開始位置,就是要伺服器端傳遞倒數 100 位元組的内容。而不是從 0 開始的 100 位元組。
  • Range: bytes=0-100, 200-300

    也可以同時指定多個範圍的内容,這種情況不是很常見。

另外斷點續傳時需要驗證伺服器上面的檔案是否發生變化,此時用到了

If-Match

頭。

If-Match

對應的是

Etag

的值。

用戶端在發起請求時在Header中攜帶

Range

,

If-Match

,OSS伺服器在收到請求後會驗證'If-Match'中的Etag值,如果不比對,則會傳回412 precondition 狀态碼。

OSS的伺服器針對getObject這個開放API已經支援了

Range

If-Match

If-None-Match

If-Modified-Since

If-Unmodified-Since

,所有我們能夠在移動端實踐OSS資源的斷點下載下傳功能。

首先來看一下流程圖

【最佳實踐】OSS移動端斷點下載下傳簡單實作

效果圖:

【最佳實踐】OSS移動端斷點下載下傳簡單實作

這裡以iOS下載下傳實作思路為例,參考代碼如下 僅供參考,切不可用于生産!

#import "DownloadService.h"
#import "OSSTestMacros.h"

@implementation DownloadRequest

@end

@implementation Checkpoint

- (instancetype)copyWithZone:(NSZone *)zone {
    Checkpoint *other = [[[self class] allocWithZone:zone] init];
    
    other.etag = self.etag;
    other.totalExpectedLength = self.totalExpectedLength;
    
    return other;
}

@end


@interface DownloadService()<NSURLSessionTaskDelegate, NSURLSessionDataDelegate>

@property (nonatomic, strong) NSURLSession *session;         //網絡會話
@property (nonatomic, strong) NSURLSessionDataTask *dataTask;   //資料請求任務
@property (nonatomic, copy) DownloadFailureBlock failure;    //請求出錯
@property (nonatomic, copy) DownloadSuccessBlock success;    //請求成功
@property (nonatomic, copy) DownloadProgressBlock progress;  //下載下傳進度
@property (nonatomic, copy) Checkpoint *checkpoint;        //檢查節點
@property (nonatomic, copy) NSString *requestURLString;    //檔案資源位址,用于下載下傳請求
@property (nonatomic, copy) NSString *headURLString;       //檔案資源位址,用于head請求
@property (nonatomic, copy) NSString *targetPath;     //檔案存儲路徑
@property (nonatomic, assign) unsigned long long totalReceivedContentLength; //已下載下傳大小
@property (nonatomic, strong) dispatch_semaphore_t semaphore;

@end

@implementation DownloadService

- (instancetype)init
{
    self = [super init];
    if (self) {
        NSURLSessionConfiguration *conf = [NSURLSessionConfiguration defaultSessionConfiguration];
        conf.timeoutIntervalForRequest = 15;
        
        NSOperationQueue *processQueue = [NSOperationQueue new];
        _session = [NSURLSession sessionWithConfiguration:conf delegate:self delegateQueue:processQueue];
        _semaphore = dispatch_semaphore_create(0);
        _checkpoint = [[Checkpoint alloc] init];
    }
    return self;
}

+ (instancetype)downloadServiceWithRequest:(DownloadRequest *)request {
    DownloadService *service = [[DownloadService alloc] init];
    if (service) {
        service.failure = request.failure;
        service.success = request.success;
        service.requestURLString = request.sourceURLString;
        service.headURLString = request.headURLString;
        service.targetPath = request.downloadFilePath;
        service.progress = request.downloadProgress;
        if (request.checkpoint) {
            service.checkpoint = request.checkpoint;
        }
    }
    return service;
}

/**
 * head檔案資訊,取出來檔案的etag和本地checkpoint中儲存的etag進行對比,并且将結果傳回
 */
- (BOOL)getFileInfo {
    __block BOOL resumable = NO;
    NSURL *url = [NSURL URLWithString:self.headURLString];
    NSMutableURLRequest *request = [[NSMutableURLRequest alloc]initWithURL:url];
    [request setHTTPMethod:@"HEAD"];
    
    NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        if (error) {
            NSLog(@"擷取檔案meta資訊失敗,error : %@", error);
        } else {
            NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
            NSString *etag = [httpResponse.allHeaderFields objectForKey:@"Etag"];
            if ([self.checkpoint.etag isEqualToString:etag]) {
                resumable = YES;
            } else {
                resumable = NO;
            }
        }
        dispatch_semaphore_signal(self.semaphore);
    }];
    [task resume];
    
    dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
    return resumable;
}

/**
 * 用于擷取本地檔案的大小
 */
- (unsigned long long)fileSizeAtPath:(NSString *)filePath {
    unsigned long long fileSize = 0;
    NSFileManager *dfm = [NSFileManager defaultManager];
    if ([dfm fileExistsAtPath:filePath]) {
        NSError *error = nil;
        NSDictionary *attributes = [dfm attributesOfItemAtPath:filePath error:&error];
        if (!error && attributes) {
            fileSize = attributes.fileSize;
        } else if (error) {
            NSLog(@"error: %@", error);
        }
    }

    return fileSize;
}

- (void)resume {
    NSURL *url = [NSURL URLWithString:self.requestURLString];
    NSMutableURLRequest *request = [[NSMutableURLRequest alloc]initWithURL:url];
    [request setHTTPMethod:@"GET"];
    
    BOOL resumable = [self getFileInfo];    // 如果resumable為NO,則證明不能斷點續傳,否則走續傳邏輯。
    if (resumable) {
        self.totalReceivedContentLength = [self fileSizeAtPath:self.targetPath];
        NSString *requestRange = [NSString stringWithFormat:@"bytes=%llu-", self.totalReceivedContentLength];
        [request setValue:requestRange forHTTPHeaderField:@"Range"];
    } else {
        self.totalReceivedContentLength = 0;
    }
    
    if (self.totalReceivedContentLength == 0) {
        [[NSFileManager defaultManager] createFileAtPath:self.targetPath contents:nil attributes:nil];
    }
    
    self.dataTask = [self.session dataTaskWithRequest:request];
    [self.dataTask resume];
}

- (void)pause {
    [self.dataTask cancel];
    self.dataTask = nil;
}

- (void)cancel {
    [self.dataTask cancel];
    self.dataTask = nil;
    [self removeFileAtPath: self.targetPath];
}

- (void)removeFileAtPath:(NSString *)filePath {
    NSError *error = nil;
    [[NSFileManager defaultManager] removeItemAtPath:self.targetPath error:&error];
    if (error) {
        NSLog(@"remove file with error : %@", error);
    }
}

#pragma mark - NSURLSessionDataDelegate

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
didCompleteWithError:(nullable NSError *)error {
    NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)task.response;
    if ([httpResponse isKindOfClass:[NSHTTPURLResponse class]]) {
        if (httpResponse.statusCode == 200) {
            self.checkpoint.etag = [[httpResponse allHeaderFields] objectForKey:@"Etag"];
            self.checkpoint.totalExpectedLength = httpResponse.expectedContentLength;
        } else if (httpResponse.statusCode == 206) {
            self.checkpoint.etag = [[httpResponse allHeaderFields] objectForKey:@"Etag"];
            self.checkpoint.totalExpectedLength = self.totalReceivedContentLength + httpResponse.expectedContentLength;
        }
    }
    
    if (error) {
        if (self.failure) {
            NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithDictionary:error.userInfo];
            [userInfo oss_setObject:self.checkpoint forKey:@"checkpoint"];
            
            NSError *tError = [NSError errorWithDomain:error.domain code:error.code userInfo:userInfo];
            self.failure(tError);
        }
    } else if (self.success) {
        self.success(@{@"status": @"success"});
    }
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler
{
    NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)dataTask.response;
    if ([httpResponse isKindOfClass:[NSHTTPURLResponse class]]) {
        if (httpResponse.statusCode == 200) {
            self.checkpoint.totalExpectedLength = httpResponse.expectedContentLength;
        } else if (httpResponse.statusCode == 206) {
            self.checkpoint.totalExpectedLength = self.totalReceivedContentLength +  httpResponse.expectedContentLength;
        }
    }
    
    completionHandler(NSURLSessionResponseAllow);
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
    
    NSFileHandle *fileHandle = [NSFileHandle fileHandleForWritingAtPath:self.targetPath];
    [fileHandle seekToEndOfFile];
    [fileHandle writeData:data];
    [fileHandle closeFile];
    
    self.totalReceivedContentLength += data.length;
    if (self.progress) {
        self.progress(data.length, self.totalReceivedContentLength, self.checkpoint.totalExpectedLength);
    }
}

@end
           

上面展示的是從網絡接收資料的處理邏輯,DownloadService是下載下傳邏輯的核心。在URLSession:dataTask:didReceiveData中将接收到的網絡資料按照追加的方式寫入到檔案中,并更新下載下傳進度。在URLSession:task:didCompleteWithError判斷下載下傳任務是否完成,然後将結果傳回給上層業務。URLSession:dataTask:didReceiveResponse:completionHandler代理方法中将object相關的資訊,比如etag用于斷點續傳是的precheck,content-length用于計算下載下傳進度。

#import <Foundation/Foundation.h>

typedef void(^DownloadProgressBlock)(int64_t bytesReceived, int64_t totalBytesReceived, int64_t totalBytesExpectToReceived);
typedef void(^DownloadFailureBlock)(NSError *error);
typedef void(^DownloadSuccessBlock)(NSDictionary *result);

@interface Checkpoint : NSObject<NSCopying>

@property (nonatomic, copy) NSString *etag;     // 資源的etag值
@property (nonatomic, assign) unsigned long long totalExpectedLength;    //檔案總大小

@end

@interface DownloadRequest : NSObject

@property (nonatomic, copy) NSString *sourceURLString;      // 用于下載下傳的url

@property (nonatomic, copy) NSString *headURLString;        // 用于擷取檔案原資訊的url

@property (nonatomic, copy) NSString *downloadFilePath;     // 檔案的本地存儲位址

@property (nonatomic, copy) DownloadProgressBlock downloadProgress; // 下載下傳進度

@property (nonatomic, copy) DownloadFailureBlock failure;   // 下載下傳成功的回調

@property (nonatomic, copy) DownloadSuccessBlock success;   // 下載下傳失敗的回調

@property (nonatomic, copy) Checkpoint *checkpoint;         // checkpoint,用于存儲檔案的etag

@end


@interface DownloadService : NSObject

+ (instancetype)downloadServiceWithRequest:(DownloadRequest *)request;

/**
 * 啟動下載下傳
 */
- (void)resume;

/**
 * 暫停下載下傳
 */
- (void)pause;

/**
 * 取消下載下傳
 */
- (void)cancel;

@end
           

上面這一部分是DownloadRequest的定義。

- (void)initDownloadURLs {
    OSSPlainTextAKSKPairCredentialProvider *pCredential = [[OSSPlainTextAKSKPairCredentialProvider alloc] initWithPlainTextAccessKey:OSS_ACCESSKEY_ID secretKey:OSS_SECRETKEY_ID];
    _mClient = [[OSSClient alloc] initWithEndpoint:OSS_ENDPOINT credentialProvider:pCredential];
    
    // 生成用于get請求的帶簽名的url
    OSSTask *downloadURLTask = [_mClient presignConstrainURLWithBucketName:@"aliyun-dhc-shanghai" withObjectKey:OSS_DOWNLOAD_FILE_NAME withExpirationInterval:1800];
    [downloadURLTask waitUntilFinished];
    _downloadURLString = downloadURLTask.result;
    
    // 生成用于head請求的帶簽名的url
    OSSTask *headURLTask = [_mClient presignConstrainURLWithBucketName:@"aliyun-dhc-shanghai" withObjectKey:OSS_DOWNLOAD_FILE_NAME httpMethod:@"HEAD" withExpirationInterval:1800 withParameters:nil];
    [headURLTask waitUntilFinished];
    
    _headURLString = headURLTask.result;
}

- (IBAction)resumeDownloadClicked:(id)sender {
    _downloadRequest = [DownloadRequest new];
    _downloadRequest.sourceURLString = _downloadURLString;       // 設定資源的url
    _downloadRequest.headURLString = _headURLString;
    NSString *documentPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject;
    _downloadRequest.downloadFilePath = [documentPath stringByAppendingPathComponent:OSS_DOWNLOAD_FILE_NAME];   //設定下載下傳檔案的本地儲存路徑
    
    __weak typeof(self) wSelf = self;
    _downloadRequest.downloadProgress = ^(int64_t bytesReceived, int64_t totalBytesReceived, int64_t totalBytesExpectToReceived) {
        // totalBytesReceived是目前用戶端已經緩存了的位元組數,totalBytesExpectToReceived是總共需要下載下傳的位元組數。
        dispatch_async(dispatch_get_main_queue(), ^{
            __strong typeof(self) sSelf = wSelf;
            CGFloat fProgress = totalBytesReceived * 1.f / totalBytesExpectToReceived;
            sSelf.progressLab.text = [NSString stringWithFormat:@"%.2f%%", fProgress * 100];
            sSelf.progressBar.progress = fProgress;
        });
    };
    _downloadRequest.failure = ^(NSError *error) {
        __strong typeof(self) sSelf = wSelf;
        sSelf.checkpoint = error.userInfo[@"checkpoint"];
    };
    _downloadRequest.success = ^(NSDictionary *result) {
        NSLog(@"下載下傳成功");
    };
    _downloadRequest.checkpoint = self.checkpoint;
    
    NSString *titleText = [[_downloadButton titleLabel] text];
    if ([titleText isEqualToString:@"download"]) {
        [_downloadButton setTitle:@"pause" forState: UIControlStateNormal];
        _downloadService = [DownloadService downloadServiceWithRequest:_downloadRequest];
        [_downloadService resume];
    } else {
        [_downloadButton setTitle:@"download" forState: UIControlStateNormal];
        [_downloadService pause];
    }
}

- (IBAction)cancelDownloadClicked:(id)sender {
    [_downloadButton setTitle:@"download" forState: UIControlStateNormal];
    [_downloadService cancel];
}
           

這一部分是在上層業務的調用。暫停或取消上傳時均能從failure回調中擷取到checkpoint,重新開機下載下傳時可以将checkpoint傳到DownloadRequest,然後DownloadService内部會使用checkpoint做一緻性的校驗。

關于安卓裡面實作對OSS Object的斷點下載下傳可以參考

基于okhttp3實作的斷點下載下傳功能實作

這個開源工程。這裡列出如何使用這個示例工程下載下傳OSS資源。

//1. 首先使用sdk擷取object的下載下傳連結
String signedURLString = ossClient.presignConstrainedObjectURL(bucket, object, expires);

//2. 添加下載下傳任務

        mDownloadManager = DownloadManager.getInstance();
        mDownloadManager.add(signedURLString, new DownloadListner() {
            @Override
            public void onFinished() {
                Toast.makeText(MainActivity.this, "下載下傳完成!", Toast.LENGTH_SHORT).show();
            }

            @Override
            public void onProgress(float progress) {
                pb_progress1.setProgress((int) (progress * 100));
                tv_progress1.setText(String.format("%.2f", progress * 100) + "%");
            }

            @Override
            public void onPause() {
                Toast.makeText(MainActivity.this, "暫停了!", Toast.LENGTH_SHORT).show();
            }

            @Override
            public void onCancel() {
                tv_progress1.setText("0%");
                pb_progress1.setProgress(0);
                btn_download1.setText("下載下傳");
                Toast.makeText(MainActivity.this, "下載下傳已取消!", Toast.LENGTH_SHORT).show();
            }
        });
        
//3.開啟下載下傳
        mDownloadManager.download(signedURLString);
        
//4.暫停下載下傳
        mDownloadManager.cancel(signedURLString);
        
//5.繼續下載下傳
        mDownloadManager.download(signedURLString);
           

HTTP1.1裡的If-Range,我們來看針對該header的描述:

If-Range HTTP 請求頭字段用來使得 Range 頭字段在一定條件下起作用:當字段值中的條件得到滿足時,Range 頭字段才會起作用,同時伺服器回複206 部分内容狀态碼,以及Range 頭字段請求的相應部分;如果字段值中的條件沒有得到滿足,伺服器将會傳回 200 OK 狀态碼,并傳回完整的請求資源。

字段值中既可以用 Last-Modified 時間值用作驗證,也可以用ETag标記作為驗證,但不能将兩者同時使用。

If-Range 頭字段通常用于斷點續傳的下載下傳過程中,用來自從上次中斷後,確定下載下傳的資源沒有發生改變。

使用

If-Range

的優點:用戶端隻需要一次網絡請求,而前面所講的

If-Unmodified-Since

或者

If-Match

在條件判斷失敗時,會傳回412前置條件檢查失敗狀态碼,用戶端不得不再開啟一個請求來擷取資源。

目前OSS Server還不支援

If-Range

字段。而在iOS中使用

NSURLSessionDownloadTask

實作斷點下載下傳時會發送

If-Range

給伺服器以确認檔案是否發生過變化。是以目前還不能使用

NSURLSessionDownloadTask

去實作斷點下載下傳。

https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status/412 https://help.aliyun.com/document_detail/31856.html https://tools.ietf.org/html/rfc2616

繼續閱讀