GAE/Jのデータストアにファイルをアップロードする

以前のログ「GAE/JにStruts1.3のサンプルを乗せてみた」でファイルのUploadがうまくいかない、と書いた。
これは、GAE/Jのランタイム環境でFileOutputStreamがサポートされていないのが原因。GAE/Jがファイルシステムとして利用できないことはよく知られたことなので、ファイルシステムにuploadするのではなく、uploadしたファイルをBlob形式でデータ・ストアに保管すればよい。

実際に少し触ってみたところ、Strutsが抱え込んでいるcommons-fileuploadが、GAE/Jで利用できないということに帰着されるようだ。commons-uploadだけを使ったサンプルを書いてみたが、これもうまくいきそうにない。というのも、commons-fileuploadは、uploadしたファイルが「ある閾値を超えると一時的にファイルシステムに書き出す」という仕様になっている。commons-fileuploadは、とても便利なライブラリ。これを使わないとすれば、昔ながらにmultipart/formをパースしなくてはならない。ともあれ、「昔に勉強した本を引っ張り出して」やってみた。

まずは、multipart/formデータの形式から整理する。

<input type="file">

によってformをPOSTする仕様は、RFC1867で決まっている。
たとえば、以下のようなformをsubmitした場合、その下に示す形式のデータがPOSTされることが分かる(FireFoxなら、FireBugで簡単に確認できる)。


POSTされるデータ

Content-Type: multipart/form-data; boundary=---------------------------293372327123869
Content-Length: 2258

-----------------------------293372327123869
Content-Disposition: form-data; name="text"

aaaaa
-----------------------------293372327123869
Content-Disposition: form-data; name="file"; filename="sample.csv"
Content-Type: application/octet-stream

id,Q1,Q2,Q3,Q4,Q5.
1,1,1,・

...

-----------------------------293372327123869--


画面の見出しには「Picture」とあるが、ここでは、以下のようなcsvをuploadしている。

id,Q1,Q2,Q3,Q4,Q5.
1,1,1,・

...


これを、もう少し一般的な格好に書き直すと、以下のようになる。

<<書式>>
Content-Type: multipart/form-data; boundary=---------------------------xxxxx[CRLF]  (1)
Content-Length: xxxx[CRLF]
[CRLF]
-----------------------------xxxxx[CRLF]     (2)
Content-Disposition: form-data; name="text" [CRLF]
[CRLF]
aaaaa[CRLF]
-----------------------------xxxxx[CRLF]     (2)
Content-Disposition: form-data; name="file"; filename="xxxxxxxxx"
Content-Type: xxxxxxxxxxxxxxxxxxxxxxx
[CRLF]
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
[CRLF]
-----------------------------xxxxx--[CRLF]   (3)


(1)-(3)でboundaryが定義されて、(2)の区切りによってmultipartが表現される。最初の区切りがtextが送信されるpartで、次がfileが送信されるpartとなる。
ここで注意するのは、改行コードが[CRLF]であることで、これはRFC1867にも書かれているのだが、はまってしまった。GAE/JのサーバーはUnix系OSなので、改行はLF(Line Feed)。LFでよいと信じ込んで、formデータをparseしてもうまくいかなかった(ちゃんと読まないといけませんね)。

サンプルの概要

初期画面は、以下。テキストとファイルを送るフィールドを定義するが、テキストフィールドは、multipart/formがPOSTされる様子を調べるため(上記)だけに用いて、データストアには永続化しない。

ファイルをポストすると以下の画面に遷移して、ファイルがuploadされたことを確認する。

また、GAEの管理画面のData Viewerからデータが「どうみえるのか」を確認する。


プロジェクトの作成

Strutsのupload機能は使わないが、strutsタグを使ってJSPを書く
以前のログ「Struts1.3をGAE/Jに乗せてデータ・ストアを使ってみた」で作ったプロジェクトをコピーして、新規のプロジェクトを作成する(GaeStruts13Upload)。

エンティティーの作成

以下の2つのエンティティーを作成する。

RootTO.java エンティティーグループを定義するrootエンティティーStruts1.3をGAE/Jに乗せてデータ・ストアを使ってみたで作成したものと同じ。
Picture.java uploadしたファイルをストアするためのエンティティー

Picture.java

package test.entities;

import javax.jdo.annotations.IdGeneratorStrategy;
import javax.jdo.annotations.IdentityType;
import javax.jdo.annotations.PersistenceCapable;
import javax.jdo.annotations.Persistent;
import javax.jdo.annotations.PrimaryKey;

