서비스 플러그인: 처음부터 Elixir HTTP 서버 구축

35738 단어 elixirprogramming
다른 버전의 불로약 연금술로 돌아온 것을 환영합니다!우리가 엔진 덮개 아래에서 무슨 일이 일어났는지 계속 탐색하는 과정에서 우리는 Elixir의 HTTP 서버를 깊이 연구할 것이다.
HTTP 서버가 어떻게 작동하는지 이해하기 위해 플러그인 프로그램을 실행할 수 있는 예시를 실현할 것입니다.우리는 디코딩 요청과 인코딩 응답, 그리고 HTTP 요청을 받아들여 플러그인 프로그램을 실행할 수 있는 HTTP 서버의 최소 서브집합을 구축하여 플러그인이 웹 서버와 어떻게 상호작용하는지 배울 것입니다.
-> 교육 목적으로 구축된 HTTP 서버입니다.우리는 생산이 준비된 HTTP 서버를 구축하지 않을 것이다.이러한 서버를 찾고 있다면 cowboy을 시도하십시오. 이것은 Elixir 응용 프로그램의 HTTP 서버의 기본 선택입니다.

TCP의 HTTP


우리는 기본 원리부터 시작할 것이다.HTTP는 일반적으로 TCP을 사용하여 HTTP 클라이언트(예를 들어 웹 브라우저와 웹 서버) 사이에서 요청과 응답을 전송하는 프로토콜이다.
Erlang은 데이터를 수신하고 전송하는 TCP 소켓을 시작하는 데 사용할 수 있는 :gen_tcp 모듈을 제공합니다.우리는 이 라이브러리를 서버의 기초로 사용할 것이다.
# lib/http.ex
defmodule Http do
  require Logger

  def start_link(port: port) do
    {:ok, socket} = :gen_tcp.listen(port, active: false, packet: :http_bin, reuseaddr: true)
    Logger.info("Accepting connections on port #{port}")

    {:ok, spawn_link(Http, :accept, [socket])}
  end

  def accept(socket) do
    {:ok, request} = :gen_tcp.accept(socket)

    spawn(fn ->
      body = "Hello world! The time is #{Time.to_string(Time.utc_now())}"

      response = """
      HTTP/1.1 200\r
      Content-Type: text/html\r
      Content-Length: #{byte_size(body)}\r
      \r
      #{body}
      """

      send_response(request, response)
    end)

    accept(socket)
  end

  def send_response(socket, response) do
    :gen_tcp.send(socket, response)
    :gen_tcp.close(socket)
  end

  def child_spec(opts) do
    %{id: Http, start: {Http, :start_link, [opts]}}
  end
end
이 예에서는 HTTP 응답의 현재 시간으로 각 요청에 응답하는 TCP 서버를 만들었습니다.start_link/1에 전송된 포트를 탐색하기 위해 플러그인을 시작합니다. 새 프로세스에서 accept/1을 생성합니다. 이 프로세스는 :gen_tcp.accept/1을 호출하여 플러그인을 통해 요청을 기다립니다.
이 변수가 나타나면 request 변수에 넣고 클라이언트에게 응답을 만듭니다.이 경우, 현재 시간을 표시하는 응답을 보낼 것입니다.

HTTP 응답 생성

HTTP/1.1 200\r\nContent-Type: text/html\r\n\r\n\Hello world! The time is 14:45:49.058045

An HTTP response contains a couple of parts:

  • A status line, with the protocol version (HTTP/1.1) and a response code (200)
  • A carriage return, followed by a line feed (\r\n) to split the status line from the rest of the response
  • (Optional) header lines (Content-Type: text/html), separated by CRLFs
  • A double CRLF, to separate the headers from the response body
  • The response body that will be shown in the browser (Hello world! The time is 14:45:49.058045)

우리는 구축된 주체를 send_response/2에 전달할 것이다. 이것은 플러그인을 통해 응답을 보내고 최종적으로 플러그인 연결을 닫는 것을 책임진다.
서버가 accept/1을 다시 호출하여 새 요청을 받아들일 수 있도록 모든 요청을 위한 프로세스를 생성합니다.이렇게 하면 우리는 후속 요청이 처리될 때까지 기다려야 하는 것이 아니라 요청에 병행해서 응답할 수 있다.

