Dart Programmer 되기 [28]

< HTTP 서버개발 – GET & POST in HTTP Server >

상세한 설명을 하기전에 간단한 작업을 해 봅니다. 먼저, 앞서에서 작성한 darttutorial-27-02.dart를 다시 불러 옵니다. 그리고 handleRequest() 함수안의 case ‘GET’: 구문이 stdout.writeln(“${DateTime.now()}: GET ${req.uri.path}”);를 stdout.writeln(“${DateTime.now()}: GET ${req.uri}”);로 변경합니다. 즉 URI의 path 정보만 보던 것을 URI 전체를 출력하도록 바꾼 겁니다. 그리고 실행합니다. 다음으로는 웹브라우저를 실행한 후, 주소창에 http://localhost:4040/?q=4 라고 타이핑하곤 엔터를 타이핑 합니다. 그러면 서버쪽 화면에 “2020-02-14 11:34:06.907564: GET /?q=4″와 같이 출력이 됩니다. 이런 식으로 q라는 변수가 4라는 값을 갖도록 하라는 정보를 HTTP Client에서 HTTP Server로 전달한 것입니다. Get 명령을 통해서 Client가 Server로 정보를 보낼수 있는 것을 확인한 셈입니다. 이와 같은 방식에 대해서 오늘의 글에서 좀 더 알아가 보도록 하겠습니다.

RESTful GET Request

REST는 REpresentational State Transfer의 약어로써, 웹서비스에서 HTTP Client가 HTTP Server로 (마치 함수 호출시, 입력 파라메타를 전달하듯이) 기능 동작에 필요한 파라메타(들)을 전달하는 방법입니다. 주고 받는 변수와 이의 값을, 영어 이름 그대로, 표현해주는 방식이며, 어떤 표준이 존재하는 것은 아닙니다.

darttutorial-27-02.dart 프로그램의 앞서 수정한 부분을 한번 더 수정해 봅니다. 앞서 수정했던 구문을 stdout.writeln(“${DateTime.now()}: GET ${req.uri.queryParameters[‘q’]}”);으로 변경합니다. 의미는 URI 정보가 map 형태로 관리하는 parameter 정보 중 ‘q’를 key 값으로 하는 경우의 value를 도출한다는 의미입니다. 즉, 사용자가 보낸 REST 타입의 값들이 map 형태로 저장이 되는데, 이중 ‘q’에 해당하는 값만 추출해서 출력한다는 의미입니다. 다시 서버를 수행하고, Client는 앞서와 동일하게 http://localhost:4040/?q=4로 동작해 봅니다. 그러면, 서버의 출력으로 “GET 4″가 출력되는 것을 볼 수 있습니다. 이렇게 서버에서는 client가 보낸 일종의 변수들의 값을 추출함으로서, 동적으로 client가 요청한 동작과 파라메타에 따른 동적이 가능합니다.

만약 HTTP Client가 복수의 변수 값을 서버에 전달하고 싶다면 어떻게 할까요? 어렵지 않습니다. http://localhost:4040/?q=4&p=5.0&r=”Dart” 처럼 하면, q/p/r 변수에 각각 4/5.0/”Dart”를 값으로 해서 서버에 전달한 것입니다. 이를 제대로 보기 위해서, 이번에는 서버의 Get을 처리하는 위의 코드를 stdout.writeln(“${DateTime.now()}: GET ${req.uri.queryParameters}”);으로 변경합니다. 그러면, 2020-02-14 12:16:48.918590: GET {q: 4, p: 5.0, r: “Dart”} 처럼 Map 타입의 값이 출력 됩니다. 이렇게 HTTP Client와 서버는 REST 방식을 사용해서 간단하게 복수 파라메타의 정보를 전달할 수 있고, 이를 통한 동적인 동작이 가능합니다.

HTTP Classes

