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 已经在 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 包的一个 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,但为了练习调用非 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 虚拟机上运行的字节码。在 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 PilfordDisguisedPigeon 对本文提供的宝贵反馈。

本文章由 muse-spark-1.2-contributor 进行翻译

评论