본문 바로가기

UCLA CS 130: SWE Capstone project

Assignment 4 中 - NginX config에 경로 매칭하는 함수 만들기 + chore

이번 주 과제의 미션 내용을 보면,

 

1 서버에 로깅을 추가하기 - Boost.log를 활용함.

2 서버가 현재의 에코잉을 넘어서, 정적 파일(HTML 등)을 사용자에게 제공할 수 있는 기능을 추가하기.

 

팀 미팅 결과 내 업무는 2번째, Serve static files from your server의 일부 당첨되었다. 4가지로 분할해보면,

    • 1. Add methods on NginxConfig to extract out the web server path directives.
    • -> NginxConfig에서 웹 서버 경로 지시문을 추출하는 메서드 추가: 이 부분은 NginxConfig 클래스에 새로운 메서드를 추가하여 웹 서버의 경로 지시문을 추출하는 것입니다. 이는 웹 서버 설정 파일에서 지정된 경로를 파싱하여 추출하는 과정을 의미합니다.
    • 2. Implement FileRequestHandler: serve files using file paths relative to the web server base path (or return 404 if they’re not found).
    • -> FileRequestHandler 구현: FileRequestHandler는 파일 요청을 처리하는 클래스입니다. 웹 서버의 기본 경로를 기준으로 상대적인 파일 경로를 사용하여 파일을 제공하거나, 파일이 없으면 404를 반환해야 합니다.
    • 3. Make sure the container stays up to date: every time you add a static file that can be server e.g. test.txt → add another COPY statement to the Dockerfile to ensure that file exists in the container when built.
    • -> 컨테이너가 최신 상태를 유지하는 것: 이 부분은 정적 파일을 추가할 때마다 Dockerfile에 COPY 문을 추가하여 컨테이너가 정적 파일이 포함된 상태로 유지되도록 하는 것입니다. 이는 파일이 추가되면 Docker 이미지가 다시 빌드될 때 해당 파일이 컨테이너에 포함되어 있어야 함을 의미합니다.
    • 4. Parse file extension to determine appropriate MIME type (extend the mime_type namespace under constants.h).
    • -> 파일 확장자를 구문 분석하여 적절한 MIME 타입을 결정: 이 부분은 파일의 확장자를 분석하여 해당하는 MIME 타입을 결정하는 것을 의미합니다. 이는 constants.h 파일 내에 있는 mime_type 네임스페이스를 확장하여 구현할 수 있습니다.
// cs130 - [protocol42, tools]
// protocol42에 들어가서
../tools/env/start.sh -u "$USER" -r

// 도커 환경 내부에 들어옴
cd protocol42
// 프로젝트 디렉터리에 들어와서, git pull을 통해 현재까지의 내용 최신화해줌.

ls
// build  cmake  CMakeLists.txt  docker  include  integration_tests  my_config  scripts  src  unit_tests

// 지금까지의 상황에서는 문제가 없었는지 확인하기 위해서 
scripts/test.sh
// 100% 뜸!

// 이제 내가 건드려야 할 파일들이 있는 src로 들어가서
cd src
// 기여할 준비가 완료됨.

 

 

그러나 4월 27일 새벽 1시 현재, 아직 이전 코드에 대한 리팩터링이 끝나지 않아 2번부터는 바통터치가 어려움.

그래서 1번만 진행함.

 

 

1 NginxConfig에서 웹 서버 경로 지시문을 추출하는 메서드 추가

 

우선 NginxConfig class가 어디있는지 확인을 해봤는데, include 내부의 config_parser.h에 일부 있고, src의 config_parser.cc에 몸통이 있었음. 이 두개를 수정해주는 작업을 진행함.

 

config_parser.cc에 GetWebServerPath 함수를 만들어주고 config_parser.h에 포함시켜줌. 코드는 다음과 같음.

std::string NginxConfig::GetWebServerPath() const
{
    for (const auto &statement : statements_)
    {
        // 'location' 지시문을 찾음
        if (statement->tokens_[0] == "location")
        {
            // 해당 지시문 내부에서 'root' 값을 찾음
            for (const auto &sub_statement : statement->child_block_->statements_)
            {
                // 'root' 값을 찾았을 경우 반환
                if (sub_statement->tokens_[0] == "root")
                {
                    return sub_statement->tokens_[1];
                }
            }
        }
    }
    // 'location' 지시문이나 'root' 값을 찾지 못한 경우 빈 문자열 반환
    return "";
}

 

