본문으로 건너뛰기

Android+Node 기반 파일 업로드 구현

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

Node는 서버를 빠르게 구축할 수 있고 세밀한 제어를 지원하며, 방대한 오픈 소스 모듈을 통해 복잡한 작업까지 지원합니다.

들어가며

파일을 업로드할 때 보통 userid와 같이 파일과 밀접하게 관련되어 있으면서 서버에서 직접 가져올 수 없는 추가 정보가 필요합니다. 따라서 multipart/form-data는 폼을 통해 파일과 관련 사용자 데이터를 동시에 제출할 수 있는 유연한 선택입니다.

1. 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와 차이가 없습니다.

2. Node 서버

1. 서버 구축

express를 사용하여 HTTP 서버를 다음과 같이 빠르게 구축할 수 있습니다.

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

가장 간단한 ���식은 물론 고전적인 Hello World의 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-multipartyreq를 받은 후 먼저 모든 파일을 시스템 임시 폴더로 스트리밍하여 수신합니다(물론 수신 경로는 uploadDir 속성으로 설정할 수 있으며, maxFields, maxFieldsSize 등 제한 매개변수도 설정 가능합니다. 자세한 용법은 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')));

3. 프로젝트 주소

GitHub: ayqy/MultipartUpload

P.S. GitHub 메인 페이지가 텅 비어 있는 게 조금 민망하네요. 앞으로는 이것저것 올려보려 합니다. 엔지니어 커리어 가이드 중 하나가 포트폴리오(portfolio) 쌓기인데, 이제 더 이상 게으름 피울 핑계가 없네요. Cheer up, ready to work!

참고 자료

이 글은 해당 프로젝트를 포크(fork)한 것이지만, 원본 프로젝트가 오랫동안 관리되지 않아(Latest commit 518c106 on 19 Nov 2012) 필자가 수리했습니다.

Node와 PHP 서버 구현을 제공하는 더 성숙하고 인기 있는 Android 업로드 컴포넌트입니다. 물론 그만큼 규모도 큽니다.

댓글

아직 댓글이 없습니다

댓글 작성