えいのうにっき

a-knowの日記です

「The upload URL has expired」でblobstoreへのアップロードが失敗することへの対策

Google App Engine/Java において、例えば画像ファイルをblobstoreへアップロードする方法はいくつかありますが、その代表的なものは、公式ドキュメント(こちら)にも記述されている以下の方法ではないかと思います。

// file Upload.java

import java.io.IOException;
import java.util.Map;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import com.google.appengine.api.blobstore.BlobKey;
import com.google.appengine.api.blobstore.BlobstoreService;
import com.google.appengine.api.blobstore.BlobstoreServiceFactory;

public class Upload extends HttpServlet {
    private BlobstoreService blobstoreService = BlobstoreServiceFactory.getBlobstoreService();

    public void doPost(HttpServletRequest req, HttpServletResponse res)
        throws ServletException, IOException {

        Map<String, BlobKey> blobs = blobstoreService.getUploadedBlobs(req);
        BlobKey blobKey = blobs.get("myFile");

        if (blobKey == null) {
            res.sendRedirect("/");
        } else {
            res.sendRedirect("/serve?blob-key=" + blobKey.getKeyString());
        }
    }
}

// file Serve.java
// 今回はアップロードするまでに起きる問題についてのお話なので、省略。
// file index.jsp

<%@ page import="com.google.appengine.api.blobstore.BlobstoreServiceFactory" %>
<%@ page import="com.google.appengine.api.blobstore.BlobstoreService" %>

<%
    BlobstoreService blobstoreService = BlobstoreServiceFactory.getBlobstoreService();
%>


<html>
    <head>
        <title>Upload Test</title>
    </head>
    <body>
        <form action="<%= blobstoreService.createUploadUrl("/upload") %>" method="post" enctype="multipart/form-data">
            <input type="text" name="foo">
            <input type="file" name="myFile">
            <input type="submit" value="Submit">
        </form>
    </body>
</html>
// web.xml

<?xml version="1.0" encoding="utf-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
   xmlns="http://java.sun.com/xml/ns/javaee"
   xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"
   xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
   http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" version="2.5">

  <servlet>
    <servlet-name>Upload</servlet-name>
    <servlet-class>Upload</servlet-class>
  </servlet>
  
  <servlet>
    <servlet-name>Serve</servlet-name>
    <servlet-class>Serve</servlet-class>
  </servlet>
 
  <servlet-mapping>
    <servlet-name>Upload</servlet-name>
    <url-pattern>/upload</url-pattern>
  </servlet-mapping>

  <servlet-mapping>
    <servlet-name>Serve</servlet-name>
    <url-pattern>/serve</url-pattern>
  </servlet-mapping>
  
</web-app>

ここでは、アップロード後すぐにリダイレクトするという謎のサンプルになってますが、今回はアップロードまでのお話。

ここで問題なのが、

index.jspの「action="<%= blobstoreService.createUploadUrl("/upload") %>"」の部分。index.jspが開かれたときには既にこの部分というのは解釈され、「action="http://localhost:8888/_ah/upload/xxxxxxxxxx..."」といったURLに置き換わっています。 別にこのこと自体は正常なことなんですが、どうやらこうして生成されたURLには有効期間があるようなのです。それも10分か15分くらいという、さほど長くない時間。(厳密には調べていません、すみません。)この有効期間を過ぎて失効してしまったURLをそのまま用いてアップロードを行おうとする(formをsubmitする)と、「Bad Request - The upload URL has expired」が出て、アップロードに失敗してしまいます。

例えば、先日リリースさせて頂いた「Masterpiece」では、「自分の愛用品の写真を選択し、それに関しての説明を入力した後にアップロードする」という仕様になっています。これだと、説明文の入力に手間取ってしまうと、「登録画面を開いてから10分ないし15分経過後に登録ボタンを押下」、なんてことはざらにあるわけです(恥ずかしながら、リリースした後にこの事象に気が付きました・・・ご指摘頂いた方、ありがとうございます)。

対処方法。

少し調べてみたところ、こちらのStack Overflowでのやりとりが、参考になりそうでした。jQueryのgetを使う方法ですね。

まず、jspを以下のように変更します。すぐにsubmitさせるのではなく、javascriptを呼び出すようにします(formにnameを付け、actionのところは空白にしておきます)。

// file index.jsp

<script type="text/javascript" src="/js/upload.js"></script>
    
<html>
    <head>
        <title>Upload Test</title>
    </head>
    <body>
        <form action="" method="post" enctype="multipart/form-data" name="uploadForm">
            <input type="text" name="foo">
            <input type="file" name="myFile">
            <input type="button" value="Submit" onclick="submitButtonClick();">
        </form>
    </body>
</html>

次が、ボタン押下時に呼び出されるjavascriptです。

//upload.js

function submitButtonClick(){
    $.get("/getBlobUrl", function(data){
        document.uploadForm.action = data.url;
        document.uploadForm.submit();
    }, 'json');
}

これにより、ボタンが押下されるとまず「/getBlobUrl」がrequestされ、その結果(data)がcallback関数に渡される、となります(別に受け取るデータ形式jsonである必要はないと思います)。callback関数内では、受け取ったデータからURLを取得し、formのactionの値を書き換えた後に、formをsubmitしてます。つまり、「/getBlobUrl」で「<%= blobstoreService.createUploadUrl("/upload") %>」に相当することをしてやればいいわけです。

ここまで書けば大丈夫だと思いますが、続いては「/getBlobUrl」の部分。

// file GetBlobUrl.java

(省略)

import com.google.appengine.api.blobstore.BlobstoreService;
import com.google.appengine.api.blobstore.BlobstoreServiceFactory;

public class GetBlobUrl extends HttpServlet {

    public void doGet(HttpServletRequest req, HttpServletResponse res)
        throws ServletException, IOException {
        
            res.setContentType("application/json; charset=UTF-8");

            BlobstoreService blobstoreService = BlobstoreServiceFactory.getBlobstoreService();
            String url = blobstoreService.createUploadUrl("/upload");
            
            Map<String, String> map = new HashMap<String, String>();
            map.put("url", url);

            JSON.encode(map, this.response.getOutputStream());
        }
    }
}

web.xmlとかは省略で。 こうすることで、ボタンが押下されてからアップロード用のURLが生成されるわけで、URLの失効を考える必要もなくなります。たぶん。

ぼくはSlim3で作っているので、全てが上記の通りではありませんが、「GetBlobUrlController」を作り、その中で同等のことをすることで、今のところは問題なし男ちゃんです。 不備・不足等ありましたら、ご指摘頂けると幸いです。