HTTP 관련 Class들에 대해서 부언 설명을 할 시점 입니다. 먼저 HttpRequest는 계속 등장하고 있지만, Client가 전달한 요청에 대한 정보를 저장합니다. method Property는 HTTP의 표준 명령을 의미하여, GET/PUT/POST 등의 정보를 갖습니다. uri Property는 Uri 객체를 저장하는데, scheme, host, port, querystring과 같이 HTTP Request 메시지안에 포함된 정보들을 포함하고 있습니다. response Property는 HttpResponse 객체를 저장하여, HttpRequest에 대한 응답 정보를 저장하고 있습니다. 마지막으로 headers Property는 HttpHeaders 객체를 저장하여, HTTP Request의 헤더 정보를 포함하는데, ContentType, content length, date 등의 정보를 저장하고 있습니다. 이미 우리는 darttutorial-27-02.dart 에서 req.method 문법을 통해서 “GET”과 매칭이 되는지를 확인 했으며, 다음과 같은 코드를 통해서, HTTP Request에 대한 응답의 코드와 Body 내용을 채우는 것을 보았습니다. 그리고 이번 글의 req.uri.queryParameters 문법으로 HTTP Request의 URI 내의 Map 정보를 접근하는 것도 확인 했습니다.

res
  ..statusCode = HttpStatus.methodNotAllowed
  ..write('${DateTime.now()}: Unsupported request: ${req.method}.');

HTTP Request에 대한 응답으로 만들어지는 HTTP Response 정보는 HttpResponse 객체로 만들어 집니다. 가장 기초적으로 함수의 리턴 값이 있듯이, HTTP Request에 대한 성공/실패 여부를 알리는 정보가 있으며, 이는 HttpResponse의 statusCode Property에 저장합니다. 가장 대표적인 두 값은 성공적으로 Request가 처리되었음을 의미하는 HttpStatus.ok이고, 서버가 처리할 수 없다는 의미인 (그리고 darttutorial-28-01.dart에서 등장했었던) HttpStatus.methodNotAllowed 입니다. HttpResponse안에는 그외에 유용한 property들이 있습니다. contentLength Property는 HTTP Response의 크기를 의미합니다. cookies Property는 HTTP Response를 받은 HTTP Client가 저장해야 하는 Cookies의 리스트 정보를 포함합니다. encodingProperty는 JSON이나 UTF-6과 같은 문자열을 저장할 때 사용하며, headers Property는 response의 헤더를 HttpHeaders 객체로 저장합니다.

HTTP POST Request Support in HTTP Server

앞서에서 URI에 RESTful의 형태로 함수를 호출하는 것처럼, 복수의 파라메타에 대한 값을 전달하는 방법을 보았습니다. 더 많은 정보를 전달하고자 한다면 HTTP POST Request를 사용할 수 있습니다. 예를들어 HTTP Client로부터 Map 타입의 정보를 HTTP Server가 수신하여, 이들을 화일에 저장하는 예제를 생각할 수 있습니다.

이럴때 언어와 독립적으로 정보를 저장하는 대표적인 형태가 JSON(JavaScript Object Notation) 입니다. 단어는 생소하지만, Dart 언어의 Map과 거의 같은 형태라고 보면 됩니다. 아래는 위키페디아에서 한 사람의 정보를 저장하는 JSON의 예제입니다 [출처].

 {
    "이름": "홍길동",
    "나이": 25,
    "성별": "여",
    "주소": "서울특별시 양천구 목동",
    "특기": ["농구", "도술"],
    "가족관계": {"#": 2, "아버지": "홍판서", "어머니": "춘섬"},
    "회사": "경기 수원시 팔달구 우만동"
 }

위와 같은 JSON 타입의 데이타를 HTTP 프로토콜로 전송할 때, ‘application/json’ 라고 지칭합니다. JSON 타입의 데이타를 HTTP Client에서 받아서 화일에 저장하는 기능을 darttutorial-27-02.dart 프로그램에 추가하는 함수 handlePostRequest()를 다음과 같이 만들 수 있습니다.

