A Simple Example of Calling an Elixir Library from Gleam

Michael Lynch

Gleam에서 Elixir 라이브러리를 호출하는 간단한 예제

원문은 Michael Lynch님이 에 게재했습니다. 이 블로그 구독하기

최근 새로운 프로그래밍 언어를 찾기 위한 탐색의 일환으로 Gleam과 Elixir를 조금씩 실험해 보고 있다.

Gleam의 대표적인 기능 중 하나는 Elixir 코드와 라이브러리를 호출할 수 있다는 점인데, 어떻게 하는지 보여주는 예제를 찾을 수 없었다. 그래서 Gleam/Elixir/Erlang 생태계에 대한 초보 수준의 이해를 바탕으로, Gleam 프로젝트에서 Elixir 라이브러리를 호출하는 간단한 예제를 직접 작성해 보았다.

의존성 설치

이 예제에서는 다음 버전을 사용한다

  • Gleam 1.10.0
  • Erlang 27.3.4
  • Elixir 1.18.3

이 의존성들은 원하는 방식으로 설치하면 된다. 나는 Nix로 의존성을 관리하므로, 다음과 같이 flake.nix 파일을 만들어 한 번에 설치한다.

{
  description = "Dev environment for gleam-call-elixir-simple";

  inputs = {
    flake-utils.url = "github:numtide/flake-utils";

    # 27.3.4
    erlang-nixpkgs.url = "github:NixOS/nixpkgs/8406224e30c258025cb8b31704bdb977a8f1f0";

    # 1.10.0
    gleam-nixpkgs.url = "github:NixOS/nixpkgs/3866ad91cfc172f08a6839def503d8fc2923c603";
  };

  outputs = {
    self,
    flake-utils,
    erlang-nixpkgs,
    gleam-nixpkgs,
  } @ inputs:
    flake-utils.lib.eachDefaultSystem (system: let
      erlang = erlang-nixpkgs.legacyPackages.${system}.beam27Packages.erlang;
      elixir = erlang-nixpkgs.legacyPackages.${system}.beam27Packages.elixir;
      gleam = gleam-nixpkgs.legacyPackages.${system}.gleam;
      inotify-tools = erlang-nixpkgs.legacyPackages.${system}.inotify-tools;
    in {
      devShells.default =
        erlang-nixpkgs.legacyPackages.${system}.mkShell
        {
          packages = [
            erlang
            elixir
            gleam
            inotify-tools
          ];

          shellHook = ''
            echo "erlang" $(erl -eval 'erlang:display(erlang:system_info(otp_release)), halt().' -noshell)
            gleam --version
          '';
        };

      formatter = erlang-nixpkgs.legacyPackages.${system}.alejandra;
    });
}

flake.nix 다운로드

nix develop을 실행하면 셸에서 erlang과 Gleam을 사용할 수 있는 것을 확인할 수 있다:

$ nix develop
erlang "27"
gleam 1.10.0

프로젝트 생성

gleam new로 프로젝트를 생성한다. 이 프로젝트는 Github 외부에 호스팅하고 있어서, Gleam이 기본적으로 추가하는 Github 관련 파일들을 생략하기 위해 --skip-github 옵션을 추가한다:

PROJECT_NAME='call_elixir'
gleam new --name "${PROJECT_NAME}" --skip-github .

사이드 퀘스트: Gleam 패키지 버그 우회하기

이제 gleam new가 생성한 보일러플레이트 코드를 실행해 보지만 실패한다:

$ gleam run
  Resolving versions
Downloading packages
 Downloaded 2 packages in 0.01s
  Compiling gleam_stdlib
error: Incompatible Gleam version

The package `gleeunit` requires a Gleam version satisfying 1.11.0 <= v but you are using v1.10.0.

문제는 gleam new가 의존성으로 gleeunit 1.4.0을 추가했는데, 해당 패키지가 Gleam 1.11.0에 의존하고 있어 내 로컬 Gleam 버전(1.10.0)보다 최신 버전을 요구한다는 점이다.

다른 사람도 같은 문제를 겪고 내가 겪기 불과 한 시간 전에 Github에 버그를 제보했다.

다음과 같이 Gleam이 gleeunit 1.3.1로 다운그레이드하도록 강제하면 이 문제를 우회할 수 있다:

$ gleam add --dev [email protected]
  Resolving versions
Downloading packages
 Downloaded 1 package in 0.00s
      Added gleeunit v1.3.1

그러면 gleam rungleam test가 예상대로 동작한다:

