#はじめに
iOS アプリで GCS(Google Cloud Storage)にファイルを直接アップロードするための実装手順を説明します。
この仕組みには、次のようなメリットがあります。
- ウェブサーバ(Google Compute Engine 等を使って)を使わずに、(ある程度)セキュリティを意識したコンテンツサーバを立てることができる
- GCS のフルマネージドなサービスのメリット(拡張性、可用性)を享受することができる
GCS は AWS でいうところの S3(Amazon Simple Storage Service)の位置づけです。業界的にデファクトとなっている AWS S3 からの移行がしやすいように、Google は GCS に Interoperability API というものを提供しており、AWS S3 と同じ HTTPS リクエストで GCS のファイル操作が可能になっています。
この API を使って、Swift で書かれた iOS アプリから直接 GCS にファイルをアップロードする機能を実装します。
#実装方法
次のサイトを参考にしました。
Google Cloud Storage に S3 互換の REST でアップロード
PHP で同じことを実装しているので、そのまま Swift でなぞりました。
Swift 向けの AWS S3 用ライブラリは当然すでに存在している(GCS 用のライブラリもあるかもしれない)ため、それを流用すればすんなりできたのかもしれませんが、PHP のコードにならって素の HTTP リクエストで実装することにしました。
アクセスキー・シークレットキーを使っていくつかの HTTP ヘッダを構成し、リクエストボディにはアップロードしたいデータをそのまま埋め込みます。
Authorization HTTP ヘッダを作るという工程で、いくつかの項目を MD5 ハッシュ・BASE64 エンコード等しているところが特殊な仕掛けになっています。
#GCS 側の設定
GCS のバケット設定で、Interoperability API を使用できるようにします。
相互運用ストレージ アクセスキーが生成できるので、アクセスキーとシークレットの文字列を控えます。
これらの作業は GCS のコンソール画面で行います。
次の記事が参考になります。
Google Cloud Storage に AWS CLI や AWS SDK for PHP でアップロード
#Swift 側の準備
通常は API コールを行うときの肝となる MD5 ハッシュ、HMAC ハッシュができる関数が用意されていないので、Objective-C の関数をブリッジして呼べるように設定します。
ここが PHP と比較して厄介なところです。標準 API として実装しておいてほしかった・・・
まず、Bridging-Header.h をプロジェクトのトップレベルのフォルダ直下に作成します。
#ifndef Bridging_Header_h
#define Bridging_Header_h
#import "CommonCrypto/CommonHMAC.h"
#endif /* Bridging_Header_h */
次に、プロジェクト設定で、このヘッダファイルをブリッジ用のファイルとして認識させます。
XCode で設定しているビルドのスキームそれぞれに Build Settings を追加する必要があるので、注意が必要です。
#MD5 関数の実装
Objective-C の関数が呼べるようになったので、MD5 ハッシュを実行する関数を Swift 側で実装します。
次のサイトを参考にしました。
Swift 4で HMAC-SHA256
上のサイトで見つけたコードでは、変換結果を 16進数の文字列表現に変換しているため、PHP コードでいうところの md5($body, true)
ではなく md5($body, false)
の結果となってしまいます。
そこで、バイナリデータである Data クラスのオブジェクトを返すように手直ししています。
String クラス、Data クラスの extension として実装しています。
import Foundation
enum CryptoAlgorithm {
case MD5, SHA1, SHA224, SHA256, SHA384, SHA512
var HMACAlgorithm: CCHmacAlgorithm {
var result: Int = 0
switch self {
case .MD5: result = kCCHmacAlgMD5
case .SHA1: result = kCCHmacAlgSHA1
case .SHA224: result = kCCHmacAlgSHA224
case .SHA256: result = kCCHmacAlgSHA256
case .SHA384: result = kCCHmacAlgSHA384
case .SHA512: result = kCCHmacAlgSHA512
}
return CCHmacAlgorithm(result)
}
var digestLength: Int {
var result: Int32 = 0
switch self {
case .MD5: result = CC_MD5_DIGEST_LENGTH
case .SHA1: result = CC_SHA1_DIGEST_LENGTH
case .SHA224: result = CC_SHA224_DIGEST_LENGTH
case .SHA256: result = CC_SHA256_DIGEST_LENGTH
case .SHA384: result = CC_SHA384_DIGEST_LENGTH
case .SHA512: result = CC_SHA512_DIGEST_LENGTH
}
return Int(result)
}
}
extension String {
// String 型の変数を hmac ハッシュして Data 型で返す
func hmacData(algorithm: CryptoAlgorithm, key: String) -> Data {
var result: [CUnsignedChar]
if let ckey = key.cString(using: String.Encoding.utf8), let cdata = self.cString(using: String.Encoding.utf8) {
result = Array(repeating: 0, count: Int(algorithm.digestLength))
CCHmac(algorithm.HMACAlgorithm, ckey, ckey.count - 1, cdata, cdata.count - 1, &result)
} else {
fatalError("Nil returned when processing input strings as UTF8")
}
return Data(bytes: result, count: result.count)
}
}
extension Data {
// Data 型の変数を md5 ハッシュして Data 型で返す
public func md5Data() -> Data {
var digest = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH))
_ = self.withUnsafeBytes { bytes in
CC_MD5(bytes, CC_LONG(self.count), &digest)
}
return Data(bytes: digest)
}
}
ここの手直し方法を見出すまでに、だいぶ手間取りました。
#REST API コールの実装
さて、ようやく API コールをするところの実装です。
初めの参考サイトの PHP 実装にならってコーディングしていきます。
日付の文字列を作るときに、ロケールによって DateFormatter の生成する文字列が変わってしまうので、ロケールを en_US_POSIX
に固定することで対処しています。
あとは、決まった手順で HTTP ヘッダを作成し、リクエストボディにアップロードしたい画像データを渡せば完了です。
let verb = "PUT"
let contentType = "image/jpeg"
let photoName = name + ".jpg"
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
let dt = dateFormatter.string(from: Date())
let md5:Data = imageData.md5Data()
let md5base64 = md5.base64EncodedString()
let resource = String(format: "/%@/%@", PhotoSharingServerBucket, photoName)
let stringToSign = String(format: "%@\n%@\n%@\n%@\n%@", verb, md5base64, contentType, dt, resource)
let hmac = stringToSign.hmacData(algorithm: .SHA1, key: GCSSecretKey)
let hmacBase64 = hmac.base64EncodedString()
let authorization = String(format: "AWS %@:%@", GCSAccessKey, hmacBase64)
var request = URLRequest(url: PhotoSharingUploadURL(photoName),
cachePolicy: .reloadIgnoringLocalCacheData,
timeoutInterval: 60)
request.httpMethod = verb
request.addValue(dt, forHTTPHeaderField: "Date")
request.addValue(contentType, forHTTPHeaderField: "Content-Type")
request.addValue(md5base64, forHTTPHeaderField: "Content-MD5")
request.addValue(authorization, forHTTPHeaderField: "Authorization")
// ボディデータの作成
request.httpBody = imageData
// レスポンスアクションの設定
let dataTask = URLSession.shared.dataTask(with: request) {[weak self]
data, response, error in
if let error = error {
// エラー発生時
}
let status = response.statusCode()
if status != 200 {
// HTTPステータスエラー
}
}
// 接続開始
dataTask.resume()
#まとめ
以前はこのアプリのために専用のサーバインスタンスを準備し、その API にならってリクエストを組んでいました。今回の実装によって GCS に直接アクセスすることができるようになったため、無駄なコスト削減と運用負荷の低減を実現しています。
かなりニッチなノウハウと思いますが、何かのお役に立てれば幸いです。