그리고 나서 이 코드를 검증할 수 있는 unit test를 하나 생성해서 테스트를 돌려줌.

 

그래서 config_parser.cc 파일을 stack을 활용해서 재귀적으로 모든 디렉토리를 traverse하게끔 바꿔줬다.

std::string NginxConfig::GetWebServerPath() const
{
    std::stack<const NginxConfig *> config_stack;
    config_stack.push(this);

    while (!config_stack.empty())
    {
        const NginxConfig *config = config_stack.top();
        config_stack.pop();

        for (const auto &statement : config->statements_)
        {
            if (statement->tokens_[0] == "location")
            {
                for (const auto &sub_statement : statement->child_block_->statements_)
                {
                    if (sub_statement->tokens_.size() >= 2 && sub_statement->tokens_[0] == "root")
                    {
                        return sub_statement->tokens_[1];
                    }
                }
            }
            else if (statement->child_block_ != nullptr)
            {
                config_stack.push(statement->child_block_.get());
            }
        }
    }
    
    return "";
}

 

1번 미션 완료!

 

다 끝내고 보니 지금까지의 코드 변경을 어느 다른 브랜치로 옮겨서 진행했어야 했다. 아무것도 add도 안한 상태였으므로,

git checkout -b 'new branch'
// 이렇게 브랜치 이동해주고

git add .
git commit -m '...'
// 해주고

// git push 대신 
git review --reviewers @id
// 를 통해 github에서의 push를 시전해주었다

 

...

 

이렇게 다 끝내주었는 줄 알았는데, 팀 미팅을 하면서 TL이 의문을 제기했다.

 

껄껄,,, 그러니까 NginX config 특성상 location이 여러개일 수 있다는 것이다. 그러면 지금의 코드에서는 뭐든 간에 location 하나만 걸리면 일단 그 안에 root 있는지 확인하고 거기서 값을 추출해서 반환 시켜버렸는데, 그게 아닐 확률이 생긴 거였다. 그래서 다시 지문을 읽어보니,

 

...
You should maintain the “echo” functionality in your server, which should be demonstrable for some paths. While you are probably currently echoing requests to every path, so you will now have to handle requests for some paths differently from others. This means you should handle requests differently based on the path being requested. You should develop the concept of a request handler that can be given a request object, and generate a response. It’s up to you to define the API for this, and in this assignment you should end up with two implementations of a common request handler interface, one for handling echo requests, and one for handling file requests. Your two request handlers should inherit from a common base class, defined in a separate header file.
Your server should be made configurable, to serve static files on some path(s) (e.g. /static), and serve echo responses on some path(s) (e.g. /echo).
Your static file handler should serve files from a configurable base directory. For example, if you have a base filesystem directory of /foo/bar and define /static as a path for static serving, then the URL http://host:port/static/somefile.html would serve /foo/bar/somefile.html to the user.
Files served should display correctly for common file extensions. For example, .html should show up in the browser as a website, and .jpg should display as an image. To do this, you need to set the Content-Type HTTP response header properly. Add several common MIME-types that you think would be useful, including at least .zip and .txt extensions.
If a file isn’t present, the static file request handler should return a 404 response code.
Anything configurable should be specified in your config file. Your configuration should be flexible enough to allow the definition of multiple servlets/request handlers of the same type. For example, you could have http://host:port/static1 and http://host:port/static2 serve files from different directories, since each request path could be configured with a static file request handler serving from different base directories.
...

 

원어민인 팀원들 사이에서도 이 부분에 대해 의견이 분분했다. echo를 하고 싶으면 꼭 /echo라는 URI를 location의 뒤에 붙이라는 건지, 그리고 본문에서 echo가 아닌 정적 파일을 응답하는 예시로는 /static을 들었는데 그 아래에는 /static1, /static2 등이 등장해도 작동해야 한다는 걸 보니 그냥 echo가 아닌 모든 URI에 대해 정적파일 핸들러를 작동시키라는 건지 등에 대해 이견이 있다.

 

