A Simple Example of Calling an Elixir Library from Gleam

Michael Lynch

從 Gleam 呼叫 Elixir 函式庫的簡易範例

最近我一直在嘗試 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 已可在我的 shell 中使用:

$ nix develop
erlang "27"
gleam 1.10.0

建立專案

我使用 gleam new 建立專案。我將專案託管在Github 之外,因此我加入 --skip-github 來略過 Gleam 預設加入的 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 newgleeunit 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

題外話:有一個 Gleam 原生的 CSV 函式庫叫做 gsv,但我改用 Elixir 函式庫,以便練習呼叫非 Gleam 的函式庫。

使用 Elixir 測試 CSV 套件

首先,我需要了解究竟該如何呼叫 CSV 套件的 API,然後才能為我需要的 API 撰寫 Elixir 包裝函式。

我想呼叫CSV.encode 函式,其簽章如下:

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

因此,encode 有兩個參數:

  1. 實作 Enumerable 協定的物件。
  2. (選用)encode_options() 型別

而它會回傳一個實作 Enumerable 協定的物件。

我不懂 Elixir,所以我啟動了 iex,也就是 Elixir 的互動式 shell,來了解其語意:

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 的東西,所以我需要使用另一個 Elixir API,將 Enumerable 轉換為 Gleam 能理解的東西。

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 virtual machine(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(erlang, "Elixir.Enum", "to_list")
fn enum_to_list(elixir_enum: ElixirEnumerable) -> List(String)

在這裡,我使用 Elixir 的Enum.to_list 函式ElixirEnumerable 轉換為Elixir List 型別,它似乎與Gleam List 型別相符。

建立 Gleam 友善的包裝函式

既然我已經撰寫了可以包裝我所需 Elixir API 的函式,現在是時候將它們整合起來,透過這個模組向我的 Gleam 應用程式公開一個函式:

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

這個函式很簡單。它接收字串清單的清單形式的資料,然後使用Gleam pipe operator(管線運算子)將其傳遞給 csv_encode,再一次使用 pipe operator 將結果轉換為 Gleam 相容的字串清單。

這是我在 csv 模組中唯一宣告為公開的函式,因為其他包裝函式太過底層,對此模組的 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(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

呼叫我的包裝函式

既然我已經有了一個 Gleam 原生的 CSV.encode 函式包裝,現在我可以撰寫一個簡單的 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 的 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 Pilford(路易斯·皮爾福德)DisguisedPigeon 對本文提供的寶貴回饋。

原文由 Michael Lynch 發布

本文章由 muse-spark-1.2-contributor 進行翻譯