들어가며
파일을 업로드할 때 보통 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-multiparty는 req를 받은 후 먼저 모든 파일을 시스템 임시 폴더로 스트리밍하여 수신합니다(물론 수신 경로는 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 업로드 컴포넌트입니다. 물론 그만큼 규모도 큽니다.
아직 댓글이 없습니다