// Handler for HTTP POST Request.
Future handlePostRequest(HttpRequest req) async {
  // #1 Retrieve an associated HttpResponse object in HttpRequst object.
  HttpResponse res = req.response;

  // #2 Do something : Example - Write file based on the reseived JSON.
  if (req.headers.contentType?.mimeType == 'application/json') {
    try {
      // #2.1 utf8.decoder.bind(req) : Retrieve multiple chunks within a same HTTP Request.
      // #2.2 .join() : Puts the chunks together.  
      String jsonContent = await utf8.decoder.bind(req).join();

      // #2.3 Retrieve file name from HTTP POST Request
      var jsonFileName = req.uri.pathSegments.last; 

      // #2.4 Save the received JSON content into a file in a current working directory
      await File(jsonFileName).writeAsString(jsonContent, mode: FileMode.write);

      // #2.5 Decode the received JSON content and concert into MAP format
      var jsonData = jsonDecode(jsonContent) as Map;

      // #2.6 Make a response for success case
      req.response
        ..statusCode = HttpStatus.ok
        ..write('${DateTime.now()}: Wrote data for ${jsonData}.');
    } catch (e) {
      // #2.7 Make a response for failed case
      res
        ..statusCode = HttpStatus.internalServerError
        ..write('${DateTime.now()}: Exception during file I/O: $e.');
    }
  } else {
    // 2.8 Make a response for not supported POST Request (not a JSON format)
    res
    ..statusCode = HttpStatus.methodNotAllowed
    ..write('${DateTime.now()}: Unsupported POST type: ${req.headers.contentType?.mimeType}.');
  }

  // #3 Close the response and send it to the client.
  await res.close();
}

handlePostRequest() 함수에 대해서 자세히 알아 봅니다. 입력 파라메타는 다른 HTTP Request와 마찬가지로 HTTP Client로부터 받은 메세지가 포함된 HttpRequest 객체를 받습니다. 그리고 비동기 모드 동작을 위한 기능이 포함되었기에, async와 Future의 문법을 사용하고 있습니다. #1과 #3은 GET을 처리하는 함수와 동일 합니다. #2의 구문을 보면, req.headers.contentType?.mimeType == ‘application/json’ 구문을 통해서, 당장은 JSON 데이타 타입만 처리하겠다고 선언 했습니다. 따라서, 다른 타입으로 POST가 수신되면, #2.8에 의해서 “지원하지 않는 타입”이라는 메세지를 회신하게 됩니다. JSON 타입이라면, 후속작업을 하게 됩니다. #2.1으로 이 작업을 시작하는데, HTTP POST Request에 포함된 정보들을 UTF-8 형태로 변환하고, (통신상에서 같은 HTTP POST Request 메시지가 여러개의 통신 메시지로 나뉘어져서 수신될 수 있으니) 같은 HTTP Request에 속한 메시지를 하나로 묶어 주는 작업을 #2.2에서 join()으로 수행합니다. 이렇게 해서 jsonContent은 HTTP Client가 전달한 HTTP POST Request의 모든 정보를 하나의 문자열에 담습니다.

다음으로 POST를 통해서 전달된 Map 형식의 데이타를 저장할 화일이름을 HTTP Client가 전송했으며, 이를 받아야 합니다. 이를 위해서 익숙한 req.uri Property가 다시 등장했고, 이번에는 URI에서 마지막에 포함되어 있을 화일이름만 추출하는 작업을 #2.3에서 pathSegments.last Property를 통해서 확보합니다.

#2.4에서 드디어 화일에 저장하는 작업을 (단순하게 한줄이지만) 성공적으로 수행합니다. 저장할 화일 이름을 File 클래스의 Constructor에 전달하여, File 객체를 만들면서, JSON 형태로 받은 정보를 File 객체에 저장하도록 writeAsString()를 실행합니다. 이렇게 해서 JSON 타입으로 전달 받은 정보는 서버에 화일 형태로 저장됩니다.

#2.5는 혹시라도 있을 서버에서의 데이타 처리를 위하여, JSON 형태의 데이타를 아예 Dart 언어의 Map 형태로 바꾼후 jsonData 변수에 저장합니다. 이후 #2.6에서 성공적인 응답이라는 코드와 함께 저장한 내용을 다시 HTTP Client에게 HTTP Response 메시지로 전달하게 됩니다.