서버 실행


한번 해볼게요.장애가 발생했을 때 즉시 재부팅할 수 있도록 supervisor을 사용하여 HTTP 서버를 실행합니다.
우리 서버는 child_spec/1 함수를 실현하여 그것을 시작하는 방법을 지정합니다.이 함수는 전송된 옵션을 사용하여 Http.start/1 함수를 호출해야 하며, 이 함수는 새로 생성된 프로세스 ID를 되돌려줍니다.
# lib/http.ex
defmodule Http do
  # ...

  def child_spec(opts) do
    %{id: Http, start: {Http, :start_link, [opts]}}
  end
end
따라서 lib/http/application.ex에서 관리를 주관하는 어린이 목록에 추가할 수 있습니다.
# lib/http/application.ex
defmodule Http.Application do
  @moduledoc false
  use Application

  def start(_type, _args) do
    children = [
      {Http, port: 8080}
    ]

    opts = [strategy: :one_for_one, name: Http.Supervisor]
    Supervisor.start_link(children, opts)
  end
end
응용 프로그램이 시작될 때, 우리는 {Http, port: 8080}을 주관하는 한 아이를 8080 포트 시작 서버로 전달한다.
$ mix run --no-halt
19:39:29.454 [info]  Accepting connections on port 8080
만약 우리가 서버를 시작하고 브라우저를 사용하여 요청을 보낸다면, 이것은 확실히 현재 시간을 되돌려 주는 것을 볼 수 있습니다.

플러그


현재 우리는 웹 서버의 작업 원리를 알고 있으며, 이를 다음 단계로 끌어올릴 것이다.우리의 현재 구현은 응답을 서버에 하드코딩합니다.웹 서버가 서로 다른 응용 프로그램을 실행할 수 있도록 우리는 응용 프로그램을 단독 Plug 모듈로 옮길 것입니다.
# lib/current_time.ex
defmodule CurrentTime do
  import Plug.Conn

  def init(options), do: options

  def call(conn, _opts) do
    conn
    |> put_resp_content_type("text/html")
    |> send_resp(200, "Hello world! The time is #{Time.to_string(Time.utc_now())}")
  end
end
CurrentTime 모듈은 call/2 함수를 정의하였으며, 이 함수는 전입된 %Plug.Conn 구조를 채택하였다.그리고 응답 내용 형식을 "text/html"로 설정하고 "Hello world!"를 보냅니다.메시지와 되돌아오는 현재 시간을 응답으로 합니다.
우리의 새 모듈의 행위는 웹 서버의 예시와 같지만, 웹 서버와 분리됩니다.플러그인 때문에, 우리는 응용 프로그램 코드를 변경하지 않는 상황에서 서버를 교체할 수도 있고, 웹 서버에 접촉하지 않는 상황에서 응용 프로그램을 변경할 수도 있다.

플러그 어댑터 쓰기


웹 서버가 웹 응용 프로그램과 통신할 수 있도록 %Plug.Conn{} 구조를 구축하여 CurrentTime.call/2에 전달해야 합니다.웹 서버는 플러그인을 통해 응답을 문자열로 변환해야 합니다.
이를 위해, 플러그인 프로그램과 웹 서버 간의 통신을 처리하는 어댑터를 만들 것입니다.
# lib/http/adapter.ex
defmodule Http.PlugAdapter do
  def dispatch(request, plug) do
    %Plug.Conn{
      adapter: {Http.PlugAdapter, request},
      owner: self()
    }
    |> plug.call([])
  end

  def send_resp(socket, status, headers, body) do
    response = "HTTP/1.1 #{status}\r\n#{headers(headers)}\r\n#{body}"

    Http.send_response(socket, response)
    {:ok, nil, socket}
  end

  def child_spec(plug: plug, port: port) do
    Http.child_spec(port: port, dispatch: &dispatch(&1, plug))
  end

  defp headers(headers) do
    Enum.reduce(headers, "", fn {key, value}, acc ->
      acc <> key <> ": " <> value <> "\n\r"
    end)
  end
