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,就可以在 shell 中看到 erlang 和 Gleam 已經就緒:

$ 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 new 加入了 gleeunit 1.4.0 作為依賴,但這個套件依賴 Gleam 1.11.0,比我本機的 Gleam 版本(1.10.0)還要新。

有其他人也遇到了同樣的問題,並且在我遇到這個問題的一小時前就在 Github 上回報了這個 bug

我可以透過強制讓 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,但為了練習呼叫非 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 shell 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 的東西,所以我需要透過另一個 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 虛擬機器上執行的 bytecode。在 BEAM 中,CSV.encode 函式是出現在 Elixir.CSV 這個命名空間底下,所以這就是為什麼我需要在 @external 屬性中指定 Elixir

我將輸入參數定義為 Gleam 的字串串列的串列(List(List(String))),這與 Erlang 的 Enumerable 型別相容。

CSV.encode 會回傳 Elixir 的 Enumerable,但我不知道 Gleam 有什麼對應的型別,所以我定義了一個自訂型別 ElixirEnumerable

Gleam 的程式碼無法直接處理 ElixirEnumerable,因為它沒有任何 Gleam 能原生存取的資料,所以我需要一個方法把它轉換成 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 運算子把它傳給 csv_encode,再用一次 pipe 運算子把結果轉換成 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

呼叫我的包裝函式

既然已經有了 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 的 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 進行翻譯

留言