POST Request를 다루는 함수를 추가했으니, 앞서 만들었던 handleRequest()에서 handlePostRequest()를 호출할 수 있도록 추가해야 합니다. 아래는 case “POST”:를 통해서 HTTP POST Request가 수신되면, handlePostRequest()를 수행하도록 수정한 함수 입니다.

// Handler for HTTP Request.
Future handleRequest(HttpRequest req) async {
    // #1 Do something based on HTTP request types.
    switch (req.method) {
      // #1.1 GET Request.
      case 'GET':
        // Print log message and activate HTTP Get Request handler.
        stdout.writeln("${DateTime.now()}: GET ${req.uri}");
        await handleGetRequest(req);
        break;
      // #1.2 GET Request.
      case 'POST':
        // Print log message and activate HTTP Post Request handler.
        stdout.writeln("${DateTime.now()}: POST ${req.uri}");
        await handlePostRequest(req);
        break;
      // #1.3 Other Requests.
      default:
        stdout.writeln("${DateTime.now()}: ${req.method} not allowed");
        await handleNotAllowedRequest(req);
    }
}

자 이제 기존의 darttutorial-27-02.dart에 위에서 수정한 함수들을 모두 포함하도록 변경한 darttutorial-28-01.dart 프로그램을 만들었습니다. 아래의 소스 프로그램은 POST Reqeust를 수정과 함께, 다른 부분에 대해서 일부 개선한 형태입니다. 이렇게 해서, 우리의 HTTP Server는 GET과 POST를 처리하도록 향상 되었습니다.

// darttutorial-28-01.dart

import "dart:io";
import 'dart:async';
import 'dart:convert';

// Handler for HTTP GET Request.
Future handleGetRequest(HttpRequest req) async {
  // #1 Retrieve an associated HttpResponse object in HttpRequst object.
  HttpResponse res = req.response;

  // #2 Do something : Example - Write text body in the response.
  res
  ..statusCode = HttpStatus.ok
  ..write('${DateTime.now()}: Hello World!');

  // #3 Close the response and send it to the client.
  await res.close();
}

// Handler for HTTP POST Request.
Future handlePostRequest(HttpRequest req) async {
  // #1 Retrieve an associated HttpResponse object in HttpRequst object.
  HttpResponse res = req.response;

  // #2 Do something : Example - Write file based on the reseived JSON.
  if (req.headers.contentType?.mimeType == 'application/json') {
    try {
      // #2.1 utf8.decoder.bind(req) : Retrieve multiple chunks within a same HTTP Request.
      // #2.2 .join() : Puts the chunks together.  
      String jsonContent = await utf8.decoder.bind(req).join();

      // #2.3 Retrieve file name from HTTP POST Request
      var jsonFileName = req.uri.pathSegments.last; 

      // #2.4 Save the received JSON content into a file in a current working directory
      await File(jsonFileName).writeAsString(jsonContent, mode: FileMode.write);

      // #2.5 Decode the received JSON content and concert into MAP format
      var jsonData = jsonDecode(jsonContent) as Map;

      // #2.6 Make a response for success case
      req.response
        ..statusCode = HttpStatus.ok
        ..write('${DateTime.now()}: Wrote data for ${jsonData}.');
    } catch (e) {
      // #2.7 Make a response for failed case
      res
        ..statusCode = HttpStatus.internalServerError
        ..write('${DateTime.now()}: Exception during file I/O: $e.');
    }
  } else {
    // 2.8 Make a response for not supported POST Request (not a JSON format)
    res
    ..statusCode = HttpStatus.methodNotAllowed
    ..write('${DateTime.now()}: Unsupported POST type: ${req.headers.contentType?.mimeType}.');
  }

  // #3 Close the response and send it to the client.
  await res.close();
}

// Handler for not allowed HTTP Request.
Future handleNotAllowedRequest(HttpRequest req) async {
  // #1 Retrieve an associated HttpResponse object in HttpRequst object.
  HttpResponse res = req.response;

  // #2 Do something : Example - Write text body in the response.
  res
  ..statusCode = HttpStatus.methodNotAllowed
  ..write('${DateTime.now()}: Unsupported request: ${req.method}.');

  // #3 Close the response and send it to the client.
  await res.close();
}

