#elixirlang Elixir-Slack 라이브러리가 매크로를 사용해 slack api 호출 함수를 생성하는 방법

8 minute read

그룹 채팅 툴 slack의 elixir 클라이언트 라이브러리인 Elixir-Slack을 보다가 재미있는 코드를 발견했다. 명색이 slack 클라이언트면 slack api 호출을 편하게 할 수 있는 래핑 함수 정도는 제공해야 한다. 하지만 이게 몇 개여. 호출하는 함수를 일일이 언제 다 만드냐. 힘으로 하나하나 구현했다가는 API 업데이트를 쫓아가다 지친다. Elixir-Slack 라이브러리는 slack api 문서를 만드는 프로젝트인 slack-api-docs를 파싱(parsing)해서 elixir macro를 사용해 함수를 만든다.

slack-api-docs 프로젝트

This is a copy of the files used to generate the Slack API documentation at https://api.slack.com/. It can be followed to see changes in our API documentation over time.

slack api 문서를 만드는 프로젝트다. 이런 것까지 공개하다니 친절하다. 함수별로 파일이 있다. md 파일과 json 파일이 한 쌍이다. https://api.slack.com/methods/chat.postMessage 페이지에 해당하는 파일은 chat.postMessage.json 파일과 chat.postMessage.md 파일이다.

{
    "desc": "Sends a message to a channel.",
    "args": {
        "channel": {
            "type": "channel",
            "required": true,
            "desc": "Channel, private group, or IM channel to send message to. Can be an encoded ID, or a name. See [below](#channels) for more details."
        },
        "text": {
            "required": true,
            "example": "Hello world",
            "desc": "Text of the message to send. See below for an explanation of [formatting](#formatting)."
        },
        ...
    },
    "errors": {
        "channel_not_found": "Value passed for `channel` was invalid.",
        "not_in_channel": "Cannot post user messages to a channel they are not in.",
        "is_archived": "Channel has been archived.",
        "msg_too_long": "Message text is too long",
        "no_text": "No message text provided",
        "rate_limited": "Application has posted too many messages, [read the Rate Limit documentation](/docs/rate-limits) for more information"
    }
}

api 함수가 가지는 공통 속성을 json 파일로 저장했다. 설명, 인자, 에러 타입이 있다. 매크로를 사용해 함수를 만들기에 충분한 정보다. 이 파일을 읽어서 elixir 모듈과 함수를 만든다.

This method posts a message to a public channel, private group, or IM channel.

## Arguments

{ARGS}

## Formatting

Messages are formatted as described in the [formatting spec](/docs/formatting). You
can specify values for `parse` and `link_names` to change formatting behavior.

...

## Response

    {
        "ok": true,
        "ts": "1405895017.000506",
        "channel": "C024BE91L",
        "message": {
            ...
        }
    }

The response includes the timestamp (`ts`) and channel for the posted message. It also
includes the complete message object, as it was parsed by our servers. This
may differ from the provided arguments as our servers sanitize links,
attachments and other properties.

## Errors

{ERRORS}

## Warnings

{WARNINGS}

api에 대한 설명을 md 파일에 적는다. {ARGS}, {ERRORS}, {WARNINGS} 태그에 해당하는 내용을 짝궁 json 파일에서 찾아서 넣는다.

그냥 싹 md 파일에 적을 것 같았는데, api 함수가 가지는 공통 속성을 이해하고 json 파일로 분리했다. 규격화는 힘이다. 활용이 가능하다. 덕분에 json 파일을 읽어서 함수를 생성하는 매크로를 짤 수 있다.

https://api.slack.com/methods/chat.postMessage API 함수 설명 페이지가 chat.postMessage.md 파일 내용과 아주 다르다. slackhq/slack-api-docs 프로젝트 마지막 업데이트가 2017년인 걸 보니 slack API 문서 생성하는 프로세스가 바뀌었나 보다.

매크로를 사용해 모듈과 함수를 만들 준비 - mix task로 json 파일 복사

매크로의 시간은 컴파일 시간이다. 컴파일 시간에 slackhq/slack-api-docs 프로젝트에서 json 파일을 가져올 순 없으니 미리 준비한다. elixir 빌드 툴인 mix가 지원하는 커스텀 태스크(task)를 만들기 딱 좋은 작업이다. 똑같은 작업을 shell script로 할 수도 있겠지만 mix 커스텀 태스크로 만들면 elixir 언어로 다 해결할 수 있다. 괜히 다른 언어를 섞을 이유가 없다.