import com.google.appengine.api.datastore.Blob;
import com.google.appengine.api.datastore.Key;

/**
 * Pictureを表すオブジェクト。
 * 
 */
@PersistenceCapable(identityType=IdentityType.APPLICATION)
public class Picture{

    @PrimaryKey
    @Persistent(valueStrategy = IdGeneratorStrategy.IDENTITY)
    private Key key;

    @Persistent
    private Long number;

    @Persistent
    private String name;

    @Persistent
    private Blob picture;

    @Persistent
    private int size;

    @Persistent
    private String contenttype;
    
    
    /**
     * オブジェクトを構築します。
     * 
     */
    public Picture() {
    }

    /**
     * Setter/Getter
     */

    public Key getKey() {
        return key;
    }

    public void setKey(Key key) {
        this.key = key;
    }
    
    public Long getNumber() {
        return number;
    }
    
    public void setNumber(Long number) {
        this.number = number;
    }

    public int getIntNumber() {
        return number.intValue();
    }
   
    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Blob getPicture() {
        return picture;
    }

    public void setPicture(Blob picture) {
        this.picture = picture;
    }

    public int getSize() {
        return size;
    }

    public void setSize(int size) {
        this.size = size;
    }

    public String getContenttype() {
        return contenttype;
    }

    public void setContenttype(String contenttype) {
        this.contenttype = contenttype;
    }

}

ContextListnerの作成

サンプルでストアするデータの初期化は、(これまでのサンプル同様に)ContextListnerで行う。

CreateRootContextListner.java

package test;

import java.util.List;

import javax.jdo.PersistenceManager;
import javax.jdo.Query;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;

import test.entities.Picture;
import test.entities.RootTO;

import com.google.appengine.api.datastore.Key;
import com.google.appengine.api.datastore.KeyFactory;

public class CreateRootContextListner implements ServletContextListener {

    @Override
    public void contextDestroyed(ServletContextEvent arg0) {
    }

    /**
     * DataStoreの初期設定を行います。
     * 
     */
    @SuppressWarnings("unchecked")
    @Override
    public void contextInitialized(ServletContextEvent arg0) {
        /*
         * Rootエンティティーの初期設定
         *  カウンターを0にリセットします。
         */
        RootTO to = new RootTO("root",new Long(0));
        Key key =KeyFactory.createKey(RootTO.class.getSimpleName(), "key");
        to.setKey(key);
        
        PersistenceManager pm = PMF.get().getPersistenceManager();
        // rootエンティティーを登録
        try{
            pm.makePersistent(to);
        }finally{
            pm.close();
        }
        
        /*
         * Pictureエンティティーの初期設定
         *  Pictureエンティティーを全て削除します。
         */
        pm = PMF.get().getPersistenceManager();
        Query query = pm.newQuery(Picture.class);
        try{
            List<Picture> list = (List<Picture>)query.execute();
            pm.deletePersistentAll(list);
        }finally{
            pm.close();
        }
    }
}

PictureのためのDAO(Data Access Object)の作成

Pictureエンティティーに関して、GAEのデータストアを操作するためのDAOを作成する。

PictureDao.java

package test;

import java.util.ArrayList;
import java.util.List;

import javax.jdo.PersistenceManager;
import javax.jdo.Query;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import com.google.appengine.api.datastore.Key;
import com.google.appengine.api.datastore.KeyFactory;

import test.entities.Picture;
import test.entities.RootTO;

/**
 * PictureのData Access Object
 * 
 */
public class PictureDao{
    private static final Log log = LogFactory.getLog(PictureDao.class);
    
    /**
     * コンストラクタ
     */
    public PictureDao() {
    }
    
    @SuppressWarnings("unchecked")
    private List<Picture> loadPeople(){

        List<Picture> pics=
            new ArrayList<Picture>();

        PersistenceManager pm 
                = PMF.get().getPersistenceManager();
        /**
         * Pictureエンティティーに対するクエリの実行
         */
        Query query = pm.newQuery(Picture.class);
        query.setOrdering("number asc");

        try{
            List<Picture> list = (List<Picture>)query.execute();
            for(Picture picture : list){
                pics.add(picture);
                log.debug("picture loaded: "+picture.toString());
            }
        }finally{
            query.closeAll();
            pm.close();
        }
        return pics;
    }

    /*
     *  Pictureのリストを返却する。 
     */
    public List<Picture> getAllPeople() {
        return this.loadPeople();
    }

