Erlang/OTP 27 亮点

2024 年 5 月 20 日 · 作者:Björn Gustavsson

Erlang/OTP 27 终于发布了。这篇博文将介绍我们最兴奋的新功能。

所有更改的列表可以在 Erlang/OTP 27 自述文件中找到。或者,像往常一样,查看您感兴趣的应用程序的发行说明。例如:Erlang/OTP 27 - Erts 发行说明 - 版本 15.0

这篇博文中提到的今年的亮点是

改进的文档系统 #

Erlang/OTP 27 之前的 Erlang/OTP 文档是用 XML 编写的,Erl_Docgen 应用程序可以从中生成 HTML 网页、PDF 或 Unix 手册页。生成 PDF 的原因是该文档曾经被印刷成实际的纸质书。书籍最后一次印刷是在 2000 年发布的 Erlang/OTP R7。

例如,这是来自 Erlang/OTP 26 的 lists:duplicate/2 的 XML 代码

    <func>
      <name name="duplicate" arity="2" since=""/>
      <fsummary>Make <c>N</c> copies of element.</fsummary>
      <desc>
        <p>Returns a list containing <c><anno>N</anno></c> copies of term
          <c><anno>Elem</anno></c>.</p>
        <p><em>Example:</em></p>
        <pre>
> <input>lists:duplicate(5, xx).</input>
[xx,xx,xx,xx,xx]</pre>
      </desc>
    </func>

XML 代码存储在单独的文件中,而不是源代码中。构建文档时,源代码中的函数规范将与文档文件中的文本相结合。编写者有责任确保文档主体中提到的变量与函数规范中的名称匹配。

关于 Erl_Docgen 和旧文档系统,从来没有人说过它使编写文档变得愉快而毫不费力。这是我们想要通过新文档系统改变的一件事。我们希望使编写文档变得有趣,或者至少减少对正确使用 XML 标签等繁琐细节的关注。

在 Erlang/OTP 27 中,文档以 Markdown 编写,并放置在源代码中函数规范和实现之前。这是 Erlang/OTP 27 中 lists:duplicate/2 的文档和实现

-doc """
Returns a list containing `N` copies of term `Elem`.

_Example:_

```erlang
> lists:duplicate(5, xx).
[xx,xx,xx,xx,xx]
```
""".

-spec duplicate(N, Elem) -> List when
      N :: non_neg_integer(),
      Elem :: T,
      List :: [T],
      T :: term().

duplicate(N, X) when is_integer(N), N >= 0 -> duplicate(N, X, []).