defmodule Mix.Tasks.UpdateSlackApi do
  # ...
  def run(_) do
    try do
      System.cmd("git", [
            "clone",
            "https://github.com/slackhq/slack-api-docs",
            "#{@dir}/slack-api-docs"
          ]) # <-- 1

      list_files() # <-- 2
      |> filter_json # <-- 3
      |> copy_files # <-- 4
    after
      System.cmd("rm", ["-rf", "#{@dir}/slack-api-docs"])
    end
  end

  defp copy_files(files) do
    File.mkdir_p!("lib/slack/web/docs")

    Enum.map(files, fn file ->
      origin = "#{@dir}slack-api-docs/methods/#{file}"
      dest = "lib/slack/web/docs/#{file}"
      File.cp!(origin, dest)
    end)
  end
end

Mix.Tasks.UpdateSlackApi 커스텀 태스크 모듈 구현 코드다. 1번 코드로 git 저장소를 clone 한다. 2번 코드로 methods 디렉터리에 있는 파일 리스트를 만들고 3번 코드로 json만 골라낸다. 4번 코드로 json 파일을 복사한다. 즉, slackhq/slack-api-docs 저장소 methods 디렉터리에 있는 json 파일을 lib/slack/web/docs 디렉터리로 복사한다.

$ mix update_slack_api

mix 인자로 실행할 커스텀 태스크 이름을 넣는다. CamelCase로 정의한 UpdateSlackApi 태스크는 snake_case로 변경한 update_slack_api 태스크로 자동 등록된다. 컴파일 전에 update_slack_api 태스크를 실행하면 slack api를 갱신할 수 있다.

update_slack_api.exs 커스텀 태스크 파일 확장자가 이상하다. 왜 elixir script를 저장할 때, 사용하는 exs 확장자를 썼을까? update_slack_api 커스텀 태스크를 실행하려면 update_slack_api.exs 파일을 update_slack_api.ex 파일로 이름을 바꿔야 한다. 확장자를 ex로 변경해야 한다.

모듈과 함수 코드를 만드는데 필요한 정보를 json 파일에서 가공

lib/slack/web/web.ex 파일에 매크로를 사용해 모듈과 함수를 생성하는 코드가 있다.

Enum.each(Slack.Web.get_documentation(), fn {module_name, functions} ->
  # 모듈 코드 생성
end)

Slack.Web.get_documentation/0 함수가 그 역할을 한다. 위에서 설명한 Mix.Tasks.UpdateSlackApi 모듈로 /lib/slack/web/docs 디렉터리에 json 파일을 다운로드하는데, 이 파일을 읽어서 모듈과 함수를 만들어내는데 필요한 정보를 가공한다.

iex> %{"chat" => [function]} = Slack.Web.format_documentation(["chat.postMessage.json"])
iex> Map.take(function, [:function, :required_params, :optional_params, :module])
%{
 function: :post_message,
 module: "chat",
 optional_params: [:as_user, :attachments, :blocks, :icon_emoji, :icon_url,
  :link_names, :parse, :thread_ts, :unfurl_links, :unfurl_media, :username],
 required_params: [:channel, :text]
}

모듈 이름, 함수 이름, 필수 파라미터, 옵션 파라미터와 같은 정보를 elixir map으로 리턴한다.

모듈, 함수를 생성하는 매크로 코드

Enum.each(Slack.Web.get_documentation(), fn {module_name, functions} ->
  # 모듈 코드 생성
end)

이제 모듈을 만드는 코드를 살펴볼 차례다.

Enum.each(Slack.Web.get_documentation(), fn {module_name, functions} ->
  # 모듈 이름 생성
  module =
    module_name
    |> String.split(".")
    |> Enum.map(&Macro.camelize/1)
    |> Enum.reduce(Slack.Web, &Module.concat(&2, &1))

  # ..
end)
iex> "chat" |> String.split(".") |> Enum.map(&Macro.camelize/1) |> Enum.reduce(Slack.Web, &Module.concat(&2, &1))
Slack.Web.Chat
iex> "admin.apps" |> String.split(".") |> Enum.map(&Macro.camelize/1) |> Enum.reduce(Slack.Web, &Module.concat(&2, &1))
Slack.Web.Admin.Apps

모듈은 CamelCase를 쓰고 함수는 snake_case를 쓰는 게 elixir 코딩 컨벤션이다. 처음엔 변태 같았지만 시간이 약이다. 이제는 익숙해져서 고향인 c++에서도 이렇게 쓰고 싶다. 첫 문자를 소문자로 쓰면 camelCase, 대문자로 쓰면 PascalCase로 구분했는데, 위키피디아에서 PascalCase를 CamelCase 페이지로 리다이렉트하는 걸 보면 다 CamelCase로 퉁치는 걸로 변했나 보다. camelCase, CamelCase로 구분해서 쓴다.

이제 모듈과 함수를 생성하는 코드를 살펴볼 차례다. 위에서 예로 든 chat.postMessage.json 파일로부터 Chat 모듈과 post_message 함수를 만드는 코드다.

