이 글에서는 REST API 기반 파일 업로드와 다운로드 구현방안을 설명합니다.
REST 서버와 REST 클라이언트를 이용해 기능을 구현했습니다.
REST 기반 파일 업로드와 다운로드 구현
REST API 구현 시 파일을 제공해야하는 경우가 있습니다. 파일 업로드 시 기존의 데이터와 함께 파일을 업로드할 수도 있고, 별도의 파일 전용 엔드포인트를 추가해 구현할 수 있습니다. 이 두가지 방법 모두에 대해 설명합니다.
이 글에 앞서 다음 내용을 이해하고 있어야 합니다. 미리 선행 학습이 필요합니다.
- [REST API] REST API 이해하기
- [REST API][실습] REST API 서버 개발하기(엔드포인트 구현, RAD 서버 이용)
- [REST API][실습] REST API 클라이언트 개발하기(REST Client 이용)
이 글에서는 다음 내용을 다룹니다.
- 파일 엔드포인트 추가 구성
- 파일 업로드 구현 방안
- 서버 측 구현
- 클라이언트 측 구현
- 파일 다운로드 구현 방안
- 서버 측 구현
- 클라이언트 측 구현
파일 엔드포인트 추가 구성
파일을 제공하는 기능을 추가하기 위해서는, 1) 기존 엔드포인트에서 파일 항목을 추가하는 방법과 2) 별도의 엔드포인트를 추가하는 방법으로 구현할 수 있습니다.
이 글에서는 별도의 엔드포인트를 추가해 파일 업로드와 다운로드 기능을 구현하는 방법을 설명합니다.
저는 images라는 리소스이름으로 RAD 서버 패키지 프로젝트를 생성했습니다.
1, File > New > Other
2, RAD Server > RAD Server Package
3, Create package with resource > Next
4, Resource name: images, File type: Data Module > Next
5, 모든 항목 선택 해제 > Finish
다음과 같이 엔드포인트를 추가합니다.(선언부에 추가 후 Ctrl + Shift + C를 눌러 구현부를 자동 생성할 수 있습니다.)
type [ResourceName('images')] TImagesResource1 = class(TDataModule) published [ResourceSuffix('{item}/photo')] procedure GetItemPhoto(const AContext: TEndpointContext; const ARequest: TEndpointRequest; const AResponse: TEndpointResponse); [ResourceSuffix('{item}/photo')] procedure PostItemPhoto(const AContext: TEndpointContext; const ARequest: TEndpointRequest; const AResponse: TEndpointResponse); end;
엔드포인트는 ResourceSuffix(리소스 접미사)와 메소드로 구성됩니다.
메소드는 Get, Post, Put, Delete 접두사로 시작해야 합니다. 리소스 호출 시 HTTP 메소드와 접두사가 매핑되어 메소드가 호출 됩니다.
즉, GET으로 요청된 경우 Get*으로 시작된 메소드가, POST로 요청한 경우 Post*로 시작된 메소드가 실행됩니다.
ResourceSuffix에 지정된 항목과 매핑된 요청이 온 경우 메소드가 실행됩니다.
중괄호({})로 감싼 부분은 파라미터화된 항목으로 구현부에서 ARequest.Params.Values['item'] 등의 코드로 그부분의 내용을 취득할 수 있습니다.
예를 들어
GET http://localhost:8080/images/1/photo 호출 시 GetItemPhoto 메소드가 호출되고
POST http://localhost:8080/images/1/photo 호출 시 PostItemPhoto 메소드가 호출됩니다.
ARequest.Params.Values['item']은 1을 반환합니다.
파일 업로드 구현 방안
서버(RAS Server) 측 구현
파일 업로드는 PostItemPhoto 메소드에서 구현합니다.
주의할 점은, 파일 전송을 위해서는 multipart/form-data 인코딩 타입으로 데이터가 전달됩니다.
현재(2020년 08월)에 RAD 서버에서 multipart/form-data 타입의 데이터를 처리하는 기능이 구현되어있지 않은 것으로 파악되어, 직접 데이터를 분석해 필요한 데이터를 사용해야 합니다.
저는 다음과 같은 DecodeParamsAndStream 함수를 이용해 데이터를 분석했습니다.
(Indy에서 재공하는 TIdMessageDecoderMIME 객체를 이용했습니다.)
type TStreamParams = class(TDictionary) private function GetStream(const Name: string): TStream; procedure SetStream(const Name: string; const Value: TStream); public property Streams[const Name: string]: TStream read GetStream write SetStream; destructor Destroy; override; end; procedure DecodeParamsAndStream(AStream: TStream; AContentType: string; Params: TStrings; StreamParams: TStreamParams);
파라메터로는
- AStream : Body의 전체 스트림
- AContentType : 요청의 컨텐트 타입, boundary 포함
- Params : 문자열 형식의 데이터(파라미터)
- StreamParams : Stream 형식의 데이터(파라미터), TStreamParams 객체는 <string, TStream> 쌍의 딕셔너리 사용
파라미터 데이터들은 컨텐트타입의 boundary 값을 앞뒤에 두어 구분합니다.
주의할 점은 문자열 형식의 파라미터 데이터의 경우 Content-Type이 누락되어 있습니다.(분석 시 누락된 경우에 대해 예외처리가 필요합니다.)
uses System.IOUtils, IdGlobalProtocols, IdMessageCoder, IdMessageCoderMIME; procedure DecodeParamsAndStream(AStream: TStream; AContentType: string; Params: TStrings; StreamParams: TStreamParams); var Boundary: string; Decoder, NewDecoder: TIdMessageDecoderMIME; MsgEnd: Boolean; FieldName: string; StringStream: TStringStream; MemoryStream: TMemoryStream; begin Boundary := ExtractHeaderSubItem(AContentType, 'boundary', QuoteHTTP); Decoder := TIdMessageDecoderMIME.Create(nil); try MsgEnd := False; repeat Decoder.MIMEBoundary := Boundary; Decoder.SourceStream := AStream; Decoder.FreeSourceStream := False; Decoder.ReadHeader; { Content-Type이 없는 경우 mcptAttachment로 인식해 기본값 설정 RESTClient에서 MultiPart 전송 시 일반 파라메터의 경우 Content-Type 누락해 전송 함} if Decoder.Headers.Values['Content-Type'] = '' then begin Decoder.Headers.Values['Content-Type'] := 'text/plain'; Decoder.CheckAndSetType(Decoder.Headers.Values['Content-Type'], Decoder.Headers.Values['Content-Disposition']); end; case Decoder.PartType of mcptText: begin FieldName := ExtractHeaderSubItem(Decoder.Headers.Values['Content-Disposition'], 'name', QuoteMIME); StringStream := TStringStream.Create; try NewDecoder := Decoder.ReadBody(StringStream, MsgEnd) as TIdMessageDecoderMIME; try Params.Values[FieldName] := StringStream.DataString.Trim; finally Decoder.Free; Decoder := NewDecoder; end; finally StringStream.Free; end; end; mcptAttachment: begin var HL: string := Decoder.Headers.Values['Content-Disposition']; FieldName := ExtractHeaderSubItem(Decoder.Headers.Values['Content-Disposition'], 'name', QuoteMIME); MemoryStream := TMemoryStream.Create; NewDecoder := Decoder.ReadBody(MemoryStream, MsgEnd) as TIdMessageDecoderMIME; try StreamParams.Streams[FieldName] := MemoryStream; finally Decoder.Free; Decoder := NewDecoder; end; end; mcptIgnore: begin FreeAndNil(Decoder); Decoder := TIdMessageDecoderMIME.Create(nil); TIdMessageDecoderMIME(Decoder).MIMEBoundary := Boundary; end; mcptEOF: begin FreeAndNil(Decoder); MsgEnd := True; end; end; until (Decoder = nil) or MsgEnd; finally Decoder.Free; end; end;
위의 코드가 좀 길고 생소할 수 있습니다.
중요한 부분은 디코더로 분석한 데이터의 Decoder.PartType이 mcptText(텍스트)인 경우 Params 항목에 데이터를 추가하고, mcptAttachment(첨부 파일)인 경우 StreamParams 항목에 데이터를 추가했습니다.
위 함수를 사용한 PostItemPhoto 메소드의 코드는 다음과 같습니다.
const ROOT_PATH = 'D:\Projects\DelphiDemos\OpenAPI\RESTUpload\Server'; procedure TImagesResource1.PostItemPhoto(const AContext: TEndpointContext; const ARequest: TEndpointRequest; const AResponse: TEndpointResponse); var LItem: string; Stream: TStream; ContentType: string; Params: TStringList; StreamParams: TStreamParams; Path, RawPath: string; begin LItem := ARequest.Params.Values['item']; Params := TStringList.Create; StreamParams := TStreamParams.Create; Stream := ARequest.Body.GetStream; ContentType := ARequest.Headers.GetValue('Content-Type'); // e.g. multipart/form-data; boundary=--------070120105641002 Path := TPath.Combine(ROOT_PATH, 'images'); RawPath := TPath.Combine(Path, 'raw_' + LItem + '.jpg'); Path := TPath.Combine(Path, LItem + '.jpg'); // Save raw data TMemoryStream(Stream).SaveToFile(RawPath); // Decode parameters and streams from body stream. DecodeParamsAndStream(Stream, ContentType, Params, StreamParams); // Using parameters as a below if Params.Values['user_id'] = '123' then begin end; // Save photo parameter to a file. if Assigned(StreamParams.Streams['photo']) then TMemoryStream(StreamParams.Streams['photo']).SaveToFile(Path); StreamParams.Free; Params.Free; end;
참고로, 파일 저장 방식은 파일로 저장하는 방식과 Blob 필드에 저장하는 방식이 있으며, 이 예제에서는 지정경로(ROOT_PATH) 하위 images 디렉토리에 {item}항목 이름으로 저장했습니다.
파일로 저장시의 주의점은 RAD 서버의 로컬 디스크로 저장 시 서버를 병렬화(여러대 구성) 시 접근이 제한됩니다. 네트워크 경로 또는 공유 파일 시스템을 이용해야 합니다.(또는 파일 공유 솔루션 등을 이용할 수도 있습니다.)
Blob 필드로 저장 시 주의점은 데이터와 별도의 테이블에 Blob 필드를 구성하는 것을 추천드립니다. 성능 측면과 향후 데이터 관리(백업 등) 시 유리할 수 있습니다.
클라이언트(REST Client) 측 구현
REST 클라이언트를 이용해 파일 업로드 구현은 데이터 전송과 크게 차이나지 않습니다.
procedure TForm1.Button1Click(Sender: TObject); var Filepath: string; Stream: TFileStream; begin if not OpenDialog1.Execute then Exit; Filepath := OpenDialog1.FileName; Stream := TFileStream.Create(Filepath, fmOpenRead); RESTClient1.BaseURL := 'http://localhost:8080'; RESTRequest2.Method := rmPOST; RESTRequest2.Resource := 'images/{item}/photo'; RESTRequest2.Params.ParameterByName('item').Value := Edit1.Text; RESTRequest2.Params.AddItem('user_id', '123'); RESTRequest2.Params.AddItem('photo', Stream, pkFILE, [], ctAPPLICATION_OCTET_STREAM); RESTRequest2.Execute; Stream.Free; end;
파일을 추가할 경우, TStream 이용하므로, TStream을 상속받는 객체들(TFileStream, TMemoryStream 등)을 이용할 수 있습니다.
스트림을 파라미터로 추가하는 코드는 다음과 같습니다.
RESTRequest2.Params.AddItem('photo', Stream, pkFILE, [], ctAPPLICATION_OCTET_STREAM);
파라미터 종류를 pkFILE로 지정시 내부적으로 multipart/form-data 인코딩 타입으로 데이터가 전송되며, 컨텐트타입을 ctAPPLICATION_OCTET_STREAM으로 지정해야 합니다.
파일 다운로드 구현 방안
서버(RAD Server) 측 구현
procedure TImagesResource1.GetItemPhoto(const AContext: TEndpointContext; const ARequest: TEndpointRequest; const AResponse: TEndpointResponse); var LItem: string; Path: string; Stream: TStream; begin LItem := ARequest.Params.Values['item']; Path := TPath.Combine(ROOT_PATH, 'images'); Path := TPath.Combine(Path, LItem + '.jpg'); if not TFile.Exists(Path) then AResponse.RaiseNotFound('Not found', '''' + LItem + ''' is not found'); Stream := TFileStream.Create(Path, fmOpenRead); AResponse.Body.SetStream(Stream, 'image/jpeg', True); end;
클라이언트(REST Client) 측 구현
procedure TForm1.Button2Click(Sender: TObject); var WICImage: TWICImage; Stream: TMemoryStream; begin RESTClient1.BaseURL := 'http://localhost:8080'; RESTRequest1.Method := rmGET; RESTRequest1.Resource := 'images/{item}/photo'; RESTRequest1.Params.ParameterByName('item').Value := Edit1.Text; RESTRequest1.ExecuteAsync(procedure begin if RESTResponse1.StatusCode = 404 then Exit; Stream := TMemoryStream.Create; Stream.WriteData(RESTResponse1.RawBytes, RESTResponse1.ContentLength); WICImage := TWICImage.Create; WICImage.LoadFromStream(Stream); Image1.Picture.Assign(WICImage); WICImage.Free; Stream.Free; end); end;