duplicate(0, _, L) -> L;
duplicate(N, X, L) -> duplicate(N-1, X, [X|L]).
```

文档放在 三引号字符串 中,遵循 -doc 属性

将文档放在规范附近可以很容易地确保文本引用函数规范中定义的变量。

我们的另一个目标是用更广泛使用的工具替换 Erl_Docgen,这样我们就不必承担维护它的全部负担。我们通过使用 ExDoc 来实现这一目标,Elixir 语言和大多数(如果不是全部)Elixir 项目也使用该工具。

出现的一个问题是,是否建议在源代码中包含用户文档。这会不会使维护代码变得更加困难?

我并不声称对这个担忧有普遍的回应,但在 Erlang/OTP 的情况下,大多数积极开发的代码都存在于缺少文档的模块中。通常,OTP 应用程序由一个或几个包含文档化 API 的模块组成,而大部分实现都可以在其他模块中找到。

例如,Erlang 编译器的接口在 compile 模块中找到,而大多数被执行的代码驻留在 Compiler 应用程序的其他 59 个模块之一中。类似地,SSL 应用程序包含 76 个模块,其中只有四个包含文档。

另一个经常更新的应用程序是 ERTS。但是,大多数 ERTS 都是用 C(和一些 C++)实现的,而 ERTS 中许多实际的 Erlang 代码都位于没有文档的模块中。

当然,应用程序的结构方式有一些例外,例如 STDLIB 应用程序,其中大多数模块都有文档。但是,STDLIB 是一个成熟的应用程序,更新频率相对较低。

三引号字符串 #

为了方便编写包含多行文本的文档属性,已经实现了 EEP 64 中描述的三引号字符串。每当需要在 Erlang 源代码中包含多行文本时,三引号字符串就会派上用场。例如,假设我们要定义一个输出一些引言的函数

1> t:quotes().
"I always have a quotation for everything -
it saves original thinking." - Dorothy L. Sayers

"Real stupidity beats artificial intelligence every time."
- Terry Pratchett
ok

在 Erlang/OTP 26 中,有几种不同的方法可以做到这一点,但没有一种方法特别令人满意。例如,可以将文本放入单个字符串中

quotes() ->
    S = "\"I always have a quotation for everything -
it saves original thinking.\" - Dorothy L. Sayers

\"Real stupidity beats artificial intelligence every time.\"
- Terry Pratchett\n",
    io:put_chars(S).

这行得通,但很丑。我们还必须记住转义每个引号字符。

一种更清晰的方法是使用多个字符串,每行一个,让编译器将它们组合起来

quotes() ->
    S = "\"I always have a quotation for everything -\n"
        "it saves original thinking.\" - Dorothy L. Sayers\n"
        "\n"
        "\"Real stupidity beats artificial intelligence every time.\"\n"
        "- Terry Pratchett\n",
    io:put_chars(S).

这稍微好一点,但我们需要键入更多的引号字符,并且我们必须不要忘记在每个字符串的末尾添加 \n。为了确保我们不会忘记插入换行符,我们可以将这种平凡的家务委托给计算机

quotes() ->
    S = ["\"I always have a quotation for everything -",
         "it saves original thinking.\" - Dorothy L. Sayers",
         "",
         "\"Real stupidity beats artificial intelligence every time.\"",
         "- Terry Pratchett"],
    io:put_chars(lists:join("\n", S)),
    io:nl().

在 Erlang/OTP 27 中,我们可以使用三引号字符串

quotes() ->
    S = """
        "I always have a quotation for everything -
        it saves original thinking." - Dorothy L. Sayers

        "Real stupidity beats artificial intelligence every time."
        - Terry Pratchett
        """,
    io:put_chars(S),
    io:nl().

结尾的 """ 决定了字符串中每行的缩进量。与 """ 之前的字符相同的字符将从开头和结尾分隔符之间的所有行中删除。对于这个特定的示例,所有空格字符都被删除,因为它们的缩进与结尾的 """ 相同。在三引号括起来的行中,引号字符和反斜杠都不是特殊的,因此无需转义任何内容。

这是另一个示例,显示三引号字符串的多功能性

effect_warning() ->
    """
    f() ->
        %% Test that the compiler warns for useless tuple building.
        {a,b,c},
        ok.
    """.

该函数返回一个包含简短 Erlang 函数的字符串。

假设 effect_warning/0 在模块 t 中定义,可以像这样调用它

1> io:format("~ts\n", [t:effect_warning()]).
f() ->
    %% Test that the compiler warns for useless tuple building.
    {a,b,c},
    ok.

请注意,函数 f/0 的 Erlang 代码的缩进被保留。

有关更多信息,请参见参考手册中的 字符串 部分。

Sigils #

已经实现了 EEP 66 中描述的字符串字面量的 Sigils。

继续引号的主题,让我们探讨一下为什么将 sigils 引入 Erlang,从中汲取古希腊哲学家的智慧

1> t:greek_quote().
"Know thyself" (Greek: Γνῶθι σαυτόν)
ok

在 Erlang/OTP 26 中,这可以按如下方式实现

greek_quote() ->
    S = "\"Know thyself\" (Greek: Γνῶθι σαυτόν)",
    io:format("~ts\n", [S]).

此时,我们收到了一些客户反馈,表明包含所有引号的模块正在消耗过多的内存。字符串中的每个字符消耗 16 个字节的内存(在 64 位计算机上)。如果使用二进制而不是字符串,则每个字符可以减少到 1 个字节。(实际上,每个 US ASCII 字符 1 个字节,每个希腊字母 2 个字节。)

这个改变应该很容易。让我们试试

greek_quote() ->
    S = <<"\"Know thyself\" (Greek: Γνῶθι σαυτόν)">>,
    io:format("~ts\n", [S]).

这对英语文本有效,但对希腊字符无效

2> t:greek_quote().
"Know thyself" (Greek: “½ö¸¹ ñÅÄ̽)

怎么回事?

二进制表达式中的字符串默认被假定为字节大小字符的序列。因此,此表达式

1> <<"Γνῶθι">>.
<<147,189,246,184,185>>

语法糖,等价于

2> <<$Γ:8, $ν:8, $ῶ:8, $θ:8, $ι:8>>.
<<147,189,246,184,185>>

必须指定字符要通过追加 /utf8 后缀来编码为 UTF-8 编码字符

greek_quote() ->
    S = <<"\"Know thyself\" (Greek: Γνῶθι σαυτόν)"/utf8>>,
    io:format("~ts\n", [S]).

这之所以有效,是因为 <<"Γνῶθι"/utf8>><<$Γ/utf8, $ν/utf8, $ῶ/utf8, $θ/utf8, $ι/utf8>> 的语法糖。

输入 sigils。

greek_quote() ->
    S = ~B["Know thyself" (Greek: Γνῶθι σαυτόν)],
    io:format("~ts\n", [S]).

~ 字符开始一个 sigil。它通常后跟一个字母,指示应如何解释或编码字符串中的字符。

在这种情况下,字符 B 表示字符应以 UTF-8 编码放入二进制文件中,并且不允许使用转义字符。

B 之后是起始分隔符,在本例中为 [。由于不允许使用转义字符,因此必须选择字符串内容中不出现的分隔符。内容之后是结束分隔符,在本例中为 ]

~b 创建二进制文件的方式与 ~B 相同,只是反斜杠将被解释为转义字符。如果要将控制字符(例如 TAB (\t))插入到二进制文件中,这会很有用

1> ~b"abc\txyz".
<<"abc\txyz">>

这里我们使用 " 字符作为分隔符,因为它没有在字符串中使用。

如果省略 ~ 之后的字母,我们将得到相同的结果

2> ~"abc\txyz".
<<"abc\txyz">>

默认 sigil(~ 之后没有字母)会创建一个二进制文件,就像 ~b~B 一样,但是否解释转义字符取决于字符串的形式。三引号字符串默认不解释诸如 \n 之类的转义序列,但普通的内联字符串会解释,因此 ~"abc\ndef" 的工作方式符合您的预期,并且您始终可以在现有字符串(例如 "abc\ndef")前面加上 ~,以将其转换为二进制文件,而不用担心更改其内容。

回到上一节的引言示例,让我们看看如何通过在开头的 """ 之前插入 ~ 来创建二进制字面量

quotes() ->
    S = ~"""
         "I always have a quotation for everything -
         it saves original thinking." - Dorothy L. Sayers

         "Real stupidity beats artificial intelligence every time."
         - Terry Pratchett
         """,
    io:put_chars(S),
    io:nl().

对于三引号字符串,默认 sigil 和 ~B 始终产生相同的二进制文件。当必须支持转义字符时,可以使用 ~b sigil。

~s 以通常的方式创建一个字符串。它与普通带引号的字符串的唯一有用的区别在于可以切换分隔符。这样,可以避免转义引号字符的麻烦,并且仍然可以使用诸如 TAB 之类的控制字符

3> ~s{"abc\txyz"}.
"\"abc\txyz\""

用于三引号字符串时,它可以使用转义字符

4> ~s"""
    \tabc
    \tdef
    """.
"\tabc\n\tdef"

~S 创建一个字符串,但不支持转义字符串内的字符,类似于 ~B

有关更多信息,请参见参考手册中的 Sigil 部分。

更新:默认 sigil 的描述已更正。感谢 Richard Carlsson 指出此错误。)

无需启用功能 maybe #

maybe 表达式 作为 Erlang/OTP 25 中的一个 功能 引入。在该版本中,必须在编译器和运行时系统中都启用它。

Erlang/OTP 26 解除了在运行时系统中启用 maybe 的必要性。

现在在 Erlang/OTP 27 中,默认情况下在编译器中启用 maybe。在 去年的博客文章中的示例中,现在可以删除 -feature(maybe_expr, enable).

$ cat t.erl
-module(t).
-export([listen_port/2]).
listen_port(Port, Options) ->
    maybe
        {ok, ListenSocket} ?= inet_tcp:listen(Port, Options),
        {ok, Address} ?= inet:sockname(ListenSocket),
        {ok, {ListenSocket, Address}}
    end.
$ erlc t.erl
$ erl
Erlang/OTP 27 . . .

Eshell V15.0  (abort with ^G)
1> t:listen_port(50000, []).
{ok,{#Port<0.5>,{{0,0,0,0},50000}}}

maybe 用作原子时,需要用引号引起来。例如

will_succeed(. . .) -> yes;
will_succeed(. . .) -> no;
   .
   .
   .
will_succeed(_) -> 'maybe'.

或者,仍然可以禁用 maybe_expr 功能。禁用该功能后,maybe 可以用作原子,而无需引号。

禁用 maybe 的一种方法是在编译时使用 -disable-feature 选项。例如

erlc -disable-feature maybe_expr *.erl

禁用 maybe 的另一种方法是将以下指令添加到源代码中

-feature(maybe_expr, disable).

新的 json 模块 #

STDLIB 中有一个新的模块 json,用于生成和解析 JSON(JavaScript 对象表示法)

它由 Michał Muskała 实现,他同时也是 Elixir 的 Jason 库的作者。Jason 以其比其他纯 Erlang 或 Elixir JSON 库更快的速度而闻名。json 模块并非 Jason 的 Elixir 代码的纯粹翻译,而是重新实现,其性能甚至比 Jason 更优。

例如,假设我们有一个名为 quotes.json 的文件,其中包含电影 Jason and the Argonauts 中的引言。

[
    {"quote": "The gods are best served by those who need their help the least.",
     "attribution": "Zeus",
     "verified": true},
    {"quote": "Now the voyage is over, I don't want any trouble to begin.",
     "attribution": "Jason",
     "verified": true}
]

可以通过调用 json:decode/1 来解码该文件的 JSON 内容。

1> {ok,JSON} = file:read_file("quotes.json").
{ok,<<"[\n   {\"quote\": \"The gods are best served by those who need their help the least.\",\n    \"attribution\": \"Zeus\""...>>}
2> json:decode(JSON).
[#{<<"attribution">> => <<"Zeus">>,
   <<"quote">> =>
       <<"The gods are best served by those who need their help the least.">>,
   <<"verified">> => true},
 #{<<"attribution">> => <<"Jason">>,
   <<"quote">> =>
       <<"Now the voyage is over, I don't want any trouble to begin.">>,
   <<"verified">> => true}]

默认情况下,为了安全起见,对象的键会被转换为二进制数据。如果恶意 JSON 对象定义数百万个唯一的键,则使用原子可能会导致拒绝服务攻击

为了方便起见,仍然可以使用解码器回调以安全的方式将键转换为原子。以下是一个示例:

1> Push = fun(Key, Value, Acc) -> [{binary_to_existing_atom(Key), Value} | Acc] end.
#Fun<erl_eval.40.39164016>

此函数将 JSON 对象的键转换为一个已存在的原子,如果不存在这样的原子,则会引发异常。

由于此示例是从 shell 运行的,因此我们需要确保所有可能的键都是已知的原子。

2> {quote,attribution,verified}.
{quote,attribution,verified}

当在 Erlang 模块中完成 JSON 解码时,通常不需要这样做,因为用作键的原子大概会通过处理解码后的 JSON 对象时自然定义。

完成此准备工作后,可以使用 Push 函数作为 object_push 解码器回调来调用 JSON 解码器。

3> {Qs,_,<<>>} = json:decode(JSON, [], #{object_push => Push}), Qs.
[#{quote =>
       <<"The gods are best served by those who need their help the least.">>,
   attribution => <<"Zeus">>,verified => true},
 #{quote =>
       <<"Now the voyage is over, I don't want any trouble to begin.">>,
   attribution => <<"Jason">>,verified => true}]

json:encode/1 函数将 Erlang 项编码为 JSON。

4> io:format("~ts\n", [json:encode(Qs)]).
[{"quote":"The gods are best served by those who need their help the least.","attribution":"Zeus","verified":true},{"quote":"Now the voyage is over, I don't want any trouble to begin.","attribution":"Jason","verified":true}]
ok

编码器接受二进制数据、原子和整数作为对象的键,因此无需为此特定示例自定义编码。

但是,必要时可以自定义编码。例如,假设我们希望将每个引言存储在三元组中而不是在 map 中。

1> Q = [{~"The gods are best served by those who need their help the least.",
~"Zeus",true},
{~"Now the voyage is over, I don't want any trouble to begin.",
~"Jason",true}].
[{<<"The gods are best served by those who need their help the least.">>,
  <<"Zeus">>,true},
 {<<"Now the voyage is over, I don't want any trouble to begin.">>,
  <<"Jason">>,true}]

默认情况下,json:encode/1 函数不处理该格式,但可以通过定义编码器函数来处理。

quote_encoder({Q, A, V}, Encode)
  when is_binary(Q), is_binary(A), is_boolean(V) ->
    json:encode_map(#{quote => Q,
                      attribution => A,
                      verified => V},
                    Encode);
quote_encoder(Other, Encode) ->
    json:encode_value(Other, Encode).

第一个子句匹配一个看起来像引言的大小为三的元组。如果匹配,则将其转换为 JSON 对象的 map 表示形式,然后通过实用函数 json:encode_map/1 转换为 JSON。

第二个子句通过调用默认的编码函数 json:encode_value/2 将所有其他 Erlang 项转换为 JSON。

假设此函数在模块 t 中定义,则按如下方式调用到 JSON 的转换:

2> io:format("~ts\n", [json:encode(Q, fun t:quote_encoder/2)]).
[{"quote":"The gods are best served by those who need their help the least.","attribution":"Zeus","verified":true},{"quote":"Now the voyage is over, I don't want any trouble to begin.","attribution":"Jason","verified":true}]

JSON 编码器将为给定项递归调用回调。如果我们修改 quote_encoder/2 的第二个子句以打印 Other 的值,就可以清楚地看到这一点。

3> json:encode(Q, fun t:quote_encoder/2), ok.
-- [{<<"The gods are best served by those who need their help the least.">>,
     <<"Zeus">>,true},
    {<<"Now the voyage is over, I don't want any trouble to begin.">>,
     <<"Jason">>,true}]
-- <<"quote">>
-- <<"The gods are best served by those who need their help the least.">>
-- <<"attribution">>
-- <<"Zeus">>
-- <<"verified">>
-- true
-- <<"quote">>
-- <<"Now the voyage is over, I don't want any trouble to begin.">>
-- <<"attribution">>
-- <<"Jason">>
-- <<"verified">>
-- true

进程标签 #

为了帮助调试或一般观察,现在可以使用 proc_lib:set_label/1 在未注册的进程上设置标签。

标签是任意的项。标签会显示在 shell 命令 i/0observer 中。它们也可以在 崩溃转储 的字典部分找到。

以下是一个示例,其中启动并检查了五个带有标签的引言处理程序进程:

1> F = fun(I) ->
   spawn_link(fun() ->
     proc_lib:set_label({quote_handler, I}),
     receive _ -> ok end
   end)
   end.
#Fun<erl_eval.42.39164016>
2> Ps = [F(I) || I <- lists:seq(1, 5)].
[<0.91.0>,<0.92.0>,<0.93.0>,<0.94.0>,<0.95.0>]
3> proc_lib:get_label(hd(Ps)).
{quote_handler,1}
4> i().
Pid                   Initial Call                          Heap     Reds Msgs
Registered            Current Function                     Stack
<0.0.0>               erl_init:start/2                       987     5347    0
init                  init:loop/1                              2
   .
   .
   .
{quote_handler,1}     prim_eval:'receive'/2                    9
<0.92.0>              erlang:apply/2                         233     4006    0
{quote_handler,2}     prim_eval:'receive'/2                    9
<0.93.0>              erlang:apply/2                         233     4006    0
{quote_handler,3}     prim_eval:'receive'/2                    9
<0.94.0>              erlang:apply/2                         233     4006    0
{quote_handler,4}     prim_eval:'receive'/2                    9
<0.95.0>              erlang:apply/2                         233     4006    0
{quote_handler,5}     prim_eval:'receive'/2                    9
Total                                                     642876  1156835    0
                                                             438
ok

SSH 和 SSL 应用程序已更新为对其创建的进程进行标记。

STDLIB 中的新功能 #

用于 set 模块的新实用函数 #

STDLIB 中的三个 set 模块 — setsgb_setsordsets — 具有新函数 is_equal/2map/2filtermap/2

当需要找出两个集合是否包含相同的元素时,is_equal/2 函数非常有用。与 ===:= 进行比较并不总是可靠的。例如:

1> Seq = lists:seq(1, 20, 2).
[1,3,5,7,9,11,13,15,17,19]
2> gb_sets:from_list(Seq) == gb_sets:delete(10, gb_sets:from_list([10|Seq])).
false
3> gb_sets:is_equal(gb_sets:from_list(Seq), gb_sets:delete(10, gb_sets:from_list([10|Seq]))).
true

map/2 会映射一个集合的元素,生成一个新的集合。

4> Seq = lists:seq(1, 20, 2).
[1,3,5,7,9,11,13,15,17,19]
#Fun<erl_eval.42.39164016>
5> ordsets:to_list(ordsets:map(fun(N) -> N div 4 end, ordsets:from_list(Seq))).
[0,1,2,3,4]

filtermap/2 函数可以同时进行映射和过滤。以下示例说明如何将集合中的每个整数乘以 100 并删除非整数:

1> Mixed = [1,2,3,a,b,c].
[1,2,3,a,b,c]
2> F = fun(N) when is_integer(N) -> {true,N * 100};
   (_) -> false
   end.
#Fun<erl_eval.42.39164016>
3> sets:to_list(sets:filtermap(F, sets:from_list(Mixed))).
[300,200,100]

接受 fun 的新的 timer 便利函数 #

在 Erlang/OTP 26 中,timer 模块中的函数不接受 fun。当然可以在 erlang:apply/2 的参数中传递一个 fun,但是如果出现错误,则只有在计时器过期时才会注意到:

1> timer:apply_after(10, erlang, apply, [fun() -> io:put_chars("now!\n") end]).
{ok,{once,#Ref<0.2380540714.1485570051.86513>}}
=ERROR REPORT==== 10-Apr-2024::05:56:43.894073 ===
Error in process <0.109.0> with exit value:
{undef,[{erlang,apply,[#Fun<erl_eval.43.105768164>],[]}]}

这里忘记了 fun 的空参数列表。它应该是:

2> timer:apply_after(10, erlang, apply, [fun() -> io:put_chars("now!\n") end, []]).
{ok,{once,#Ref<0.2380540714.1485570051.86522>}}
now!

在 Erlang/OTP 27 中,使用 fun 变得容易得多:

1> timer:apply_after(10, fun() -> io:put_chars("now!\n") end).
{ok,{once,#Ref<0.3845681669.1215561736.51634>}}
now!

在使用热代码更新的系统中,为长时间运行的计时器使用本地 fun 并非理想选择。定义 fun 的代码可能已被替换,并且当计时器最终过期时,调用将失败。因此,还可以传递一个 fun 以及其参数,从而可以使用在热代码更新中生存下来的远程 fun。

2> timer:apply_after(10, fun io:put_chars/1, ["now\n"]).
{ok,{once,#Ref<0.3845681669.1215561736.51650>}}
now

apply_interval/*apply_repeatedly/* 函数现在也接受 fun。

新的 ets 函数 #

新函数 ets:first_lookup/1ets:next_lookup/2 简化并加速了 ETS 表的遍历。

1> T = ets:new(example, [ordered_set]).
#Ref<0.1968915180.2077884419.247786>
2> ets:insert(T, [{I,I*I} || I <- lists:seq(1, 10)]).
true
3> {K1,_} = ets:first_lookup(T).
{1,[{1,1}]}
4> {K2,_} = ets:next_lookup(T, K1).
{2,[{2,4}]}
5> {K3,_} = ets:next_lookup(T, K2).
{3,[{3,9}]}
6> {K4,_} = ets:next_lookup(T, K3).
{4,[{4,16}]}

类似地,ets:last_lookup/1ets:prev_lookup/2 可用于以相反的顺序遍历表。

新函数 ets:update_element/4 类似于 ets:update_element/3,但可以在没有具有给定键的现有对象时提供默认对象。

1> T = ets:new(example, []).
#Ref<0.878413430.1983512583.205850>
2> ets:update_element(T, a, {2, true}, {a, true}).
true
3> ets:lookup(T, a).
[{a,true}]

新的 SSL 客户端支持 OCSP 封套 #

Erlang/OTP 27 中 SSL 客户端的新功能是支持 OCSP 封套,以更轻松,更快速地验证服务器证书的吊销状态。

通过 OCSP 封套,SSL 客户端可以简化吊销状态的验证。通常,客户端必须使用 OCSP(在线证书状态协议)查询 CA(证书颁发机构),以确保服务器的证书尚未被吊销

OCSP 封套背后的基本思想是,服务器本身会主动向 CA 查询其自身证书的吊销状态,并将来自 CA 的带有时间戳的 OCSP 响应“封套”到证书中。当客户端连接时,服务器将其 OCSP 封套的证书传递给客户端。要验证吊销状态,客户端仅需要检查 OCSP 响应是否由 CA 签名。

以下示例说明如何在 SSL 客户端中启用 OCSP 封套:

1> ssl:start().
ok
2> {ok, Socket} = ssl:connect("duckduckgo.com", 443,
                              [{cacerts, public_key:cacerts_get()},
                               {stapling, staple}]).
{ok,{sslsocket,{gen_tcp,#Port<0.5>,tls_connection,undefined},
               [<0.122.0>,<0.121.0>]}}

tprof:另一个性能分析工具 #

在 Erlang/OTP 27 中,新的性能分析工具 tprof 加入了现有的性能分析工具 cprofeproffprof

为什么要引入新的性能分析工具?

原因之一是 cprofeprof 执行相似的性能分析任务,但是 API 函数的命名不同。在连续运行一个工具后,很容易混淆名称,并且连续运行它们并非不常见。例如,当尝试在复杂的运行中的 Erlang 系统中查找瓶颈时,一种方法是首先使用 cprof 来大致了解系统中可能存在瓶颈的总体部分。之后,在系统的有限部分上运行 eprof,试图缩小范围。直接在大型 Erlang 应用程序上运行 eprof 可能会使其过载并崩溃。

使用 tprof,相同的函数用于计算调用次数和测量每次调用的时间。以下是如何在调用 lists:seq(1, 1000) 时计算调用次数:

1> tprof:profile(lists, seq, [1, 1000], #{type => call_count}).
FUNCTION          CALLS  [    %]
lists:seq/2           1  [ 0.40]
lists:seq_loop/3    251  [99.60]
                         [100.0]
ok

请注意,调用计数始终是针对所有进程执行的。

lists:seq/2 的大部分工作在 lists:seq_loop/3 中完成,后者被调用了 251 次。由于我们要求 1000 个整数,因此我们得出结论,每次对 seq_loop/3 的尾递归调用都会一次创建四个列表元素。可以通过查看源代码来确认这一点。

要测量每次调用的时间,我们只需要将 call_count 替换为 call_time

2> tprof:profile(lists, seq, [1, 1000], #{type => call_time}).

****** Process <0.94.0>  --  100.00% of total ***
FUNCTION          CALLS  TIME (μs)  PER CALL  [     %]
lists:seq/2           1          0      0.00  [  0.00]
lists:seq_loop/3    251         50      0.20  [100.00]
                                50            [ 100.0]
ok

调用时间仅针对调用 tprof:profile/4 的进程以及该进程生成的任何进程进行测量。

通过将 call_time 替换为 call_memory,将测量每次调用消耗的内存量:

3> tprof:profile(lists, seq, [1, 1000], #{type => call_memory}).

****** Process <0.97.0>  --  100.00% of total ***
FUNCTION          CALLS  WORDS  PER CALL  [     %]
lists:seq_loop/3    251   2000      7.97  [100.00]
                          2000            [ 100.0]
ok

创建的单词总数为 2000,这是有道理的,因为每个列表元素都需要 2 个单词。每次调用消耗的单词数为 2000/251,大约为 7.97 或接近 8。这也很有意义,因为每个尾递归调用都会创建 4 个列表元素或 8 个单词,并且有 250 个这样的调用。其余调用将创建最终的空列表([])。

call_memory 跟踪在 Erlang/OTP 26 的运行时系统中引入,但未在任何现有性能分析工具中公开,因为它并不真正适合它们中的任何一个。在新工具中启用对其的支持更有意义。

多个跟踪会话 #

跟踪使人们可以观察、调试、分析和测量运行中的 Erlang 系统的性能。多年来,已经开发了许多使用跟踪的工具。仅在 Erlang/OTP 中,就有几种工具利用跟踪来实现不同的目的:

  • dbgttb - 常规跟踪工具

  • etop - 类似于 Unix 中的 top

  • eprofcproffproftprof - 性能分析工具

  • et - 事件跟踪器

  • debugger - 在评估 receive 表达式时在内部使用跟踪

在 Erlang/OTP 26 及更早版本中,跟踪有一些限制:

  • 每个跟踪的进程只能有一个跟踪器。

  • 要跟踪的进程和函数的配置在运行时系统中是全局的。

这些限制意味着不同的跟踪工具很容易互相干扰。危险之处在于,同时使用多个跟踪工具似乎可以在一段时间内工作……直到它不再工作。

在 Erlang/OTP 27 中,可以创建多个跟踪会话。每个跟踪会话都有自己的跟踪器进程和用于跟踪的进程和函数的配置。

要创建跟踪会话并设置跟踪,内核应用程序中有一个新的 trace 模块。使用该模块设置跟踪的工具将不再互相干扰。使用 旧 API 的工具将共享单个全局跟踪会话。

在 Erlang/OTP 27 的初始版本中,某些使用跟踪的工具已更新为使用跟踪会话。其他工具将在即将到来的维护版本中更新。

我们尝试设计新的 API,使其对于外部工具的维护者来说,迁移代码相对容易。除了函数名称和第一个参数(会话参数)之外,其他参数及其语义几乎与旧 API 完全相同。

快速跟踪会话示例 #

以下示例展示了新 API 的使用方法。首先,我们需要一个跟踪器进程,它会打印它接收到的所有跟踪消息。

1> Tracer = spawn(fun F() -> receive M -> io:format("== ~p ==\n", [M]), F() end end).
<0.90.0>

有了跟踪器进程,我们就可以创建一个跟踪会话。

2> Session = trace:session_create(my_session, Tracer, []).
{#Ref<0.179442114.3923902468.103849>,{my_session,0}}

接下来,我们在当前进程上启用调用跟踪。

3> trace:process(Session, self(), true, [call]).
1

确保模块 array 已加载,并跟踪其中的所有调用。

4> l(array).
{module,array}
5> trace:function(Session, {array,'_','_'}, [], [local]).
89

接下来,创建一个新数组。

6> array:new(10).
== {trace,<0.88.0>,call,{array,new,"\n"}} ==
{array,10,0,undefined,10}
== {trace,<0.88.0>,call,{array,new_0,[10,0,false]}} ==
== {trace,<0.88.0>,call,{array,new_1,["\n",0,false,undefined]}} ==
== {trace,<0.88.0>,call,{array,new_1,[[],10,true,undefined]}} ==
== {trace,<0.88.0>,call,{array,new,[10,true,undefined]}} ==
== {trace,<0.88.0>,call,{array,find_max,"\t\n"}} ==

请注意,跟踪消息与调用的返回值随机混合在一起。

当我们完成时,我们可以销毁会话。

7> trace:session_destroy(Session).

如果我们不销毁会话,当对它的最后一个引用消失时,它将自动销毁。

原生覆盖率支持 #

用于确定代码覆盖率Cover 工具长期以来一直是 Erlang/OTP 的一部分。

传统上,Cover 在没有运行时系统中任何专门功能帮助的情况下收集其覆盖率指标。为了计算模块中每行代码执行的次数,Cover 通过在每条可执行代码行上插入对 ets:update_counter/3 的调用,对该模块的抽象代码进行检测

这种方法可行,但是经过 Cover 检测的 Erlang 代码运行速度总是会变慢。变慢多少取决于被测试代码的性质。

在 Erlang/OTP 27 中,支持 JIT(即时编译器)的运行时系统现在可以在运行时系统中收集覆盖率指标,且性能开销最小。

Cover 工具已更新,以便在运行时系统支持的情况下自动利用原生覆盖率支持。在运行大多数 OTP 应用程序的测试套件时,无论是否使用 Cover,执行时间都没有明显差异。

原生覆盖率支持也可以直接用于执行 Cover 无法完成的测量,例如收集 Erlang 运行时系统启动时执行的代码的指标。

以下是一个快速示例,展示了如何收集 init 的覆盖率指标,init 是启动运行时系统时执行的第一个模块。首先,我们需要指示运行时系统使用额外代码检测所有模块中的所有函数,以计算每个函数被调用的次数。

$ bin/erl +JPcover function_counters

运行时系统正常启动。现在,我们可以读取 init 模块的计数器。

1> lists:reverse(lists:keysort(2, code:get_coverage(function, init))).
[{{archive_extension,0},392},
 {{get_argument1,2},198},
 {{objfile_extension,0},101},
 {{boot_loop,2},64},
 {{request,1},55},
 {{to_strings,1},44},
 {{do_handle_msg,2},38},
 {{handle_msg,2},38},
 {{b2s,1},38},
 {{get_argument,2},33},
 {{get_argument,1},31},
 {{'-load_modules/2-lc$^0/1-0-',1},30},
 {{'-load_modules/2-lc$^1/1-2-',1},30},
 {{'-load_modules/2-lc$^2/1-3-',1},30},
 {{'-load_modules/2-lc$^3/1-4-',1},30},
 {{extract_var,2},30},
 {{'-prepare_loading_fun/0-fun-0-',3},29},
 {{eval_script,2},23},
 {{append,1},18},
 {{get_arguments,1},18},
 {{reverse,1},17},
 {{check,2},17},
 {{ensure_loaded,2},16},
 {{ensure_loaded,1},16},
 {{do_load_module,2},14},
 {{do_ensure_loaded,2},14},
 {{get_flag_args,...},12},
 {{...},...},
 {...}|...]

返回的每个函数的计数器值列表按每个函数执行的次数降序排序。

有关更多信息,请参阅 code 模块文档中的原生覆盖率支持

弃用归档文件 #

归档文件 是 Erlang/OTP 中长期存在的实验性功能。Erlang/OTP 27 中弃用了对归档文件的一部分支持。

原因是,从归档文件加载代码的性能从未很好。更糟糕的是,归档文件功能的存在甚至会在不使用归档文件的情况下降低代码加载的性能,并使旨在减少启动时间的优化变得复杂或无法进行。

在 Erlang/OTP 27 中,以下功能已被弃用

  • 使用归档文件将单个应用程序或单个应用程序的部分打包到包含在代码路径中的归档文件中。此功能可能会在 Erlang/OTP 28 中删除。

  • code:lib_dir/2 函数。引入此函数是为了允许读取归档文件中的文件。在 Erlang/OTP 28 中,该函数本身不会被删除,但很可能不再支持查看归档文件。

  • 模块 erl_prim_loader 中处理归档文件的所有功能。该功能很可能在 Erlang/OTP 28 中删除。

  • erl-code_path_choice 标志。在 Erlang/OTP 27 中,默认值已从 relaxed 更改为 strict。此标志可能会在 Erlang/OTP 28 中删除。

为了在 Erlang/OTP 27 中使用归档文件,必须使用标志 -code_path_choice relaxed

在 Escript 中使用单个归档文件被弃用 #

归档文件仍然可以用于保存 Escript 所需的所有文件。但是,要访问归档文件中的文件(例如,读取模板或其他数据文件),唯一保证在未来版本中有效的受支持方法是使用 escript:extract/2 函数。