이 글에서는 REST API 기반 파일 업로드와 다운로드 구현방안을 설명합니다.

REST 서버와 REST 클라이언트를 이용해 기능을 구현했습니다.

REST 기반 파일 업로드와 다운로드 구현

 

REST API 구현 시 파일을 제공해야하는 경우가 있습니다. 파일 업로드 시 기존의 데이터와 함께 파일을 업로드할 수도 있고, 별도의 파일 전용 엔드포인트를 추가해 구현할 수 있습니다. 이 두가지 방법 모두에 대해 설명합니다.

 

이 글에 앞서 다음 내용을 이해하고 있어야 합니다. 미리 선행 학습이 필요합니다.

 

이 글에서는 다음 내용을 다룹니다.

  • 파일 엔드포인트 추가 구성
  • 파일 업로드 구현 방안
    • 서버 측 구현
    • 클라이언트 측 구현
  • 파일 다운로드 구현 방안
    • 서버 측 구현
    • 클라이언트 측 구현

 

파일 엔드포인트 추가 구성

파일을 제공하는 기능을 추가하기 위해서는, 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를 눌러 구현부를 자동 생성할 수 있습니다.)

1
2
3
4
5
6
7
8
9
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 객체를 이용했습니다.)

1
2
3
4
5
6
7
8
9
10
11
12
type
  TStreamParams = class(TDictionary<string, tstream="">)
  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);</string,>

파라메터로는 

  • AStream : Body의 전체 스트림
  • AContentType : 요청의 컨텐트 타입, boundary 포함
  • Params : 문자열 형식의 데이터(파라미터)
  • StreamParams : Stream 형식의 데이터(파라미터), TStreamParams 객체는 <string, TStream> 쌍의 딕셔너리 사용
 
컨텐트 타입은 다음과 같이 데이터의 인코딩 타입과 boundary값이 포함됩니다.
multipart/form-data; boundary=---------Embt-Boundary--493D921E3683D69B
 
REST Client에서 파일 업로드 요청한 데이터(Body의 스트림)의 내용은 아래 그림과 같습니다.

 


파라미터 데이터들은 컨텐트타입의 boundary 값을 앞뒤에 두어 구분합니다.

주의할 점은 문자열 형식의 파라미터 데이터의 경우 Content-Type이 누락되어 있습니다.(분석 시 누락된 경우에 대해 예외처리가 필요합니다.)

위 데이터를 분석한 내용은 다음과 같습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
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 메소드의 코드는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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 클라이언트를 이용해 파일 업로드 구현은 데이터 전송과 크게 차이나지 않습니다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 등)을 이용할 수 있습니다.

 

스트림을 파라미터로 추가하는 코드는 다음과 같습니다.

1
RESTRequest2.Params.AddItem('photo', Stream, pkFILE, [], ctAPPLICATION_OCTET_STREAM);

 

파라미터 종류를 pkFILE로 지정시 내부적으로 multipart/form-data 인코딩 타입으로 데이터가 전송되며, 컨텐트타입을 ctAPPLICATION_OCTET_STREAM으로 지정해야 합니다.

파일 다운로드 구현 방안

서버(RAD Server) 측 구현

파일 다운로드는 GetItemPhoto 메소드에서 구현합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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;
요청한 {item} 항목의 파일을 스트림(TFileStream)으로 읽어 그대로 추력합니다.
주의할 점은 마지막 줄의 SetStream의 마지막 파라미터(AOwnerValue)를 True로 지정해야 Stream 객체가 해제됩니다.(직접 해제 시 메모리 참조 오류가 발생합니다.) False로 지정 시 객체를 복사하므로, 직접 해제해도 됩니다.
 

클라이언트(REST Client) 측 구현

VCL Form Application에서 REST Client로 이미지를 다운로드 후 이미지(TImage)에 표시하는 코드는 다음과 같습니다.
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