跳到主要內容
黯羽輕揚每天積累一點點

Android+Node 實作檔案上傳

免費2016-03-20#Node#Android#安卓文件上传#android file upload#安卓multipart#android multipart form data#Node文件上传

Node 能夠快速建立伺服器,並支援精細的控制,還有大量開源模組提供更複雜的操作支援

寫在前面

上傳檔案時一般還需要附加一些額外的資訊,比如 userid 等與檔案密切相關而伺服端無法直接獲取的數據,所以 multipart/form-data 是比較靈活的選擇,可以透過表單同時提交檔案及其相關用戶數據

一. Android 用戶端

1. 包裝 http 通訊過程

首先我們不希望上傳檔案的過程影響 UI 更新,這裡以 AsyncTask 的方式來實作:

public class HttpUpload extends AsyncTask<Void, Integer, Void> {

    public HttpUpload(Context context, String filePath) {
        super();
        this.context = context;     // 用于更新ui(显示进度)
        this.filePath = filePath;
    }

    @Override
    protected void onPreExecute() {
        // 设置client参数
        // 显示进度条/对话框
    }
    
    @Override
    protected Void doInBackground(Void... params) {
        // 创建post请求
        // 填充表单字段及值
        // 监听进度
        // 发送post请求
        // 处理响应结果
    }

    @Override
    protected void onProgressUpdate(Integer... progress) {
        // 更新进度
    }

    @Override
    protected void onPostExecute(Void result) {
        // 隐藏进度
    }
}

2. 實作 http 通訊

http 通訊可以透過 Apache HttpClient 來實作,如下:

//--- onPreExecute
// 设置client参数
int timeout = 10000;
HttpParams httpParameters = new BasicHttpParams();
HttpConnectionParams.setConnectionTimeout(httpParameters, timeout);
HttpConnectionParams.setSoTimeout(httpParameters, timeout);
// 创建client
client = new DefaultHttpClient(httpParameters);
// 创建并显示进度
System.out.println("upload start");

//---doInBackground
try {
    File file = new File(filePath);

    // 创建post请求
    HttpPost post = new HttpPost(url);

    // 创建multipart报文体
    // 并监听进度
    MultipartEntity entity = new MyMultipartEntity(new ProgressListener() {
        @Override
        public void transferred(long num) {
            // Call the onProgressUpdate method with the percent
            // completed
            // publishProgress((int) ((num / (float) totalSize) * 100));
            System.out.println(num + " - " + totalSize);
        }
    });
    // 填充文件(可以有多个文件)
    ContentBody cbFile = new FileBody(file, "image/png");
    entity.addPart("source", cbFile);   // 相当于<input type="file" name="source">
    // 填充字段
    entity.addPart("userid", new StringBody("u30018512", Charset.forName("UTF-8")));
    entity.addPart("username", new StringBody("中文不乱码", Charset.forName("UTF-8")));

    // 初始化报文体总长度(用来描述进度)
    int totalSize = entity.getContentLength();

    // 设置post请求的报文体
    post.setEntity(entity);

    // 发送post请求
    HttpResponse response = client.execute(post);
    int statusCode = response.getStatusLine().getStatusCode();

    if (statusCode == HttpStatus.SC_OK) {
        // 返回200
        String fullRes = EntityUtils.toString(response.getEntity());
        System.out.println("OK: " + fullRes);
    } else {
        // 其它错误状态码
        System.out.println("Error: " + statusCode);
    }
} catch (ClientProtocolException e) {
    // Any error related to the Http Protocol (e.g. malformed url)
    e.printStackTrace();
} catch (IOException e) {
    // Any IO error (e.g. File not found)
    e.printStackTrace();
}

//--- onProgressUpdate
// 更新进度

//--- onPostExecute
// 隐藏进度

3. 記錄進度

Apache HttpClient 本身不提供進度數據,此處需要自行實作,重寫 MultipartEntity:

public class MyMultipartEntity extends MultipartEntity {

    private final ProgressListener listener;

    public MyMultipartEntity(final ProgressListener listener) {
        super();
        this.listener = listener;
    }

    public MyMultipartEntity(final HttpMultipartMode mode, final ProgressListener listener) {
        super(mode);
        this.listener = listener;
    }

    public MyMultipartEntity(HttpMultipartMode mode, final String boundary, final Charset charset,
            final ProgressListener listener) {
        super(mode, boundary, charset);
        this.listener = listener;
    }

    @Override
    public void writeTo(final OutputStream outstream) throws IOException {
        super.writeTo(new CountingOutputStream(outstream, this.listener));
    }

    public static interface ProgressListener {
        void transferred(long num);
    }

    public static class CountingOutputStream extends FilterOutputStream {

        private final ProgressListener listener;
        private long transferred;

        public CountingOutputStream(final OutputStream out, final ProgressListener listener) {
            super(out);
            this.listener = listener;
            this.transferred = 0;
        }