    /*
     *  Pictureを登録、更新する。 
     */
    public List<Picture> setPicture(Picture picture) {
        
        log.debug("setPicture picture id : " + picture.getNumber());
        
        if(picture.getNumber().intValue() != -1){
            this.updatePicture(picture);
        }else{
            this.registerPicture(picture);
        }
        
        return this.loadPeople();
    }

    /*
     *  Pictureを登録する。 
     */
    private void registerPicture(Picture picture) {
        
        PersistenceManager pm 
            = PMF.get().getPersistenceManager();
        long count = 0;
        try{
            pm.currentTransaction().begin();
            RootTO root = pm.getObjectById(RootTO.class, "key");
            count = root.getCount()+1;
            root.setCount(count);
            pm.currentTransaction().commit();
            log.debug("root count updated to: " + new Long(count).toString());
        }finally{
            if(pm.currentTransaction().isActive()){
                // ロールバック
                pm.currentTransaction().rollback();
            }
            pm.close();
        }

        if(count > 0){        
            Picture storePic = new Picture();
            storePic.setName(picture.getName());
            storePic.setPicture(picture.getPicture());
            storePic.setSize(picture.getSize());
            storePic.setContenttype(picture.getContenttype());
            
            KeyFactory.Builder kb = new KeyFactory.Builder(RootTO.class.getSimpleName(), "key");
            kb.addChild(Picture.class.getSimpleName(), 
                    Picture.class.getSimpleName()+ new Long(count).toString());
            Key key = kb.getKey();
               
            storePic.setKey(key);
            storePic.setNumber(count);
            
            pm = PMF.get().getPersistenceManager();
            try{
                pm.makePersistent(storePic);
                log.debug("picture entered: "+storePic.toString());
            }finally{
                pm.close();
            }
        }
        return;
    }

    /*
     *  Pictureを更新する。 
     */
    private void updatePicture(Picture picture) {
        
        PersistenceManager pm 
            = PMF.get().getPersistenceManager();

        try{
            // キーの生成
            KeyFactory.Builder kb = new KeyFactory.Builder(RootTO.class.getSimpleName(), "key");
            kb.addChild(Picture.class.getSimpleName(), 
                Picture.class.getSimpleName()+picture.getNumber().toString());
            Key key = kb.getKey();

            // オブジェクトの取得
            Picture storePic = pm.getObjectById(Picture.class, key);
            storePic.setName(picture.getName());
            storePic.setPicture(picture.getPicture());
            storePic.setSize(picture.getSize());
            storePic.setContenttype(picture.getContenttype());
            
            log.debug("picture updated: "+storePic.toString());
        }finally{
            pm.close();
        }
        return;
    }

    /**
     *  Pictureを削除する。 
     */
    public List<Picture> deletePicture(Picture picture) {
        PersistenceManager pm 
        = PMF.get().getPersistenceManager();

        try{
            // キーの生成
            KeyFactory.Builder kb = new KeyFactory.Builder(RootTO.class.getSimpleName(), "key");
            kb.addChild(Picture.class.getSimpleName(), 
                Picture.class.getSimpleName()+picture.getNumber().toString());
            Key key = kb.getKey();

            // オブジェクトの取得
            Picture storePic = pm.getObjectById(Picture.class, key);
            pm.deletePersistent(storePic);
            log.debug("picture deleted: "+key.toString());
        }finally{
            pm.close();
        }
        return this.loadPeople();
    }
    
    /**
     *  Pictureを一括削除する。 
     */
    @SuppressWarnings("unchecked")
    public List<Picture> deleteAllPicture() {
        PersistenceManager pm 
        = PMF.get().getPersistenceManager();

        Query query = pm.newQuery(Picture.class);
        try{
            pm.currentTransaction().begin();
            List<Picture> list = (List<Picture>)query.execute();
            pm.deletePersistentAll(list);
            pm.currentTransaction().commit();
            log.debug("picture all deleted.");
        }finally{
            if(pm.currentTransaction().isActive()){
                // ロールバック
                pm.currentTransaction().rollback();
                log.debug("picture all-delete failed.");
            }
            query.closeAll();
            pm.close();
        }
        return this.loadPeople();
    }
}

miltipart/formデータのparserの作成

miltipart/formでPOSTされたデータを解析するParserを作成する。
Parserプログラムは煩雑なので割愛する。インターネットに転がっているものを使えばOKと思う(先日、知り合いにログが長すぎると言われてしまった)。