end
우리는 어댑터의 Http.accept/2 함수를 사용하여 dispatch/2 구조를 구축하고 이를 플러그인의 %Plug.Conn{} 함수에 전달하며 call/2에서 직접 응답하지 않습니다.%Plug.Conn{}에서 우리는 :adapter을 어댑터 모듈에 연결한 다음에 보내야 할 응답을 플러그인에 전달합니다.이것은 플러그인 응용 프로그램이 어느 모듈에서 send_resp/4을 호출할지 알 수 있도록 합니다.
저희 어댑터 send_resp/4은 플러그인 응용 프로그램에서 준비한 플러그인 연결, 응답 상태, 헤더 목록과 주체를 받아들입니다.이것은 전송된 매개 변수를 사용하여 응답을 구축하고 우리가 이전에 실현한 Http.send_response/2을 호출합니다.
어댑터의 child_spec/1child_spec/1 모듈의 Http을 되돌려줍니다.이것은 우리가 어댑터를 감시할 때 웹 서버를 시작하게 할 것이다.웹 서버가 응답을 받을 때 호출할 수 있도록 디스패치 함수를 디스패치로 전달할 것입니다.
# lib/http/application.ex
defmodule Http.Application do
  @moduledoc false
  use Application

  def start(_type, _args) do
    children = [
      {Http.PlugAdapter, plug: CurrentTime, port: 8080}
    ]

    opts = [strategy: :one_for_one, name: Http.Supervisor]
    Supervisor.start_link(children, opts)
  end
end
응용 프로그램 모듈에서 Http을 시작하는 것이 아니라 Http.PlugAdapter을 시작합니다. 플러그인을 설정하고 스케줄링 함수를 준비하며 웹 서버를 시작합니다.
# lib/http.ex
defmodule Http do
  require Logger

  def start_link(port: port, dispatch: dispatch) do
    {:ok, socket} = :gen_tcp.listen(port, active: false, packet: :http_bin, reuseaddr: true)
    Logger.info("Accepting connections on port #{port}")

    {:ok, spawn_link(Http, :accept, [socket, dispatch])}
  end

  def accept(socket, dispatch) do
    {:ok, request} = :gen_tcp.accept(socket)

    spawn(fn ->
      dispatch.(request)
    end)

    accept(socket, dispatch)
  end

  # ...
end
현재 플러그인에서 요청을 처리하고 있기 때문에, Http.accept/2의 대부분의 코드를 삭제할 수 있습니다.Http.start_link/2 함수는 현재 어댑터에서 스케줄링 함수를 수신하여 요청을 Http.accept/2으로 보내는 데 사용됩니다.
$ mix run --no-halt
19:39:29.454 [info]  Accepting connections on port 8080
서버를 다시 실행한 후에도 모든 것이 예전과 똑같다.그러나 HTTP 서버, 웹 응용 프로그램, 플러그인 어댑터는 현재 세 개의 독립된 모듈입니다.

스왑 플러그인 응용 프로그램


우리 서버는 현재 어댑터와 웹 응용 프로그램과 분리되어 있기 때문에, 서버에서 다른 응용 프로그램을 실행하기 위해 플러그인을 교환할 수 있습니다.한번 해볼게요.
# mix.exs
defmodule Http.MixProject do
  # ...

  defp deps do
    [
      {:plug_octopus, github: "jeffkreeftmeijer/plug_octopus"},
      {:plug, "~> 1.7"}
    ]
  end
end
mix.exs 파일에 :plug_octopus을 의존항으로 추가합니다.
# lib/http/application.ex
defmodule Http.Application do
  @moduledoc false
  use Application

  def start(_type, _args) do
    children = [
      {Http.PlugAdapter, plug: Plug.Octopus, port: 8080}
    ]

    opts = [strategy: :one_for_one, name: Http.Supervisor]
    Supervisor.start_link(children, opts)
  end
end
그리고 우리는 CurrentTime 모듈에서 Plug.Octopus으로 Http.Application을 교체했다.서버를 시작하고 http://localhost:8080에 액세스하여 문어를 표시합니다!