        public void write(byte[] b, int off, int len) throws IOException {
            out.write(b, off, len);
            this.transferred += len;
            this.listener.transferred(this.transferred);
        }

        public void write(int b) throws IOException {
            out.write(b);
            this.transferred++;
            this.listener.transferred(this.transferred);
        }
    }
}

內部透過重寫 FilterOutputStream 支援計數,得到進度數據

4. 呼叫 HttpUpload

在需要的地方呼叫 new HttpUpload(this, filePath).execute(); 即可,和一般的 AsyncTask 沒什麼區別

二. Node 伺服端

1. 搭建伺服器

express 快速搭建 http 伺服器,如下:

var app = express();
// ...路由控制
app.listen(3000);

最簡單的方式當然是經典 helloworld 中的 http.createServer().listen(3000),此處使用 express 主要是為了簡化路由控制和中間件管理,express 提供了成熟的路由控制和中間件管理機制,自己寫的話。。還是有點麻煩

2. multipart 請求預處理

請求預處理是中間件的本職工作,這裡採用 Connect 中間件:connect-multiparty

// 中间件预处理
app.use('/upload', require('connect-multiparty')());

// 处理multipart post请求
app.post('/upload', function(req, res){
    console.log(req.body.userid);
    console.log(req.body.username);
    console.log('Received file:\n' + JSON.stringify(req.files));
    
    var imageDir = path.join(__dirname, 'images');
    var imagePath = path.join(imageDir, req.files.source.name);
    // if exists
    fs.stat(imagePath, function(err, stat) {
        if (err && err.code !== 'ENOENT') {
            res.writeHead(500);
            res.end('fs.stat() error');
        }
        else {
            // already exists, gen a new name
            if (stat && stat.isFile()) {
                imagePath = path.join(imageDir, new Date().getTime() + req.files.source.name);
            }
            // rename
            fs.rename(
                req.files.source.path,
                imagePath,
                function(err){
                    if(err !== null){
                        console.log(err);
                        res.send({error: 'Server Writting Failed'});
                    } else {
                        res.send('ok');
                    }
                }
            );
        }
    });
});

此處只是簡單的 rename 把圖片放到目標路徑,更複雜的操作,比如建立縮圖,裁剪,合成(浮水印)等等,可以透過相關開源模組完成,比如 imagemagick

connect-multiparty 拿到 req 後先流式接收所有檔案到系統臨時資料夾(當然,接收路徑可以透過 uploadDir 屬性設置,還可以設置限制參數 maxFieldsmaxFieldsSize 等等,詳細用法請查看 andrewrk/node-multiparty),同時解析表單欄位,最後把處理結果掛在 req 物件上(req.body 是表單欄位及值組成的物件, req.files 是臨時檔案物件組成的物件),例如:

u30018512
中文不乱码
Received file:
{"source":{"fieldName":"source","originalFilename":"activity.png","path":"C:\\Us
ers\\ay\\AppData\\Local\\Temp\\KjgxW_Rmz8XL1er1yIVhEqU9.png","headers":{"content
-disposition":"form-data; name=\"source\"; filename=\"activity.png\"","content-t
ype":"image/png","content-transfer-encoding":"binary"},"size":66148,"name":"acti
vity.png","type":"image/png"}}

拿到臨時檔案路徑後再 rename 就把檔案放到伺服器目標位置了

注意:Windows 下臨時資料夾為 C:/Users/[username]/AppData/Local/Temp/,如果目標路徑不在 C 槽會報錯(跨磁碟分區會導致 rename 錯誤),如下:

{ [Error: EXDEV, rename 'C:\Users\ay\AppData\Local\Temp\KjgxW_Rmz8XL1er1yIVhEqU9
.png']
  errno: -4037,
  code: 'EXDEV',
  path: 'C:\\Users\\ay\\AppData\\Local\\Temp\\KjgxW_Rmz8XL1er1yIVhEqU9.png' }

至於解決方案,不建議用 Windows 搗鼓 Node,會遇到各種奇奇怪怪的問題,徒增煩擾,況且 vbox 裝個虛擬機也不麻煩

3. 響應靜態檔案

檔案上傳之後,如果是可供存取的(比如圖片),還需要響應靜態檔案,express 直接配置靜態目錄就好了,省好多事,如下:

// static files
app.use('/images', express.static(path.join(__dirname, 'images')));

三. 專案地址

GitHub: ayqy/MultipartUpload

P.S. 話說 github 主頁空空如也,有點說不過去了,接下來的這段時間盡量放點東西上去吧。工程師事業指南之一就是累積作品集(portfolio),到現在也沒有理由偷懶了,cheer up, ready to work

參考資料

本文 fork 自該專案,但原專案年久失修 (Latest commit 518c106 on 19 Nov 2012),筆者做了修繕

更成熟更受歡迎的 android 上傳元件,提供 Node 和 PHP 的伺服端實作,當然,也相對龐大

評論

暫無評論,快來發表你的看法吧

提交評論