// Handler for HTTP Request.
Future handleRequest(HttpRequest req) async {
    // #1 Do something based on HTTP request types.
    switch (req.method) {
      // #1.1 GET Request.
      case 'GET':
        // Print log message and activate HTTP Get Request handler.
        stdout.writeln("${DateTime.now()}: GET ${req.uri.queryParameters}");
        await handleGetRequest(req);
        break;
      // #1.2 GET Request.
      case 'POST':
        // Print log message and activate HTTP Post Request handler.
        stdout.writeln("${DateTime.now()}: POST ${req.uri}");
        await handlePostRequest(req);
        break;
      // #1.3 Other Requests.
      default:
        stdout.writeln("${DateTime.now()}: ${req.method} not allowed");
        await handleNotAllowedRequest(req);
    }
}

Future main() async {
  // #1 Specify HTTP Server address (localhost) and port.
  final HOST = InternetAddress.loopbackIPv4; // or "0.0.0.0" to allow access from other machines
  final PORT = 4049;

  // #2 Starts listening for HTTP requests on the address and port.
  var httpServer = await HttpServer.bind(HOST, PORT);
  stdout.writeln("${DateTime.now()}: HTTP Server running at ${HOST.address}:$PORT");

  // #3 Listening for HTTP requests and handle requests.
  await for (HttpRequest httpRequest in httpServer) {
    try {
      // #3.1 Activate a HTTP Request handler
      handleRequest(httpRequest);
    } catch(e) {
      // #3.2 Print message at exception handling case
      stdout.writeln('${DateTime.now()}: Exception in handleRequest: $e');
    }
  }
}

Secure Connection using HTTPS

HttpServer 클래스는 암호화된 보안 통신을 지원하는 HTTPS (Hyper Text Transfer Protocol with Secure Sockets Layer) 통신을 지원합니다. 이를 위해서 bindSecure() 메소드를 지원합니다. 아래의 예제를 보면 기존 bind() 메소드와의 차이점으로 serverConect에 보안 정보가 포함되어야 한다는 점 입니다.
Certificate Authority (CA)라고 불리우는 정보로서, 이는 서버를 운영하는 개발자가 직접 보안 정보를 획득해야 합니다. 이에 대한 자세한 사항은 What is SSL and What are certificates [참조]를 참조하기 바랍니다.

var server = await HttpServer.bindSecure(
    'localhost',
    4047,
    serverContext,
  );

Dart 공식 사이트에서 HTTPS를 지원하는 예제 프로그램이 다음과 같이 등록되어 있습니다. 보안과 관련한 정보를 담은 pem 화일을 개발자가 생성한 후, 이를 토대로 bindSecure()를 실행하는 예제이니 참조하기 바랍니다.

// Source: https://dart.dev/tutorials/server/httpserver#using-https

import 'dart:io';

String certificateChain = 'server_chain.pem';
String serverKey = 'server_key.pem';

Future main() async {
  var serverContext = SecurityContext(); /*1*/
  serverContext.useCertificateChain(certificateChain); /*2*/
  serverContext.usePrivateKey(serverKey, password: 'dartdart'); /*3*/

  var server = await HttpServer.bindSecure(
    'localhost',
    4047,
    serverContext, /*4*/
  );
  print('Listening on localhost:${server.port}');
  await for (HttpRequest request in server) {
    request.response.write('Hello, world!');
    await request.response.close();
  }
}

마무리

뭔가 이상합니다. POST Request로 JSON 형태의 자료를 받아서, 화일로 저장도 하고 Dart 언어의 Map 자료 형태로 변경도 해주는 서버 프로그램을 개발했지만, 이에 대한 실행 동작은 보이지 않았습니다. 이 프로그램을 수행하려면 HTTP Client가 HTTP POST Request 메세지를 HTTP Server에게 전달 할 수 있어야 합니다. 다음 글에서 darttutorial-28-01.dart 프로그램과 대화를 나눌 HTTP POST Client 프로그램을 만들어 보도록 하겠습니다.

Creative Commons License (CC BY-NC-ND)

댓글 남기기

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다