ブログ

ryuzeeによるブログ記事。不定期更新
アジャイル開発に取り組むチーム向けのコーチングや、技術顧問、認定スクラムマスター研修などの各種トレーニングを提供しています。ぜひお気軽にご相談ください(初回相談無料)

AzureのBlobサービスにブラウザから直接ファイルをアップロードする

こんにちは。@ryuzeeです。

ずっと趣味で作っているスライド共有アプリケーションはAWS専用なのですが、Azureにも対応させようとして色々Azureを触っています。

そこで今回は、AzureのBlobサービス(AWSのS3相当)にブラウザから直接ファイルをアップロードする方法について調査したので共有します。

下準備

Azure上のBlobの画面などで、適当なコンテナを作成してください(S3のバケット相当のものです)。アクセスポリシーはプライベートに設定します(でないとアップロードしたファイルが外界から認証なしでアクセスされてしまいます)。

必要なgemのインストール

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

CORS(Cross-Origin Resource Sharing)を設定する

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(Shared Access Signature)を取得する

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アプリを作ってみます。これからカレントディレクトリにファイルを作っていきましょう。

app.rb (メイン部分)

# -*- 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

views/form.erb (View部分)

<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つになります。 ぜひ試してみてください。

課題

ファイルサイズが64MBを超えると、以下のエラーが出力されます。

413 (The request body is too large and exceeds the maximum permissible limit.)

これについては、こちらのドキュメントに記載があります。

The maximum size for a block blob created via Put Blob is 64 MB. If your blob is larger than 64 MB, you must upload it as a set of blocks. For more information, see the Put Block and Put Block List operations. It's not necessary to also call Put Blob if you upload the blob as a set of blocks.

ということで、単一ブロックではなく、複数ブロックに分割してアップロードが必要なようです。