이 과정에서 NginX config에 대해 알게된 게 몇 가지 있는데, 정리하자면 다음과 같다.

  • 하나의 server 내에 여러 개의 location을 가질 수가 있다.
  • location 뒤에 따라오는 이름이 URI, 즉 클라이언트가 보내는 url에 포함되는 글자의 일부이고, 해당 글이 등장하면 해당 location의 내부에 있는 root의 경로에서 해당 파일을 찾는 방식으로 구성되어 있다는 것이다. 

       즉, 예를 들어 클라이언트가 http://host:port/foo/somefile.html 이런 요청(url)을 보냈다면 서버는,

       /var/www/something 디렉토리로 들어가서 somefile.html 파일을 찾아서 보내주면 된다는 것이다.

  • Nginx는 실제로 url에서 location 이름을 가져와서 config에서 비교할 때, 만약 location 이름으로 시작하는 이름이 여러 개라면 그 중 가장 긴 것을 고른다고 한다. 이걸 구현하면 플러스 알파겠지만, 일단 과제에서 요구하는 것 같지는 않음.

그런데 우리 과제에서는, location 뒤에 어떤 방식으로든 (예를 들어, /echo 든 뭐든, 확실하지 않았음) echo 기능을 요구하면 그에 맞게 echo 해주는 기능을 추가해야 했다. echo가 아니라면 위에 말한대로 해당 디렉토리 가서 파일 찾아주면 되었다. 다시 그러면 내가 해야할 일은 이렇게 나눌 수 있었다.

 

  1. 클라이언트가 보내는 url을 어디서 파싱하는지 알아내기. 또 url을 분해할 때, 각각의 파트를 어떻게 나눌지 고민하기.
  2. config_parser를 수정해서, 모든 location 이름에 대해 root 디렉토리를 매핑하여 map이든 어디든 집어넣어두기.
  3. 1에서 얻어낸 location 이름을 만들어 둔 map에 넣고 없다면 404, 있으면 해당 디렉토리 가서 파일 찾아보고(* 이 과정에서 NginX의 공식 문서에 따라 prefix-matching 기법을 사용하면 더욱 좋지만 일단 하지 말자) 없으면 또 다시 404, 만약 파일까지 찾았으면 그걸 response 해주기.
  4. 각각의 코드를 테스트할 수 있는 테스트 함수 및 파일 만들어주기.
  5. 테스트 커버리지 100% 맞추고 끝내기.

 

팀원의 코드가 여지껏 approve를 못 받고 merge conflict를 내서, 제출 시간 5시간 남겨두고 내가 바통을 넘겨 받게 되었다.

 

우선은 기존의 getWebServerPaths 함수를 GetLocationPaths 함수로 바꾸면서, 한 개가 아닌 여러 개의 location을 모두 다루고, unordered map을 써서 각 location의 이름을 directory의 주소와 매칭시켜주었다. 그에 맞게 cofnig_parser.cc 고쳐주었고, 이름이 바뀌었으니 config_parser.h도 고쳐주었다.

 

std::unordered_map<std::string, std::string> NginxConfig::GetLocationPaths() const
{
    std::unordered_map<std::string, std::string> paths;
    std::stack<const NginxConfig *> config_stack;
    config_stack.push(this);

    while (!config_stack.empty())
    {
        const NginxConfig *config = config_stack.top();
        config_stack.pop();

        for (const auto &statement : config->statements_)
        {
            if (statement->tokens_.size() > 0 && statement->tokens_[0] == nginx_directive::LOCATION)
            {
                std::string location_name = statement->tokens_.size() > 1 ? statement->tokens_[1] : "default";

                for (const auto &sub_statement : statement->child_block_->statements_)
                {
                    if (sub_statement->tokens_.size() >= 2 &&
                        sub_statement->tokens_[0] == nginx_directive::ROOT)
                    {
                        paths[location_name] = sub_statement->tokens_[1];
                        break;
                    }
                }
            }
            else if (statement->child_block_ != nullptr)
            {
                config_stack.push(statement->child_block_.get());
            }
        }
    }

    return paths;
}

 

그리고 테스트케이스도 쌈빡하게 바꿔서 다시 push 했는데, 그러다가 엣지 케이스를 생각 못한 것을 발견했다.

위에 default를 쓴 줄에서, 그게 함수 유닛 상으로 에러가 나지 않게 방지하는 용이었는데, 만약 이름이 없는 location이 있다면 그걸 처리할 수 없기 때문이다. 유닛 테스트 통과하려다 외려 프로그램에 큰 오점을 남길 뻔 했다..

 

그래서 다시 한번 코드를 고쳐주었다. bool을 반환하게 해서 valid 여부를 판가름하고, unordered map을 참조함수로 받아서 문제없게끔 만들어 주었다.

 