サンプルでは、parse後、ファイルを以下のBeanに保管することにした。

FileInfo.java

package test.upload;

public class FileInfo{
    private String name;
    private byte[] fileData;
    private String fileName;
    private String contentType;
    
    public FileInfo(String name){
        this.name = name;
    }
    
    public void setFileName(String filename){
        fileName = filename;
    }
    
    public void setFileData(byte[] data){
        fileData = data;
    }
    
    public void setContentType(String type){
        contentType = type;
    }

    public String getName() {
        return name;
    }

    public byte[] getFileData() {
        return fileData;
    }

    public String getFileName() {
        return fileName;
    }

    public String getContentType() {
        return contentType;
    }
}

Servletの作成

今回は、Actionクラスを作らず、サーブレットで処理をしてしまう。
ServletInputStreamから、multipart/formデータを地道にparseしていく。fileの場合には、FileInfo.javaにデータを格納する仕様とし、そこからPictureエンティティーを登録する。fileそのものはバイト配列にしてBlobインスタンスを生成し、Pictureエンティティーのプロパティーとして格納する。

BinaryFileUploadServlet.java

package test;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;

import javax.servlet.RequestDispatcher;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletInputStream;
import javax.servlet.http.*;

import test.entities.Picture;
import test.upload.BoundaryPart;
import test.upload.FileInfo;
import test.upload.RequestInfoParser;
import test.upload.RequestInfoPart;

import com.google.appengine.api.datastore.Blob;

@SuppressWarnings("serial")
public class BinaryFileUploadServlet extends HttpServlet {

    final static int BUFSIZE = 1024;
    final static String BOUNDARY = "boundary=";
    
    public void service(HttpServletRequest req, HttpServletResponse resp)
        throws ServletException,IOException {
    
        
        // 入力用ストリームを取得
        ServletInputStream input = req.getInputStream();

        // content-typeの取り出し
        String contentType = req.getContentType();
        int index = contentType.indexOf(BOUNDARY);
        String boundary = "--" + contentType.substring(index+BOUNDARY.length());
        
        // データの読み込み
        ByteArrayOutputStream output = new ByteArrayOutputStream();
        byte[] buf = new byte[BUFSIZE];
        int writeBytes = 0;
        while((writeBytes = input.read(buf,0,BUFSIZE)) != -1){
            output.write(buf,0,writeBytes);
        }
        byte[] data = output.toByteArray();
        
        // boundaryで囲まれた部分の切り出し
        BoundaryPart[] parts = BoundaryPart.createBoundaryParts(data,boundary);
        
        // partの取り出し
        for(BoundaryPart p: parts){
            // partがtextか、fileかを判断し、前者ならTextInfo、後者ならFileInfoを返す。
            RequestInfoPart part = RequestInfoParser.parse(p);
            // fileの場合に、Pictureエンティティーの登録を行う。
            if(part instanceof FileInfo){
                FileInfo fileInfo = (FileInfo)part;
                String fullPath = fileInfo.getFileName();
                
                String filename = fullPath.substring(fullPath.lastIndexOf("\\")+1);
                
                /**
                 *  Fileの処理
                 */
                Picture picture = new Picture();
                // numberには-1が入ってくる。
                picture.setNumber(new Long(-1)); //新規
                // file名の編集
                picture.setName(filename);

                // fileデータの編集
                Blob pic = new Blob(fileInfo.getFileData());
                picture.setPicture(pic);
                // Content-Typeの編集
                picture.setContenttype(fileInfo.getContentType());

                // FileSizeの編集
                picture.setSize(pic.getBytes().length);
                
                // 登録
                PictureDao pdao = new PictureDao();
                List<Picture> pictureList = pdao.setPicture(picture);

                // ファイル送信画面にフォワード
                ServletContext sc = getServletContext();
                req.setAttribute("pictureList", pictureList);
                RequestDispatcher rd = sc.getRequestDispatcher("/pictureList.jsp");
                rd.forward(req,resp);
            }
        }
    }
}

メッセージリソースの作成

以下のようにメッセージリソースを追加する。

MessageResource.properties

picture.list.title=GAE/J Example: List of Picture Entities
picture.entry.title=GAE/J Example: Entry of Picture Information
picture.hidden=Hidden Field
picture.name=File
picture.file=File
picture.contenttype=Content-Type
picture.size=Size(Bytes)
picture.submit.entry=Enter
picture.reset.entry=Reset

