こんにちは。@ryuzeeです。
ずっと趣味で作っているスライド共有アプリケーションはAWS専用なのですが、Azureにも対応させようとして色々Azureを触っています。
そこで今回は、AzureのBlobサービス(AWSのS3相当)にブラウザから直接ファイルをアップロードする方法について調査したので共有します。
Azure上のBlobの画面などで、適当なコンテナを作成してください(S3のバケット相当のものです)。アクセスポリシーはプライベートに設定します(でないとアップロードしたファイルが外界から認証なしでアクセスされてしまいます)。
AzureのAPIを叩くために必要なgemをインストールします。適当なディレクトリで以下のようにコマンドを実行してください。
bundle init
Gemfileが作成されるので以下の内容にします。
source "https://rubygems.org"
gem 'azure', '~> 0.7.1'
gem 'azure-contrib', git: 'https://github.com/dmichael/azure-contrib.git'
gem 'sinatra'
gem 'sinatra-contrib'
gemのうち上2つが、直接ファイルをアップロードする上で必要なgemです。azureは公式のSDKで、azure-contribは第三者が作っている拡張用のgemになります。バージョン指定しておかないと色々面倒なことになるので明示的にバージョンを指定しています。
下2つは、このあと作成するWebアプリに使うsinatraをインストールするものです。
AzureのBlobストレージをいじくる上で必要な環境変数を設定しましょう。 設定が必要なのは、Azureのストレージにアクセスするためのアクセスキー、ストレージアカウントの名前、そして実際にファイルを保存するコンテナ名です。自分の環境にあわせて変更して設定します。
export AZURE_STORAGE_ACCESS_KEY=xxxxxxxxxxxxx
export AZURE_STORAGE_ACCOUNT_NAME=openslideshare
export CONTAINER_NAME=files
Ajax経由でファイルをアップロードするので、CORSを設定します。 Azureの場合はGUIでは設定できないので、SDKを使ってコードで実施します。ファイル名は適当なもので構いませんが、今回は、set_cors.rbとしておきましょう。
require 'azure'
Azure.config.storage_account_name = ENV['AZURE_STORAGE_ACCOUNT_NAME']
Azure.config.storage_access_key = ENV['AZURE_STORAGE_ACCESS_KEY']
blob_service = Azure::Blob::BlobService.new
props = Azure::Service::StorageServiceProperties.new
props.logging = nil
props.hour_metrics = nil
props.minute_metrics = nil
rule = Azure::Service::CorsRule.new
rule.allowed_headers = ["*"]
rule.allowed_methods = ["PUT", "GET", "HEAD", "POST"]
rule.allowed_origins = ["*"]
rule.exposed_headers = ["*"]
rule.max_age_in_seconds = 1800
props.cors.cors_rules = [rule]
blob_service.set_service_properties(props)
puts blob_service.get_service_properties.inspect
ここまで出来たら
bundle exec ruby set_cors.rb
としてください。
なお、CORSはストレージアカウントに対しての設定になっているようです(Container単位にできないのかは要調査)
SASは有効期限付きのワンタイムURLみたいなものです。発行するには以下の内容で適当なrubyスクリプトを用意して実行します。ここでは、アップロードファイル名は、hogehogeに固定されています。
require 'azure'
require 'azure-contrib'
Azure.config.storage_account_name = ENV['AZURE_STORAGE_ACCOUNT_NAME']
Azure.config.storage_access_key = ENV['AZURE_ACCESS_KEY']
def generate_url(container_name, blob_name, permissions)
start_time = Time.now - 10
expiration_time = Time.now + 1800
bs = Azure::Blob::BlobService.new
uri = bs.generate_uri Addressable::URI.escape("#{container_name}/#{blob_name}")
signer = Azure::Contrib::Auth::SharedAccessSignature.new(uri, {
resource: "b",
permissions: permissions,
start: start_time.utc.iso8601,
expiry: expiration_time.utc.iso8601
}, Azure.config.storage_account_name)
url = signer.sign
url
end
puts generate_url('files', 'hogehoge', 'w')
これを実行すると以下のようなURLが出来上がります。
http://openslideshare.blob.core.windows.net/files/hogegoge?se=2016-02-06T03%3A28%3A42Z&sig=%2FFr80o0c7W1%2BITXqg7DCcfnx2sL4zhq1op6nJOY%2Flsc%3D&sp=rw&sr=b&st=2016-02-06T02%3A58%3A32Z
このURLに対してファイルをPUTすればOKです。以下のようにして試してみましょう。
curl -X PUT "http://openslideshare.blob.core.windows.net/files/hogehoge?se=2016-02-06T04%3A43%3A09Z&sig=HewO2DhvwKASDg6LCF0GNhvATQA0Lfl3J0nPsKNCJ9k%3D&sp=w&sr=b&st=2016-02-06T04%3A12%3A59Z" -F "file=~/Desktop/hogehoge;type=application-octetstream"
これでAzureのBlobの指定したコンテナ内にファイルができていればOKです!
注意点としてはSASのURLを取得する時点でBlobに保存する際のファイル名が必要になる点で、ユーザーがアップロードしたファイルの名前を元にしたい場合は、JavaScriptなどとの組み合わせで先にファイル名取得→SASのURLを発行という流れを経てからファイルのアップロードに進まないといけないことになります。
ここまで来たら後はWebアプリでアップロードできるようにしましょう。前述の通り、Sinatraを使ってWebアプリを作ってみます。これからカレントディレクトリにファイルを作っていきましょう。
# -*- coding: utf-8 -*-
require 'sinatra'
require 'sinatra/reloader'
require 'azure'
require 'azure-contrib'
require 'uri'
require 'json'
Azure.config.storage_account_name = ENV['AZURE_STORAGE_ACCOUNT_NAME']
Azure.config.storage_access_key = ENV['AZURE_STORAGE_ACCESS_KEY']
get '/' do
erb :form
end
post '/sas' do
filename = params[:filename]
url = generate_url(ENV['CONTAINER_NAME'], filename, 'w')
puts url
content_type :json
data = { url: url }
JSON.dump(data)
end
def generate_url(container_name, blob_name, permissions)
start_time = Time.now - 10
expiration_time = Time.now + 1800
bs = Azure::Blob::BlobService.new
uri = bs.generate_uri Addressable::URI.escape("#{container_name}/#{blob_name}")
signer = Azure::Contrib::Auth::SharedAccessSignature.new(uri, {
resource: "b",
permissions: permissions,
start: start_time.utc.iso8601,
expiry: expiration_time.utc.iso8601
}, Azure.config.storage_account_name)
url = signer.sign
url
end
<html>
<head>
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
</head>
<body>
<div>
<form id="upload-form" method="post" enctype="multipart/form-data">
<div>
<label class="control-label col-sm-2"><span>File</span></label>
<div>
<input type="file" name="file" id="file" />
</div>
</div>
</div>
<span id="progress">0%</span>
<script type="text/javascript">
$(document).ready(function() {
$('#file').on("change", function(event) {
var file = this.files[0];
if(file != null) {
console.log(file.name);
} else {
return;
}
event.preventDefault();
url = '';
var formDataSAS = new FormData();
formDataSAS.append('filename', file.name);
$.ajax({
type: 'POST',
url: '/sas',
dataType: 'json',
data: formDataSAS,
async: false,
cache: false,
contentType: false,
processData: false
}).done(function( data, textStatus, jqXHR ) {
console.debug(data.url);
url = data.url;
}).fail(function( jqXHR, textStatus, errorThrown ) {
console.log('Fail...');
});
if (url == '') {
alert('Could not get SAS url...')
return false;
}
var formData = new FormData();
var form = $('#upload-form');
$(form.serializeArray()).each(function(i, v) {
if(v.name != "file") {
formData.append(v.name, v.value);
}
});
formData.append("file", $("#file").prop("files")[0]);
$.ajax({
url: url,
type: 'PUT',
contentType: 'application/octet-stream',
data: formData,
async: true,
crossDomain: true,
xhr: function() {
xhr = $.ajaxSettings.xhr();
xhr.upload.addEventListener("progress", function(evt) {
if (evt.lengthComputable) {
var percentComplete = evt.loaded / evt.total;
var p = Math.round(percentComplete * 100);
$("#progress").html(p + "%");
}
}, false);
return xhr;
},
statusCode: {
201: function(){
console.log("201:OK");
},
403: function(){
console.log("403:Forbidden");
},
404: function(){
console.log("404:NOT Found");
},
405: function(){
console.log("405:Authentication Error");
}
},
cache: false,
contentType: false,
processData: false
}).done(function( data, textStatus, jqXHR ) {
alert('Success');
}) .fail(function( jqXHR, textStatus, errorThrown ) {
alert('Fail...');
});
return false;
});
});
</script>
</body>
</html>
ここまで出来たらSinatraのアプリケーションを起動しましょう。以下のようにします。
bundle exec ruby app.rb
以下のような感じでログが出力されます。4567番ポートでListenしているのが分かります。
I, [2016-02-06T20:13:12.143616 #19922] INFO -- : Celluloid 0.17.3 is running in BACKPORTED mode. [ http://git.io/vJf3J ]
[2016-02-06 20:13:12] INFO WEBrick 1.3.1
[2016-02-06 20:13:12] INFO ruby 2.2.3 (2015-08-18) [x86_64-darwin14]
== Sinatra (v1.4.7) has taken the stage on 4567 for development with backup from WEBrick
[2016-02-06 20:13:12] INFO WEBrick::HTTPServer#start: pid=19922 port=4567
http://localhost:4567 にアクセスすると以下のような(無味乾燥な)画面が表示されますので適当なファイルを指定してみてください。
アップロードが終わったらAzure側の画面を確認して、BLOBが生成されているかを確認しましょう。
AWSでもAzureでも、大きなファイルをごにょごにょする際に、一端仮想マシン側でPOSTで受け付けるようなことをしてしまうと、仮想マシンが止まった場合に問題が起こりやすく、仮想マシンの負荷が増えたりまた転送料金が高くなったりします。したがってこのような仕掛けを使ってファイルを直接ストレージに配置することはベスト・プラクティスの1つになります。 ぜひ試してみてください。
ファイルサイズがすごい大きいと、以下のエラーを吐く。このあたりUndocumentedな感じなのでもうちょっと調査しないといけなさそう。。
413 (The request body is too large and exceeds the maximum permissible limit.)