bool NginxConfigParser::Parse(const char *file_name, NginxConfig *config)
{
    std::ifstream config_file;
    config_file.open(file_name);
    if (!config_file.good())
    {
        printf("Failed to open config file: %s\n", file_name);
        return false;
    }

    // Empty file case check (placed after checking if file can be opened to
    // avoid unexpected behavior).
    if (config_file.peek() == std::ifstream::traits_type::eof())
    {
        return true;
    }

    const bool return_value = Parse(dynamic_cast<std::istream *>(&config_file),
                                    config);
    config_file.close();

    return return_value;
}

bool NginxConfig::GetLocationPaths(std::unordered_map<std::string, std::string>& out_map) const
{
    std::stack<const NginxConfig*> config_stack;
    config_stack.push(this);
    bool valid_config = true;

    while (!config_stack.empty()) {
        const NginxConfig* config = config_stack.top();
        config_stack.pop();

        for (const auto& statement : config->statements_) {
            if (statement->tokens_.size() > 1 && statement->tokens_[0] == nginx_directive::LOCATION) {
                std::string location_name = statement->tokens_[1];

                bool found_root = false;
                for (const auto& sub_statement : statement->child_block_->statements_) {
                    if (sub_statement->tokens_.size() >= 2 && sub_statement->tokens_[0] == nginx_directive::ROOT) {
                        out_map[location_name] = sub_statement->tokens_[1];
                        found_root = true;
                        break;
                    }
                }
                if (!found_root) {
                    valid_config = false; // if no root directory
                }
            }
            else if (statement->tokens_.size() > 0 && statement->tokens_[0] == nginx_directive::LOCATION) {
                // if no location name
                valid_config = false;
            }
            else if (statement->child_block_ != nullptr) {
                config_stack.push(statement->child_block_.get());
            }
        }
    }

    return valid_config;
}

 

그리고 그에 테스트코드도 작성해주었다.

TEST_F(NginxConfigParserUnitTest, ValidConfigPort)
{
    TestPort("port_config", 8080);
}

TEST_F(NginxConfigParserUnitTest, ValidConfigPortWithOtherDirectives)
{
    TestPort("port_with_other_directives_config", 8080);
}

// Tests for GetLocationPaths function to ensure it properly parses and validates location blocks.
TEST_F(NginxConfigParserUnitTest, GetLocationPathsValidConfig)
{
    std::string config_text = R"(
        server {
            location /example {
                root /data/example;
            }
            location /example2 {
                root /data/example2;
            }
        }
    )";
    std::istringstream config_stream(config_text);
    NginxConfig out_config;
    parser_.Parse(&config_stream, &out_config);

    std::unordered_map<std::string, std::string> location_paths;
    bool result = out_config.GetLocationPaths(location_paths);

    ASSERT_TRUE(result);
    ASSERT_EQ(location_paths.size(), 2);
    EXPECT_EQ(location_paths["/example"], "/data/example");
    EXPECT_EQ(location_paths["/example2"], "/data/example2");
}

TEST_F(NginxConfigParserUnitTest, GetLocationPathsInvalidConfigMissingRoot)
{
    std::string config_text = R"(
        server {
            location /example {
            }
        }
    )";
    std::istringstream config_stream(config_text);
    NginxConfig out_config;
    bool success = parser_.Parse(&config_stream, &out_config);
    ASSERT_TRUE(success);

    std::unordered_map<std::string, std::string> location_paths;
    bool result = out_config.GetLocationPaths(location_paths);

    EXPECT_FALSE(result);
    EXPECT_TRUE(location_paths.empty());
}

TEST_F(NginxConfigParserUnitTest, GetLocationPathsInvalidConfigMissingLocationName)
{
    std::string config_text = R"(
        server {
            location {
                root /data/anonymous;
            }
        }
    )";
    std::istringstream config_stream(config_text);
    NginxConfig out_config;
    parser_.Parse(&config_stream, &out_config);
    bool success = parser_.Parse(&config_stream, &out_config);
    ASSERT_TRUE(success);

    std::unordered_map<std::string, std::string> location_paths;
    bool result = out_config.GetLocationPaths(location_paths);

    EXPECT_FALSE(result);
    EXPECT_TRUE(location_paths.empty());
}

 

이 작업을 하는 데에 3시간 여 걸렸다.

 

아 그리고, 처음 push를 한 이후에 이에 대해서 iteration하려면, 아래의 과정을 거쳐야 한다.