MessageResource_ja.properties;実際には、これをnative2asciiしたものを配置する。

picture.list.title=GAE/J サンプル: Pictureエンティティーのリスト
picture.entry.title=GAE/J サンプル: Pictureの登録
picture.hidden=隠しフィールード
picture.name=ファイル名
picture.file=ファイルデータ
picture.contenttype=Content-Type
picture.size=Size(Bytes)
picture.submit.entry=登録
picture.reset.entry=リセット

JSPの作成

以下の2つのJSPを追加する。

pictureEntry.jspファイルをuploadするための画面。Actionクラスを通さないので、html:formタグは使わない。

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib uri="http://struts.apache.org/tags-bean" prefix="bean" %>
<%@ taglib uri="http://struts.apache.org/tags-html" prefix="html" %>

<html:html>
<head>
    <title><bean:message key="picture.entry.title" /></title>
	<link rel="shortcut icon" href="../images/egp-favicon.ico" >
</head>
<body>

<h2><bean:message key="picture.entry.title" /></h2>

<!--// struts actionを通さないので、ここは普通のHTML  -->
<form action="fileUpload" method="post" enctype="multipart/form-data">
<table>
<tr>
	<td>テキストを送るテスト</td>
	<td><input type="text" name="text"></td>
</tr>
<tr>
	<td>バイナリーを送るテスト</td>
	<td><input type="file" name="file"></td>
</tr>
</table>
<input type="submit" value="送信">
</form>

</body>
</html:html>


pictureList.jspファイルのupload後にfowardされるリスト画面。

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib uri="http://struts.apache.org/tags-bean" prefix="bean" %>
<%@ taglib uri="http://struts.apache.org/tags-html" prefix="html" %>
<%@ taglib uri="http://struts.apache.org/tags-logic" prefix="logic" %>
<%@ taglib uri="http://struts.apache.org/tags-nested" prefix="nested" %>

<html:html>
<head>
    <title><bean:message key="picture.list.title" /></title>
	<link rel="shortcut icon" href="../images/egp-favicon.ico" >
</head>
<style type="text/css">
td {
	padding: 3px;
}
</style>

<body>

<h2><bean:message key="picture.list.title" /></h2>

<table border="1">
	<tr>
		<th>ID</th>
		<th><bean:message key="picture.name" /></th>
		<th><bean:message key="picture.size" /></th>
		<th><bean:message key="picture.contenttype" /></th>
		<th><bean:message key="picture.file" /></th>
	</tr>
	
	<logic:iterate id="per" name="pictureList" >
	<tr>
		<td><bean:write name="per" property="number" /></td>
		<td><bean:write name="per" property="name" /></td>
		<td><bean:write name="per" property="size" /></td>
		<td><bean:write name="per" property="contenttype" /></td>
		<td><bean:write name="per" property="picture" /></td>
	</tr>
	</logic:iterate>
	</table>
<br>
<a href="./pictureEntry.jsp">Pictureの登録へ</a>
</body>
</html:html>

web.xml

war/WEB-INF/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">

  <display-name>GAE/J Sample Application</display-name>

   <listener>
  	<listener-class>
   	test.CreateRootContextListner
  	</listener-class>
   </listener>

   <servlet>
	 <servlet-name>FileUpload</servlet-name>
	 <servlet-class>test.BinaryFileUploadServlet</servlet-class>
  </servlet>
    
  <!-- Standard Action Servlet Configuration -->
  <servlet>
    <servlet-name>action</servlet-name>
    <servlet-class>org.apache.struts.action.ActionServlet</servlet-class>
    <init-param>
      <param-name>config</param-name>
      <param-value>/WEB-INF/struts-config.xml</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
  </servlet>

  <!-- Standard Action Servlet Mapping -->
  <servlet-mapping>
    <servlet-name>action</servlet-name>
    <url-pattern>*.do</url-pattern>
  </servlet-mapping>

  <!-- Standard Action Servlet Mapping -->
  <servlet-mapping>
	<servlet-name>FileUpload</servlet-name>
    <url-pattern>/fileUpload</url-pattern>
  </servlet-mapping>

  <!-- The Usual Welcome File List -->
  <welcome-file-list>
    <welcome-file>pictureEntry.jsp</welcome-file>
  </welcome-file-list>

</web-app>

デプロイとテスト

ローカル環境でテストした後、GAE/Jにデプロイする。

Data Viwerからは、以下のようにデータが参照できる(Blobには、SHA-1がかかっているようだ)。