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,可以看到我的 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 包的 bug

接下来,我尝试运行 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,但我改用 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 虚拟机上运行的字节码。在 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 的管道操作符把它传给 csv_encode,再用一次管道操作符把结果转换为 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 Pilford(路易斯·皮尔福德)DisguisedPigeon 对本文提出的有益反馈。

原文由 Michael Lynch 发布

本文章由 stealth/ox-alpha 进行翻译