git review -l
git review -d <ID>

// 고치고

git add .
git commit --amend --no-edit
git review -f

 

유념하자.


일단 여기까지 완료했고, 아직까지 못다한 부분은 우리에게 주어진 late hours를 써가면서까지 완료하기로 합의했다.

이 추가 작업 동안 내가 맡은 부분은:

이 부분인데, new code 중에서도 request_handler.cc 와 echo_request_handler.cc, file_request_handler.cc에 집중하기로 했다.

 

일단 일련의 과정을 진행하기 전에 지금의 테스트 케이스들이 현재 우리 코드를 어느정도 커버하는지 확인해 봤다. 방법은:

$ mkdir build_coverage
$ cd build_coverage
$ cmake -DCMAKE_BUILD_TYPE=Coverage ..
$ make coverage

// /build_coverage/report/index.html

 

상당히 커버리지가 저조한데, 이 커버리지를 올리기 위해 어떤 일을 해야하는지 약간 의문이다. 좀 알아보자.

 

1 request_handler.cc

#include "request_handler.h"
#include <boost/beast/http.hpp>
#include <regex>
#include <string>
#include <unordered_map>
#include <vector>

#include "constants.h"
#include "uri_path.h"

using request_t = boost::beast::http::request<boost::beast::http::string_body>;
using response_t =
    boost::beast::http::response<boost::beast::http::string_body>;

RequestHandler::RequestHandler(
    const request_constants::RequestType handler_type,
    const std::unordered_map<std::string, std::string> &accepted_paths)
{
  this->handler_type_ = handler_type;
  this->accepted_paths_ = accepted_paths;
}

request_constants::RequestType
RequestHandler::GetRequestType(const request_t &client_request)
{
  std::string client_request_prefix =
      URIPath(client_request.target().to_string()).GetPrefix();
  std::regex uri_regex(R"(/(echo|static)([^/]+)?)");
  std::smatch match;
  if (std::regex_search(client_request_prefix, match, uri_regex))
  {
    if (match[1].matched && match[1].str() == "echo")
    {
      return request_constants::RequestType::REQUEST_ECHO;
    }
    else if (match[1].matched && match[1].str() == "static")
    {
      return request_constants::RequestType::REQUEST_STATIC_FILE;
    }
    else
    {
      return request_constants::RequestType::REQUEST_TYPE_ERROR;
    }
  }
  return request_constants::RequestType::REQUEST_TYPE_ERROR;
}

bool RequestHandler::IsPathAccepted(const request_t &client_request)
{
  std::string client_request_prefix =
      URIPath(client_request.target().to_string()).GetPrefix();
  return (this->accepted_paths_.find(client_request_prefix) !=
          this->accepted_paths_.end());
}

 

우선 이 부분은, echo 및 file request handler의 부모 클래스다. 들어온 요청이 우리의 규약을 따르는지, 만약 따른다면 1 에코를 할지 2 그에 맞는 파일을 전달을 할지 정하고, 만약 안 따른다면 에러 메시지를 보내는 결정을 한다.

 

/static1/resource, /echo1/resource 등의 옳은 요청에 대해, 그리고 /api/resource 혹은

'' 등의 invalid 요청에 대해 잘 반응하는지 확인하는 테스트케이스를 추가해 주었다.

 

2 FileRequestHandler

그리고, 기존의 file request handler에 대한 테스트는 다음을 추가해 줬다.

// svg mime test
TEST_F(FileRequestHandlerTest, GetRequestMimeType_SVG) {
    std::string uri_path_str = "/static/example.svg";
    ArrangeBasicGetRequest(uri_path_str);
    EXPECT_EQ(handler_.GetRequestMimeType(request_), MimeType::IMAGE_SVG);
}

// zip mime test
TEST_F(FileRequestHandlerTest, GetRequestMimeType_ZIP) {
    std::string uri_path_str = "/static/example.zip";
    ArrangeBasicGetRequest(uri_path_str);
    EXPECT_EQ(handler_.GetRequestMimeType(request_), MimeType::APPLICATION_ZIP);
}

 

zip 파일에 대해서는 우리의 constant에 설정이 되어 있지 않아서 그것 또한 설정해주었다.


결론적으로 git branch가 꼬여서 시간이 많이 허비됐다. 팀원들에게 미안하다. 하나하나의 커밋에 더욱 신중을 가하자.

일단 임무 완수!