Enum.each(Slack.Web.get_documentation(), fn {module_name, functions} ->
  # ...

  defmodule module do
    Enum.each(functions, fn doc ->
      function_name = doc.function

      arguments = Documentation.arguments(doc) # <--- 1
      argument_value_keyword_list = Documentation.arguments_with_values(doc)

      @doc """
      #{Documentation.to_doc_string(doc)}
      """
      # <--- 2
      def unquote(function_name)(unquote_splicing(arguments), optional_params \\ %{}) do
        required_params = unquote(argument_value_keyword_list)

        url = Application.get_env(:slack, :url, "https://slack.com")

        params =
          optional_params
          |> Map.to_list()
          |> Keyword.merge(required_params)
          |> Keyword.put_new(:token, get_token(optional_params))
          |> Enum.reject(fn {_, v} -> v == nil end)

        perform!(
          "#{url}/api/#{unquote(doc.endpoint)}",
          params(unquote(function_name), params, unquote(arguments))
        )
      end
    end)

    # ...
  end
end)

slack api 필수 매개변수(required parameter)를 읽는 1번 코드와 매크로를 사용해 만들어낼 함수 시그니처(function signature)를 만드는 2번 코드를 살펴보자.

iex> %{"chat" => [function]} = Slack.Web.format_documentation(["chat.postMessage.json"])
iex> arguments = Slack.Web.Documentation.arguments(function)
[{:channel, [], nil}, {:text, [], nil}]

1번 코드에서 slack api 필수 매개변수를 arguments에 할당한다.

iex> quote do
...> def unquote(function.function)(unquote_splicing(arguments), optional_params \\ %{}) do
...> end
...> end |> Macro.to_string
"def(post_message(channel, text, optional_params \\\\ %{})) do\n \nend"

Macro.to_string/1 함수를 사용해 2번 코드가 만들어내는 함수 시그니처를 좀 더 보기 편하게 출력해서 보자. unquote_splicing/1 매크로를 사용해서 리스트 알맹이만 가져와서 짜집는다.

"def(post_message([channel, text], optional_params \\\\ %{})) do\n \nend"

만약 unquote/1를 사용한다면 리스트가 그대로 인자로 들어가게 된다. 함수를 호출할 때, 쓰지도 않는 리스트를 만들어서 호출해야 한다. 불편하다.

함수 시그니처는 잘 만들어졌다. 알맹이를 살펴보자.

iex> quote do
...>  def unquote(function.function)(unquote_splicing(arguments), optional_params \\ %{}) do
...>   required_params = unquote(argument_value_keyword_list) #<--- 1
...>   params =
...>    optional_params
...>    |> Map.to_list()
...>    |> Keyword.merge(required_params)
...>    |> Keyword.put_new(:token, get_token(optional_params))
...>    |> Enum.reject(fn {_, v} -> v == nil end)
...>  end
...> end |> Macro.to_string
"def(post_message(channel, text, optional_params \\\\ %{})) do
  required_params = [text: text, channel: channel]
  params =
   optional_params
   |> Map.to_list()
   |> Keyword.merge(required_params)
   |> Keyword.put_new(:token, get_token(optional_params))
   |> Enum.reject(fn {_, v} -> v == nil end)
  end"

HTTP post 메서드로 넘길 매개변수(parameter)를 조합하는 코드를 만든다. chat.postMessage.json 파일로 코드를 만들면 1번 코드는 required_params = [text: text, channel: channel] 로 생성된다. 필수 매개변수를 Keyword로 만들고 선택적 매개변수(optional parameter)에 통합한다. 같은 key가 있으면 필수 매개변수의 값을 덮어쓴다.