$ gleam run
  Compiling gleeunit
  Compiling call_elixir
   Compiled in 0.28s
    Running call_elixir.main
Hello from call_elixir!
$ gleam test
   Compiled in 0.01s
    Running call_elixir_test.main
.
Finished in 0.006 seconds
1 tests, 0 failures

Elixir 패키지 설치

의미가 단순한 패키지를 고르고 싶으니, CSV 라이브러리는 어떨까?

hex 패키지 매니저를 보면 가장 인기 있는 CSV 패키지CSV라는 이름이므로, 이를 설치한다:

$ gleam add csv
  Resolving versions
Downloading packages
 Downloaded 1 package in 0.00s
      Added csv v3.2.2

참고: gsv라는 Gleam 네이티브 CSV 라이브러리도 있지만, Gleam이 아닌 라이브러리를 호출하는 연습을 위해 Elixir 라이브러리를 사용한다.

Elixir로 CSV 패키지 테스트하기

먼저 CSV 패키지 API를 어떻게 호출하는지부터 이해한 뒤, 필요한 API에 대한 Elixir 래퍼를 작성해야 한다.

CSV.encode 함수를 호출하고 싶은데, 시그니처는 다음과 같다:

@spec encode(Enumerable.t(), [encode_options()]) :: Enumerable.t()

즉, encode는 두 개의 매개변수를 가진다:

  1. Enumerable 프로토콜을 구현한 객체.
  2. (선택 사항) encode_options() 타입.

그리고 Enumerable 프로토콜을 구현한 객체를 반환한다.

Elixir를 잘 모르므로, 의미를 파악하기 위해 대화형 Elixir 셸인 iex를 실행한다:

iex

그리고 iex 안에서 CSV 패키지를 설치한다:

iex> Mix.install([:csv])
Resolving Hex dependencies...
Resolution completed in 0.008s
New:
  csv 3.2.2
* Getting csv (Hex package)

이제 CSV 패키지 문서의 예제 중 하나를 시도해 본다:

iex> [~w(a b), ~w(c d)]
[["a", "b"], ["c", "d"]]
iex> |> CSV.encode
#Function<61.117496853/2 in Stream.transform/3>
iex> |> Enum.take(2)
["a,b\r\n", "c,d\r\n"]

아직 Elixir의 시길(sigil) 문법을 잘 모르겠고, “a”와 “b”를 쓰는 예제도 마음에 들지 않아서, 좀 더 직관적으로 느껴지는 방식으로 다시 작성해 보았다:

iex> CSV.encode([["movie", "rating"], ["The Godfather", 10], ["Gigli", 2]])
#Function<61.117496853/2 in Stream.transform/3>

iex> |> Enum.to_list()
["movie,rating\r\n", "The Godfather,10\r\n", "Gigli,2\r\n"]

iex> |> IO.puts()
movie,rating
The Godfather,10
Gigli,2

:ok

좋다, CSV.encode는 문자열 리스트의 리스트를 받아 문자열의 Enumerable을 반환하는 것으로 보인다.

Elixir 패키지를 위한 Gleam 래퍼 만들기

이제 CSV.encode의 의미를 이해했으니, Gleam에서 호출하기 위한 래퍼 함수를 작성해야 한다.

Elixir 함수를 위한 Gleam 래퍼를 작성할 때 가장 큰 어려움은 두 언어의 타입이 일치하지 않는다는 점이다. Gleam은 더 엄격한 정적 타이핑을 사용하는 반면, Elixir는 더 유연한 동적 타이핑을 사용한다.

CSV.encode 래핑하기

다시 한번, 호출하려는 CSV.encode 함수의 시그니처는 다음과 같다:

@spec encode(Enumerable.t(), [encode_options()]) :: Enumerable.t()

Gleam 표준 라이브러리에는 Elixir의 Enumerable에 해당하는 타입이 없는 것 같으므로, Enumerable을 Gleam이 이해할 수 있는 형태로 변환하기 위해 다른 Elixir API를 사용해야 한다.

Enum.to_list가 가장 좋아 보인다. Elixir 내장 list 타입을 반환하고, Gleam에도 이에 대응하는 List 타입이 있기 때문이다.

그래서 먼저 Elixir의 CSV.encode API를 위한 Gleam 래퍼 함수를 정의한다:

// Create a custom Gleam type to represent the type we receive from Elixir.
type ElixirEnumerable

@external(erlang, "Elixir.CSV", "encode")
fn csv_encode(data: List(List(String))) -> ElixirEnumerable