단, 뒤집기 클릭!그리고 멘붕!버튼이 작동하지 않습니다. 호출된 URL은 같은 페이지를 표시합니다.이것은 우리가 요청에 대한 해석을 완전히 소홀히 했기 때문이다.우리는 요청을 읽지 않기 때문에, 항상 같은 응답을 전달합니다.우리가 이 문제를 해결합시다.

요청 분석 중


요청을 읽으려면 플러그인에서 응답을 읽어야 합니다.http_bin을 호출할 때 전달된 :gen_tcp.listen/2 옵션 덕분에 패턴이 일치하는 형식으로 되돌아갈 수 있습니다.
# lib/http.ex
defmodule Http do
  # ...

  def read_request(request, acc \\ %{headers: []}) do
    case :gen_tcp.recv(request, 0) do
      {:ok, {:http_request, :GET, {:abs_path, full_path}, _}} ->
        read_request(request, Map.put(acc, :full_path, full_path))

      {:ok, :http_eoh} ->
        acc

      {:ok, {:http_header, _, key, _, value}} ->
        read_request(
          request,
          Map.put(acc, :headers, [{String.downcase(to_string(key)), value} | acc.headers])
        )

      {:ok, line} ->
        read_request(request, acc)
    end
  end

  # ...
end
Http.read_request/2 함수는 플러그인 연결을 사용하고 스케줄링 함수에서 호출됩니다.요청 헤더가 끝날 때까지 :gen_tcp.recv/2을 호출합니다.
전체 요청 경로를 포함하여 :http_eoh줄에서 일치합니다.나중에 경로와 URL 매개 변수를 추출하는 데 사용할 것입니다.모든 :http_request줄과 일치합니다. 목록으로 변환해서 나중에 플러그인 프로그램에 전달할 수 있습니다.
# lib/http/adapter.ex
defmodule Http.PlugAdapter do
  def dispatch(request, plug) do
    %{full_path: full_path} = Http.read_request(request)

    %Plug.Conn{
      adapter: {Http.PlugAdapter, request},
      owner: self(),
      path_info: path_info(full_path),
      query_string: query_string(full_path)
    }
    |> plug.call([])
  end

  # ...

  defp headers(headers) do
    Enum.reduce(headers, "", fn {key, value}, acc ->
      acc <> key <> ": " <> value <> "\n\r"
    end)
  end

  defp path_info(full_path) do
    [path | _] = String.split(full_path, "?")
    path |> String.split("/") |> Enum.reject(&(&1 == ""))
  end

  defp query_string([_]), do: ""
  defp query_string([_, query_string]), do: query_string

  defp query_string(full_path) do
    full_path
    |> String.split("?")
    |> query_string
  end
end
우리는 :http_header에서 Http.read_request/1으로 전화를 걸었다.Http.PlugAdapter.dispatch/2이 있으면 full_path(경로 세그먼트 목록)과 path_info(모든 URL 매개 변수는 "?"뒤에 있음)을 추출할 수 있습니다.플러그인 응용 프로그램이 나머지 부분을 처리할 수 있도록 query_string에 추가합니다.
서버를 다시 시작하면 랍스터를 뒤집고 붕괴시킬 수 있습니다.

반전과 충돌이 발생한 최소 웹 서버의 예


모두 자리에 앉았습니다. 바닥에 나사못이 없습니다. 우리는 Elixir에서 HTTP 서버를 연구하는 프로젝트를 완성했습니다.요청에서 데이터를 추출하고 플러그인 프로그램에 요청을 보내는 웹 서버를 구현했습니다.그것은 심지어 합병성까지 포함한다. 모든 요청이 하나의 단독 프로세스를 생성하기 때문에, 우리의 웹 서버는 여러 개의 합병 사용자를 처리할 수 있다.
HTTP 서버는 본고에서 소개한 것보다 내용이 많지만, 처음부터 HTTP 서버를 실현하면 웹 서버의 작업 방식을 이해할 수 있기를 바랍니다.
코드를 보고 싶으면 the finished project을 보세요. 불로장생약 연금술을 더 읽고 싶으면 subscribe to the mailing list을 잊지 마세요!

좋은 웹페이지 즐겨찾기