iex> quote do
...>  url = Application.get_env(:slack, :url, "https://slack.com")
...>  perform!(
...>   "#{url}/api/#{unquote(function.endpoint)}",
...>   params(unquote(function.function), params, unquote(arguments))
...>  )
...> end |> Macro.to_string
"(
  url = Application.get_env(:slack, :url, \"https://slack.com\")
  perform!(\"\#{url}/api/\#{\"chat.postMessage\"}\", params(:post_message, params, [channel, text]))
 )"

이제 만든 매개변수를 사용하는 코드다. perform!/2 함수를 호출해서 HTTP post 메서드를 호출한다.

perform!/2, get_token/1, params/3 함수를 매크로 안에서 정의했는데, json 파일 내용에 의존적이지 않은 이런 함수까지 매크로 안에 넣을 필요는 없다. 매크로는 어렵기 때문에 최대한 덩치를 작게 만들어야 한다. 이런 공통 함수는 매크로에서 빼내서 따로 모듈을 만들고 매크로를 사용해 만드는 코드에서 호출하게 짜야지 좀 더 코드가 읽기 쉽다. 매크로는 어렵다. 최대한 매크로 안에 코드를 작게 유지해야 한다.

필수 매개변수는 각각 함수 매개변수로 넘기고 선택적 매개변수를 map으로 넘기는 함수로 만든 건 elixir 컨벤션을 따르는 좋은 결정이다. 다만 선택적 매개변수를 map으로 넘기는 건 아쉽다. elixir는 함수에 마지막 인자(argument)로 keyword를 넘기면 대괄호(square brackets)를 생략해도 되는 편의 문법(syntactic sugar)을 지원하기 때문이다.

예를 들면 다음과 같다.

Slack.Web.Chat.post_message(
  channel,
  message,
  %{
    as_user: false,
    token: Application.get_env(:slack, :token),
    attachments: attachments
  }
)

이런 코드를

Slack.Web.Chat.post_message(
  channel,
  message,
  as_user: false,
  token: Application.get_env(:slack, :token),
  attachments: attachments
)

이런 식으로 사용할 수 있다. 그래서 선택적 매개변수를 elixir에서는 마지막 인자로 keyword를 사용한다.

왜 매크로를 사용했을까?

나 같으면 코드 생성을 했을 것 같다. mix task로 json을 복사하는 게 아니라 매크로를 사용해 컴파일 시간에 모듈을 만드는 대신 mix task로 json으로부터 elixir 코드를 파일로 만들었을 것 같다. 매크로냐 코드 생성이냐를 결정하는데 개인 취향도 한몫하는 것 같다. 외부 파일로부터 어떤 모듈이나 함수를 만드는 건 바로 소스 코드를 볼 수 있어야 마음이 편하다. 반면 아주 간단한 코드를 만드는 매크로니 코드 생성보다는 소스 코드 역할을 하는 json과 매크로 코드만으로도 충분하다고 생각할 수도 있다. 라이브러리 작성자는 후자인 것 같다.

매크로냐 코드 생성이냐에 대한 좋은 기준점은 elixir의 String.Tokenizer 모듈인 것 같다. 거대한 UnicodeData.txt 파일로부터 elixir 코드를 생성하는 대신 매크로를 사용해 unicode tokenizer를 구현했다. 대신 UnicodeData.txt 파일로부터 패턴 매칭을 사용한 unicode_upper?/1, unicode_start?/1, unicode_continue?/1, ascii_pattern?/1 같이 간단한 함수만 만들고 그걸 사용한 코드를 정의해서 코드를 읽기 좋게 만들었다. 매크로냐 코드 생성이냐를 고민할 때, String.Tokenizer 모듈을 떠올릴 것 같다.

iex> h Slack.Web.Chat.post_message
* def post_message(channel, text, optional_params \\ %{})

Sends a message to a channel.

Required Params
* `channel` - Channel, private group, or IM channel to send message to. Can be an encoded ID, or a name. See [below](#channels) for more details.
* `text` - Text of the message to send. See below for an explanation of [formatting](#formatting). ex: `Hello world`


Optional Params
* `as_user` - Pass true to post the message as the authed user, instead of as a bot. Defaults to false. See [authorship](#authorship) below. ex: `true`
* `attachments` - Structured message attachments. ex: `[{"pretext": "pre-hello", "text": "text-world"}]`
* `blocks` - A JSON-based array of structured blocks, presented as a URL-encoded string ex: `[{"type": "section", "text": {"type": "plain_text", "text": "Hello world"}}]`
  ...

Errors the API can return:
* `channel_not_found` - Value passed for `channel` was invalid.
* `is_archived` - Channel has been archived.
  ...

매크로를 사용해 모듈과 코드를 만들 때, 도움말도 잘 생성되게 해놔서 소스 코드를 바로 볼 수 없는 단점을 잘 만회하고 있다. 코드 생성 대신 매크로를 선택했다면 설명도 잘 생성해서 코드를 읽는 다른 프로그래머도 배려하자.

마치며

slack은 그사이에 OpenAPI spec에 맞춰 slack api spec을 정리한 저장소를 만들었다. OpenAPI spec에 맞췄으니 openapi-generator를 사용해서 클라이언트 라이브러리를 손쉽게 만들 수 있다. elixir도 지원한다.

지금 slack api 클라이언트 라이브러리를 만든다면 OpenAPI를 사용할 것 같다. OpenAPI spec으로 공개하기 전에 만들어서 slack api 문서를 생성하는 프로젝트를 파싱해서 slack api 클라이언트를 만든 것 같다. 구조화된 slack api 문서 프로젝트를 사용해서 slack api 클라이언트 라이브러리를 만든 건 아주 훌륭한 결정이다. 그리고 이 모든 건 slack api 문서를 생성하는 프로젝트를 잘 구조화해서 만들고 공개한 slack 덕분이다.

참고