@external 속성을 사용하면 Gleam에서 Elixir 코드를 호출할 수 있다. Gleam, Elixir, Erlang은 모두 BEAM 가상 머신에서 실행되는 바이트코드로 컴파일될 수 있다. BEAM 안에서는 CSV.encode 함수가 Elixir.CSV 네임스페이스 아래에 나타나므로, @external 속성에서 Elixir를 지정해야 한다.

입력 매개변수는 Gleam의 문자열 리스트의 리스트(List(List(String)))로 정의했는데, 이는 Erlang의 Enumerable 타입과 호환된다.

CSV.encode는 Elixir Enumerable을 반환하지만, 이에 대응하는 Gleam 타입을 모르므로 ElixirEnumerable이라는 커스텀 타입을 정의한다.

Gleam 코드는 ElixirEnumerable을 가지고 아무 것도 할 수 없다. Gleam이 네이티브하게 접근하는 방법을 아는 데이터가 없기 때문이다. 따라서 ElixirEnumerable을 Gleam 네이티브 타입으로 변환할 방법이 필요하다.

Elixir Enumerable을 Gleam List로 변환하기

Elixir의 Enumerable 타입을 Gleam의 List로 변환하기 위해, 결과를 변환하는 또 다른 external 함수를 선언한다:

@external(erlang, "Elixir.Enum", "to_list")
fn enum_to_list(elixir_enum: ElixirEnumerable) -> List(String)

여기서는 Elixir의 Enum.to_list 함수를 사용해 ElixirEnumerableElixir List 타입으로 변환한다. 이는 Gleam List 타입과 일치하는 것으로 보인다.

Gleam 친화적인 래퍼 만들기

원하는 Elixir API를 래핑할 함수들을 작성했으니, 이제 이 모듈에서 Gleam 앱에 노출할 함수로 모두 묶을 차례다:

pub fn encode(data: List(List(String))) -> List(String) {
  data
  |> csv_encode
  |> enum_to_list
}

함수는 단순하다. 문자열 리스트의 리스트 형태의 데이터를 받아 Gleam 파이프 연산자를 이용해 csv_encode에 전달하고, 다시 파이프 연산자를 이용해 결과를 Gleam과 호환되는 문자열 리스트로 변환한다.

이 모듈의 다른 래퍼들은 너무 저수준이라 이 모듈을 사용하는 Gleam 클라이언트에게 유용하지 않으므로, csv 모듈에서 public으로 선언한 함수는 이것뿐이다.

전체 모듈은 다음과 같다:

// Create a custom Gleam type to represent the type we receive from Elixir.
type ElixirEnumerable

@external(erlang, "Elixir.CSV", "encode")
fn csv_encode(data: List(List(String))) -> ElixirEnumerable

@external(erlang, "Elixir.Enum", "to_list")
fn enum_to_list(elixir_enum: ElixirEnumerable) -> List(String)

pub fn encode(data: List(List(String))) -> List(String) {
  data
  |> csv_encode
  |> enum_to_list
}

csv.gleam 다운로드

래퍼 함수 호출하기

이제 CSV.encode 함수를 위한 Gleam 네이티브 래퍼가 생겼으니, 이 래퍼를 호출하는 간단한 Gleam 앱을 작성할 수 있다:

import gleam/io
import gleam/string
import csv

pub fn main() {
  [
    ["Title", "Author", "Release Year"],
    ["Infinite Jest", "David Foster Wallace", "1996"],
    ["Emma", "Jane Austen", "1815"],
    ["Catch-22", "Joseph Heller", "1961"]
  ] |> csv.encode()
    |> string.concat()
    |> io.print()
}

call_elixir.gleam 다운로드

문자열 리스트의 리스트를 간단히 만들어 csv.encode Gleam 래퍼에 전달한다. 이 함수는 문자열 리스트를 반환하므로, Gleam 표준 라이브러리 함수인 Gleam의 string.concat을 호출해 문자열들을 하나의 문자열로 합친다. 마지막으로 io.print를 호출해 결과를 콘솔에 출력한다.

$ gleam run
  Compiling call_elixir
   Compiled in 0.20s
    Running call_elixir.main
Title,Author,Release Year
Infinite Jest,David Foster Wallace,1996
Emma,Jane Austen,1815
Catch-22,Joseph Heller,1961

성공이다! 간단한 Gleam 애플리케이션에서 Elixir CSV 라이브러리를 성공적으로 호출했다.

소스 코드

이 예제의 전체 소스는 아래에서 확인할 수 있다:

Louis PilfordDisguisedPigeon에게 이 글에 대한 유용한 피드백을 주셔서 감사드립니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글