七牛文件列表的 Swift/SwiftUI 实现

1,109 阅读3分钟

这是我参与更文挑战的第2天,活动详情查看: 更文挑战

本文实现的项目地址

将用户的一些静态资源文件、日志文件放到七牛上,应该是很多公司的通用做法。七牛也提供了图形化工具 Kodo Browser,方便用户浏览存储在七牛服务器中的文件。

当有定制化需求的时候,也可以通过 kodo-browserGitHub 源码 进行定制。我们为了可以方便的查看加密后的日志文件,就需要对工具进行定制(可以查看美团 Logan 的 Swift / Object-C 解密程序)。但是由于本人没有 Notes 等相关的知识,所以只能另辟蹊径,使用 Swift + SwiftUI 来实现类似于 Mac 上 Finder 的功能。

想要获取七牛上的文件,首先就要搞明白的是七牛的网络协议是怎样的。好消息是七牛提供了 API 文档,坏消息是我看了半天结果什么也看不懂。所以我只能看 Java SDK 来分析网络协议。

七牛提供了很多种语言的 SDK,直接使用 API 的场景很少,所以感觉七牛在 API 文档的维护方面做得并不好,当然也不排除出本人能力有限。

下面是 Swift 实现的三个网络协议:

  1. 获取 Bucket 列表
  2. 获取文件列表
  3. 获取文件信息
import Foundation
import Moya

let RsQboxBaseURL = URL(string: "https://rs.qbox.me")!
let RsQiniuBaseURL = URL(string: "https://rs.qiniu.com")!
let RsfQiniuBaseURL = URL(string: "https://rsf.qbox.me")!

public enum QiniuRequest {
    case buckets
    case statInfo(bucket: String, fileKey: String?)
    case list(bucket: String, prefix: String)
}

extension QiniuRequest: TargetType {

    public var baseURL: URL {
        switch self {
        case .buckets:
            return RsQiniuBaseURL
        case .statInfo:
            return RsQboxBaseURL
        case .list:
            return RsfQiniuBaseURL
        }
    }

    public var path: String {
        switch self {
        case .list:
            return "/list"
        case .buckets:
            return "/buckets"
        case let .statInfo(bucket, fileKey):
            let key = UrlSafeBase64.encodedEntry(bucket: bucket, fileKey: fileKey) ?? ""
            return "/stat/\(key)"
        }
    }
    
    
    public var task: Task {
        switch self {
        case .buckets:
            return .requestPlain
        case .statInfo:
            return .requestPlain
        case .list:
            return .requestParameters(parameters: getPram() ?? [String : String](), encoding: URLEncoding.default)
        }
    }
    
    func getPram() -> [String : String]? {
        switch self {
        case let .list(bucket, prefix):
            var pram = [String: String]()
            pram["bucket"] = bucket
            pram["delimiter"] = "/"
            if prefix.count > 0 {
                pram["prefix"] = prefix
            }
            
            return pram
        default:
            return nil
        }
    }
    
    public var headers: [String : String]? {
        var header = [String : String]()
        var absoluteString = self.absoluteURL.absoluteString
        if let parameters = getPram(), parameters.count > 0 {
            absoluteString += "?"
            
            var components: [(String, String)] = []

            for key in parameters.keys.sorted(by: <) {
                let value = parameters[key]!
                components += queryComponents(fromKey: key, value: value)
            }
            absoluteString += components.map { "\($0)=\($1)" }.joined(separator: "&")
        }
        let url = URL(string: absoluteString) ?? absoluteURL
        debugPrint("url:\(url)")
        header["Authorization"] = QiniuTool.getToken(url: url)
        return header
    }
    

    public var method: Moya.Method {
        switch self {
        case .statInfo,
             .list,
             .buckets:
            return .get
        }
    }
}

extension QiniuRequest {
    /// Creates a percent-escaped, URL encoded query string components from the given key-value pair recursively.
    ///
    /// - Parameters:
    ///   - key:   Key of the query component.
    ///   - value: Value of the query component.
    ///
    /// - Returns: The percent-escaped, URL encoded query string components.
    public func queryComponents(fromKey key: String, value: Any) -> [(String, String)] {
        var components: [(String, String)] = []
        switch value {
        case let dictionary as [String: Any]:
            for (nestedKey, value) in dictionary {
                components += queryComponents(fromKey: "\(key)[\(nestedKey)]", value: value)
            }
        default:
            components.append((escape(key), escape("\(value)")))
        }
        return components
    }

    /// Creates a percent-escaped string following RFC 3986 for a query string key or value.
    ///
    /// - Parameter string: `String` to be percent-escaped.
    ///
    /// - Returns:          The percent-escaped `String`.
    public func escape(_ string: String) -> String {
        string.addingPercentEncoding(withAllowedCharacters: .afURLQueryAllowed) ?? string
    }
}

可以看出这三个网络请求,居然用了三个不同的 host,应该是为了缓解服务器的压力。

七牛的网络协议

七牛的网络协议是在 header 中加入 Authorization 的鉴权信息。Authorization 的生成过程为:

  1. 获取完整的请求 url 路径;
  2. 将所有的请求参数进行排序,拼接在 url 路径后面,再拼接一个换行符 "\n",然后进行加密;
  3. 最后拼接成 "QBox QiniuAccessKey:加密结果" 格式的字符串。

下面是对字符串进行加密过程:

class QiniuTool { 
    static func getToken(url: URL) -> String? {
        var text = ""
        text += url.path
        if let query = url.query {
            text += "?\(query)"
        }
        text += "\n"

        var digest = text.hmac(algorithm: .SHA1, key: QiniuConfig.QiniuSecretKey)
        digest = digest.replacingOccurrences(of: "/", with: "_")
        digest = digest.replacingOccurrences(of: "+", with: "-")
        return "QBox \(QiniuConfig.QiniuAccessKey):\(digest)"
    }
}

七牛的临时访问链接

为了方便下载文件,可以生成一条临时访问链接。

生成七牛的临时访问链接并不需要请求服务,而是在访问链接后面加上加密后的 token,当访问服务器的时候,服务器再对 token 进行验证。

class QiniuTool {
    static func getPublicUrl(bucket: String = QiniuConfig.BucketDomain, key: String) -> String? {
        let e = Int(Date().timeIntervalSince1970 + 3600)
        let url = "https://\(bucket)/\(key)?e=\(e)"
        
        var r = url.hmac(algorithm: .SHA1, key: QiniuConfig.QiniuSecretKey)
        r = r.replacingOccurrences(of: "/", with: "_")
        r = r.replacingOccurrences(of: "+", with: "-")
    
        return "\(url)&token=\(QiniuConfig.QiniuAccessKey):\(r)"
    }
}

以前我以为这种链接需要在服务器生成才安全,其实完成可以在客户端生成。

再次提醒

可以查看本文完整项目地址

当有定制化需求的时候,可以通过 kodo-browserGitHub 源码 进行定制,或者进行使用七牛提供好的 SDK 会省去很多麻烦事。