《Erlang程序设计》

目录

《Erlang程序设计》

越精简的代码, 越复杂, erlang语言是精简中的精简.

就由这本书作为我的Erlang入门吧!

配置Erlang环境看这里:https://blog.csdn.net/weixin_38044597/article/details/118194771

另外我选用IDEA作为开发环境。

这本书有非常多未填的坑,等待机会以后填。

学习感悟

  1. 学习这本书时领会到笔记应该和书有所区别,笔记的结构应当是消化后的结构,而不是按照书上来,做笔记的时机至少应当等到一章的结束,甚至可以一本书的结束,这样才有全局的结构感。才方便作复习用笔记,而不是一个精简版书目。书目是循序渐进的,而笔记应当是全面的,大体上有顺序的。而且这么做有助于提高阅读效率和知识印象。
  2. 笔记不是查阅全书

第1章 什么是并发

并发建模

1-module(person).
2-export([init/1]).
3
4init(Name) -> ...

第1行-module(person).的意思是此文件包含用于person模块的代码。它应该与文件名一致(除了.erl这个文件扩展名) 。模块名必须以一个小写字母开头。从技术上说,模块名是一个原子(atom)。

模块声明之后是一条导出声明。导出声明指明了模块里哪些函数可以从模块外部进行调用。它们类似于许多编程语言里的public声明。没有包括在导出声明里的函数是私有的,无法在模块外调用

-export([init/1]).语法的意思是带有一个参数(/1指的就是这个意思,而不是除以1)的函数init可以在模块外调用。如果想要导出多个函数,就应该使用下面这种语法:

1-export([FuncName1/N1, FuncName2/N2, ...])

方括号[ … ]的意思是“列表”,因此这条声明的意思是我们想要从模块里导出一个函数列表。

创建进程

 1-module(world).
 2-export([start/0]).
 3
 4start() ->
 5	Joe	     = spawn(person, init, ["Joe"]),
 6	Susannah = spawn(person, init, ["Susannah"]),
 7	Dave	 = spawn(person, init, ["Dave"]),
 8	Andy	 = spawn(person, init, ["Andy"]),
 9	Rover	 = spawn(dog, init, ["Rover"]),
10	...
11	Rabbit1	 = spawn(rabbit, init, ["Flopsy"]),
12	...
13

spawn是一个Erlang基本函数,它会创建一个并发进程并返回一个进程标识符。spawn可以这样调用:

1spawn(ModName, FuncName, [Arg1, Arg2, ..., ArgN])

当Erlang运行时系统执行spawn时,它会创建一个新进程(不是操作系统的进程,而是一个由Erlang系统管理的轻量级进程)。当进程创建完毕后,它便开始执行参数所指定的代码。ModName是包含想要执行代码的模块名。FuncName是模块里的函数名,而[Arg1, Arg2, …]是一个列表,包含了想要执行的函数参数。因此,下面这个调用的意思是启动一个执行函数person:init(“Joe”)的进程:

1spawn(person, init, ["Joe"])

spawn的返回值是一个进程标识符(PID,Process IDentifier),可以用来与新创建的进程交互。

Erlang里的模块类似于面向对象编程语言(OOPL,Object-Oriented Programming Language)里的类,进程则类似于OOPL里的对象(或者说类实例)。在Erlang里,spawn通过运行某个模块里定义的函数创建一个新进程。而在Java里,new通过运行某个类中定义的方法创建一个新对象。在OOPL里可以用一个类创建数千个类实例。类似地,在Erlang里我们可以用一个模块创建数千甚至数百万个执行模块代码的进程。

发送消息

启动模拟之后,我们希望在程序的不同进程之间发送消息。在Erlang里,各个进程不共享内存,只能通过发送消息来与其他进程交互。

Joe想要对Susannah说些什么。在程序里我们会编写这样一行代码:

1Susannah ! {self(), "Hope the dogs dont chase the rabbits"}

Pid ! Msg 语法的意思是发送消息Msg到进程Pid。大括号里的self()参数标明了发送消息的进程(在此处是Joe)。

接收消息

为了让Susannah的进程接收来自Joe的消息,要这样写:

1receive
2	{From, Message} ->
3		...
4end

当Susannah的进程接收到一条消息时,变量From会绑定为Joe,这样Susannah就知道消息来自何处,变量Message则会包含此消息。

并发程序和并行计算机

Erlang里的并发程序是由互相通信的多组顺序进程组成的。一个Erlang进程就是一个小小的虚拟机,可以执行单个Erlang函数。别把它和操作系统的进程相混淆。

顺序和并发编程语言

在Erlang里,并发性由Erlang虚拟机提供,而非操作系统或任何的外部库。在大多数顺序编程语言里,并发性都是以接口的形式提供,指向主机操作系统的内部并发函数。区分基于操作系统的并发和基于语言的并发很重要,因为如果使用基于操作系统的并发,那么程序在不同的操作系统上就会有不同的工作方式。Erlang的并发在所有操作系统上都有着相同的工作方式。要用Erlang编写并发程序,只需掌握Erlang,而不必掌握操作系统的并发机制。在Erlang里,进程和并发是我们可以用来定型和解决问题的工具。这让细粒度控制程序的并发结构成为可能,而用操作系统的进程是很难做到的。

第2章 Erlang速览

Shell初探

Erlang shell以横幅信息和计数提示符1>作为响应。然后输入一个表达式,并得到了执行和显示。请注意每一条表达式都必须以一个句号后接一个空白字符结尾。在这个上下文环境里,空白是指空格、制表(Tab)或者回车符。

  • 可以用=操作符给变量赋值(严格来说是给变量绑定一个值)。不能重新绑定变量。Erlang是一种函数式语言,所以一旦定义了X = 123,那么X永远就是123,不允许改变!=不是一个赋值操作符,它实际上是一个模式匹配操作符。与其他函数式编程语言一样,Erlang的变量只能绑定一次。绑定变量的意思是给变量一个值,一旦这个值被绑定,以后就不能改动了。
  • Erlang的变量以大写字母开头。所以X、This和A_long_name都是变量。以小写字母开头的名称(比如monday或friday)不是变量,而是符号常量,它们被称为原子(atom)。

编译与调用

Erlang程序是由许多并行的进程构成的。进程负责执行模块里定义的函数。模块则是扩展名为.erl的文件,运行前必须先编译它们。编译某个模块之后,就可以在shell或者直接从操作系统环境的命令行里执行该模块中的函数了。

1.shell编译hello.erl文件

1-module(hello).
2-export([start/0]).
3
4start() ->
5	io:format("Hellow world~n")/
1$erl
2>c(hello).
3{ok, hello}
4>hello:start().
5Hello world
6ok
7>halt().

c(hello)命令编译了hello.erl文件里的代码。{ok, hello}的意思是编译成功。现在代码已准备好运行了。第2行里执行hello:start()函数。第3行里停止了Erlang shell。

在shell里进行操作的优点是只要平台为Erlang所支持,这种编译和运行程序的方法就一定可用。在操作系统的命令行里的操作可能会因平台的不同而有所差别。

2.shell外编译

1$ erlc hello.erl
2$ erl -noshell -s hello start -s init stop
3Hello world

erlc 从命令行启动了Erlang编译器。编译器编译了 hello.erl 里的代码并生成一个名为hello.beam的目标代码文件。

$erl -noshell …命令加载了 hello模块并执行 hello:start()函数。随后,它执行了init:stop(),这个表达式终止了Erlang会话。

在Erlang shell之外运行Erlang编译器(erlc)是编译Erlang代码的首选方式。可以在Erlang shell里编译模块,但要这么做必须首先启动Erlang shell。使用erlc的优点在于自动化。我们可以在rakefile或makefile内运行erlc来自动化构建过程。

并发实例

1.文件服务器进程

afile_server.erl:

 1-module(afile_server).
 2-export([start/1, loop/1]).
 3
 4start(Dir) -> spawn(afile_server, loop, [Dir]).
 5
 6loop(Dir) ->
 7	receive
 8		{Client, list_dir} ->
 9			Client ! {self(), file:list_dir(Dir)};
10		{Client, {get_file, File}} ->
11            % 获取绝对路径
12			Full = filename:join(Dir, File),
13            % 调用read_file读文件
14			Client ! {self(), file:read_file(Full)}
15	end,
16	loop(Dir).
  • Erlang编写无限循环的方法:

    1loop(Dir) ->
    2	receive
    3      Command ->
    4          .....
    5	end,
    6	loop(Dir)
    

    不用担心最后的自身调用,这不会耗尽栈空间。Erlang对代码采用了一种所谓“尾部调用”的优化,意思是此函数的运行空间是固定的。这是用Erlang编写循环的标准方式,只要在最后调用自身即可。

  • 模式匹配:

    1receive
    2	Pattern1 ->
    3      Actions1;
    4	Pattern2 ->
    5      Actions2 ->
    6      ...
    7	...
    8end
    

    Erlang编译器和运行时系统会正确推断出如何在收到消息时运行适当的代码。不需要编写任何的if-then-else或switch语句来设定该做什么。

  • shell测试:

     11> c(afile_server).
     2{ok, afile_server}
     3% 编译afile_server.erl文件所包含的afile_server模块。
     4  
     52> FileServer = afile_server:start(".").
     6<0.47.0>
     7% afile_server:start(Dir)调用spawn(afile_server, loop, [Dir])。
     8% 这就创建出一个新的并行进程来执行函数afile_server:loop(Dir)并返回一个进程标识符
     9% <0.47.0>是文件服务器进程的进程标识符。它的显示方式是尖括号内由句号分隔的三个整数。
    10  
    113> FileServer ! {self(), list_dir}.
    12{<0.31.0>, list_dir}.
    13% 这里给文件服务器进程发送了一条{self(), list_dir}消息。Pid ! 
    14% Message的返回值被规定为Message,因此shell打印出{self(),list_dir}的值,即{<0.31.0>, list_dir} 
    15  
    164> receive X -> X end.
    17{<0.47.0>, 
    18	{ok, ["afile_server.beam", ...]}
    19}
    20% receive X -> X end接收文件服务器发送的回复。它返回元组{<0.47.0>, {ok, ...}。
    21% 该元组的第一个元素<0.47.0>是文件服务器的进程标识符。
    22% 第二个参数是file:list_dir(Dir)函数的返回值
    

2.客户端代码

afile_client.erl:

 1-module(afile_client).
 2-export([ls/1, get_file/2]).
 3
 4ls(Server) ->
 5	Server ! {self(), list_dir},
 6	receive
 7		{Server, FileList} ->
 8			FileList
 9	end.
10
11get_file(Server, File) ->
12	Server ! {self(), {get_file, File}},
13	receive
14		{Server, Content} ->
15			Content
16	end.

3.测试

 11> c(afile_server).
 2{ok, afile_server}
 32> c(afile_client).
 4{ok, afile_client}
 53> FileServer = afile_server:start(".").
 6<0.43.0>
 74> afile_client:get_file(FileServer, "missing").
 8{error, enoent}
 95> afile_client:get_file(FileServer, "afile_server.erl").
10{ok, <<"-modle(afile_server).\n-export...."}

练习

(4)

运行文件客户端和服务器代码。加入一个名为put_file的命令。你需要添加何种消息?学习如何查阅手册页。查阅手册页里的file模块。

write_file/2:https://www.erlang.org/doc/apps/kernel/file.html#write_file/2

客户端应发送文件名及文件内容:

 1-module(afile_client).
 2-export([ls/1, get_file/2, put_file/3]).
 3
 4ls(Server) ->
 5	Server ! {self(), list_dir},
 6	receive
 7		{Server, FileList} ->
 8			FileList
 9	end.
10
11get_file(Server, File) ->
12	Server ! {self(), {get_file, File}},
13	receive
14		{Server, Content} ->
15			Content
16	end.
17
18put_file(Server, FileName, Content) ->
19    Server ! {self(), {FileName, Content}},
20    receive
21        {Server, response} ->
22            response
23    end.

对应的Server接收文件名和内容,写入文件,并返回操作结果:

 1-module(afile_server).
 2-export([start/1, loop/1]).
 3
 4start(Dir) -> spawn(afile_server, loop, [Dir]).
 5
 6loop(Dir) ->
 7	receive
 8		{Client, list_dir} ->
 9			Client ! {self(), file:list_dir(Dir)};
10		{Client, {get_file, File}} ->
11			Full = filename:join(Dir, File),
12			Client ! {self(), file:read_file(Full)};
13        {Client, {FileName, Content}} ->
14            Full = filename:join(Dir, FileName),
15           	response = file:write_file(Full, list_to_binary(Content)),
16            Client ! {self(), response}
17	end,
18	loop(Dir).

测试失败:目前暂时不去解决,不过要写的文件还是写上去了。

 11> c(afile_server).
 2{ok,afile_server}
 32> c(afile_client).
 4{ok,afile_client}
 53> FileServer = afile_server:start(".").
 6<0.94.0>
 74> afile_client:put_file(FileServer, "test.txt", "Erlang file test").
 8=ERROR REPORT==== 4-Dec-2024::14:44:33.629142 ===
 9Error in process <0.94.0> with exit value:
10{{badmatch,ok},[{afile_server,loop,1,[{file,"afile_server.erl"},{line,14}]}]}

第3章 基本概念

Erlang shell

退出:

1ctrl+c
2
3a 可能导致数据损坏
4q() 是init:stop()命令在shell里的别名。以一种受控的方式停止了系统。所有打开的文件都被刷入缓存并关闭,数据库(如果正在运行的话)会被停止,所有的应用程序都以有序的方式关停
5erlang:halt() 立即停止系统

编写命令快捷键:

1^A 行首
2^D 删除当前字符
3^E 行尾
4^F或右箭头键 向前的字符
5^B或左箭头键 向后的字符
6^P或上箭头键 前一行
7^N或下箭头键 下一行
8^T 调换最近两个字符的位置
9Tab 尝试扩展当前模块或函数的名称

整数运算

16进制乘32进制:

116#cafe * 32#sugar.

变量

  • 所有变量名都必须以大写字母开头。
  • 想知道一个变量的值,只需要输入变量名。
  • 如果试图给变量X指派一个不同的值,就会得到一条错误消息。
  • 在Erlang里怎样表达X = X + 1这类概念?Erlang的方式是创建一个名字未被使用过的新变量(比方说X1),然后编写X1 = X + 1。

Erlang的变量是一次性赋值变量(single-assignment variable)。已被指派一个值的变量称为绑定变量,否则称为未绑定 变量。=是一个模式匹配操作符,它在X为未绑定变量时的表现类似于赋值。

第一次说X=SomeExpression时,Erlang对自己说:“我要做些什么才能让这条语句为真?”因为X还没有值,它可以绑定X到SomeExpression这个值上,这条语句就成立了。如果后期我们说X=AnotherExpression,那么只有在SomeExpression和Another-Expression相等的情况下匹配才会成功

11> X = (2+4).
26
32> Y = 10.
410
5% 在计算这个表达式之前,X等于6,因此匹配成功
63> X = 6.
76
84> X = Y.
9exception...

变量的作用域是它定义时所处的语汇单元。因此,如果X被用在一条单独的函数子句之内,它的值就不会“逃出”这个子句。没有同一函数的不同子句共享全局或私有变量这种说法。如果X出现在许多不同的函数里,那么所有这些X的值都是不相干的。(这一点让编程变得非常困难)

浮点数

  • 用/给两个整数做除法时,结果会自动转换成浮点数。
  • 要从除法里获得整数结果,我们必须使用操作符div和rem:N div M是让N除以M然后舍去余数。N rem M是N除以M后剩下的余数。
  • Erlang在内部使用64位的IEEE 754-1985浮点数,因此使用浮点数的程序会存在和C等语言一样的浮点数取整与精度问题。
11> 5/3.
21.666666666666666667
32> 4/2.
42.0
53> 5 div 3.
61
74> 5 rem 3.
82

原子

  • 原子以小写字母开头,后接一串字母、数字、下划线(_)或at(@)符号,例如:

    1red
    2december
    3cat
    4meters
    5yards
    6joe@somehost
    7a_long_name
    
  • 原子还可以放在单引号(')内。可以用这种引号形式创建以大写字母开头(否则会被解释成变量)或包含字母数字以外字符的原子,例如'Monday''Tuesday''+''*''an atomwith spaces'。甚至可以给无需引号的原子加上引号,因此’a’和a的意思完全一致。

  • 在某些语言里,单引号和双引号可以互换使用。Erlang里不是这样。单引号的用法如前面所示,双引号用于给字符串字面量(string literal)定界

  • 一个原子的值就是它本身。可以充当全局常量的效果,只不过不需要再定义数字了,它本身就有含义。

  • truefalse也是原子

元组

1.创建:

  • Erlang没有类型声明,元组里的字段没有名字。
  • 为了更容易记住元组的用途,一种常用的做法是将原子作为元组的第一个元素,用它来表示元组是什么。
  • 元组还可以嵌套。
  • 元组用于保存固定数量的元素。
1>P = {10, 45}.
2>P = {point, 10, 45}.
3>Person = {person, {name, job}, {height, 1.82}, {footsize, 42}, {eyecolour, brown}}.
  • 如果在构建新元组时用到变量,那么新的元组会共享该变量所引用数据结构的值;
  • 如果试图用未定义的变量创建数据结构,就会得到一个错误。
1>F = {firstName, joe}.
2{firstName, joe}
3>L = {lastName, armstrong}.
4{lastName, armstrong}
5>P = {person, F, L}.
6{person, {firstName, joe}, {firstName, joe}}
7
8>{true, Q, 23, Costs}
9** 1.variable 'Q' is unbound**

2.提取元组的值:

  • 如果想从某个元组里提取一些值,就会使用模式匹配操作符=。在待提取值的位置加入未绑定变量来提取该元组的值。
  • 等号两侧的元组必须有相同数量的元素,而且两侧的对应元素必须绑定为相同的值。原子对应原子。
  • 可以用匿名变量表示不感兴趣的值。
  • 将_作为占位符,用于表示不感兴趣的那些变量。符号_被称为匿名变量。与正规变量不同,同一模式里的多个_不必绑定相同的值。
 1>P = {point, 10, 45}.
 2{point,10,45}.
 3>{point, X, Y} = Point.
 4{point,10,45}
 5>X.
 610
 7>Y.
 845
 9% X绑定了10,Y绑定了45。根据规定,表达式Lhs = Rhs的值是Rhs,因此shell打印出{point,10,45}。
10
11>{point, C, C} = Point.
12** exception error....
13% 模式{point, C, C}与{point, 10, 45}不匹配,因为C不能同时是10和45。因此,这次模式匹配失败,系统打印出了错误消息。
14
15>Person={person, {firstName, joe}, {firstName, joe}}.
16>{_,{_,First}.{_,_}} = Person.
17>First.
18joe
19% 如果有一个复杂的元组,就可以编写一个与该元组形状(结构)相同的模式,并在待提取值的位置加入未绑定变量来提取该元组的值。

列表

  • 列表(list)被用来存放任意数量的事物。创建列表的方法是用中括号把列表元素括起来,并用逗号分隔它们。
  • 列表里的各元素可以是任何类型
  • 列表的第一个元素被称为列表头(head)。假设把列表头去掉,剩下的就被称为列表尾(tail)。
  • 无论何时,只要用[…|T]语法构建一个列表,就应该确保T是列表。可以给T的开头添加不止一个元素,写法是[E1,E2,..,En|T]。
  • 列表用于保存可变数量的元素。

1.定义列表:

1[1+7, hello, 2-2, {cost, apple, 30-20}, 3].
2ThingsToBuy = [{apples, 10}, {pears, 6}, {milk, 3}].
3ThingsToBuy1 = [{oranges, 4}, {newspaper,1}|ThingsToBuy].

2.提取列表元素:

可以用模式匹配操作来提取某个列表里的元素。如果有一个非空列表L,那么表达式[X|Y] = L(X和Y都是未绑定变量)会提取列表头作为X,列表尾作为Y。

1[Buy1|ThingsToBuy2] = ThingsToBuy1.
2% 操作成功,绑定如下:Buy1 = {oranges,4} ,ThingsToBuy2 = [{newspaper,1},然后可以继续拆出{apples,10}, {pears,6}, {milk,3}]。于是我们先去买橙子(oranges)下一对商品。

字符串

1.用字符串字面量来创建一个列表

  • 严格来说,Erlang里没有字符串。要在Erlang里表示字符串,可以选择一个由整数组成的列表或者一个二进制型。当字符串表示为一个整数列表时,列表里的每个元素都代表了一个Unicode代码点(codepoint)。
    • 可以把“美元符号语法”用于这个目的。举个例子,$a实际上就是代表字符a的整数
 1>Name = "Hello".
 2"Hello"
 3
 4
 5> I = $s.
 6115
 7> [I-32, $u, $r, $p, $r, $i, $s, $e].
 8"Surprise"
 9% 可以把“美元符号语法”用于这个目的。举个例子,$a实际上就是代表字符a的整数
10
11> X = "a\x{221e}b"
12> io:format("~ts~n", [X]).
13a。。b
14% 用列表来表示字符串时,它里面的各个整数都代表Unicode字符。必须使用特殊的语法才能输入某些字符,在打印列表时也要选择正确的格式惯例。
15% 在第1行里,我们创建了一个包含三个整数的列表。第一个整数97是字符a的ASCII和Unicode编码。\x{221e}这种记法的作用是输入一个代表Unicode无穷大字符的十六进制整数。98是字符b的ASCII和Unicode编码。
16% 在第2行里,我们用一个格式化I/O语句打印出这个字符串,里面使用了代表无穷大字符的正确字符图案。

2.整数列表的打印:

  • 如果列表内的所有整数都代表可打印字符,它就会将其打印成字符串字面量
  • 否则,打印成列表记法
  • 如果shell将某个整数列表打印成字符串,而你其实想让它打印成一列整数,那就必须使用格式化的写语句
 1>[1,2,3]
 2[1,2,3]
 3>[83,117]
 4"Su"
 5>[1,83,117]
 6[1,83,117]
 7
 8% 列表[1,2,3]在打印时未做转换。这是因为1、2和3不是可打印字符。
 9% 列表里的所有项目都是可打印字符,因此它被打#这个列表以1开头,而1不是可打印字符。因此,这个列表在打印时未做转换。印成字符串字面量。
10% 这个列表以1开头,而1不是可打印字符。因此,这个列表在打印时未做转换。
11
12
13>X = [97,98,99].
14"abc"
15>io:format("~w~n,["abc"]).
16[97,98,99]

模式匹配再探

1[A,B,C,D] = [a,b,c,d,e,f] 
2% 成功

f()命令让shell忘记现有的任何绑定。在这个命令之后,所有变量都会变成未绑定状态。

help()命令提示一些函数。

练习

(3)

试着用一个元组来表示一座房子,再用一个房子列表来表示一条街道。请确保你能向这些结构中加入数据或从中取出数据。

 14> House1 = {house, {location,10,20}, {owner, joe}}.
 2{house,{location,10,20},{owner,joe}}
 35> House2 = {house, {location, -10, 40}, {owner, peter}}.
 4{house,{location,-10,40},{owner,peter}}
 56> Street = [House1, House2].
 6[{house,{location,10,20},{owner,joe}},
 7 {house,{location,-10,40},{owner,peter}}]
 87> [{_,_,{_,House1Owner}},_] = Street.
 9[{house,{location,10,20},{owner,joe}},
10 {house,{location,-10,40},{owner,peter}}]
118> House1Owner.
12joe

第4章 模块与函数

模块介绍

1.示例程序

1-module(geometry).
2-export([area/1]).
3
4area({rectangle, Width, Height}) -> Width * Height;
5area({circle, Radius}) -> 3.14159 * Radius * Radius;
6area({square, Side}) -> Side * Side.
1>c(geometry).
2{ok,geometry}
3>geometry:area({rectangle, 10, 5}).
450
5>geometry:area({square, 3}).
69
  • 第一行是模块声明。声明里的模块名必须与存放该模块的主文件名相同。
  • 第二行是导出声明。Name/N这种记法是指一个带有N个参数的函数Name,N被称为函数的元数(arity)。export的参数是由Name/N项目组成的一个列表。
  • 未从模块里导出的函数只能在模块内调用。
  • area函数有三个子句。这些子句由一个分号隔开,最后的子句以句号加空白结束。每条子句都有一个头部和一个主体,两者用箭头(->)分隔。头部包含一个函数名,后接零个或更多个模式,主体则包含一列表达式,它们会在头部里的模式与调用参数成功匹配时执行。这些子句会根据它们在函数定义里出现的顺序进行匹配。
  • 命令c(geometry),它的作用是编译geometry.erl文件里的代码。编译器返回了{ok,geometry},意思是编译成功,而且geometry模块已被编译和加载。编译器会在当前目录创建一个名为geometry.beam的目标代码模块。
  • 在第2行和第3行调用了geometry模块里的函数。请注意,需要给函数名附上模块名,这样才能准确标明想调用的是哪个函数。
  • 我们的函数并不处理模式匹配失败的情形,程序会以一个运行时错误结束。这是有意而为的,是在Erlang里编程的方式。
  • Erlang代码里,我们只需要编写模式,Erlang编译器就会生成最佳的模式匹配代码,用它来选择正确的程序入口点。
  • 逗号(,)分隔函数调用、数据构造和模式中的参数。
  • 分号( ; )分隔子句。我们能在很多地方看到子句,例如函数定义,以及 case 、 if 、try..catch和receive表达式
  • 句号(.)(后接空白)分隔函数整体,以及shell里的表达式。

2.Erlang shell内建命令

  • pwd()打印当前工作目录。
  • ls()列出当前工作目录里所有的文件名。
  • cd(Dir)修改当前工作目录至Dir。

结算程序

shop.erl:

1-module(shop).
2-export([cost/1]).
3
4cost(oranges) -> 5;
5cost(newspaper) -> 8;
6cost(apples) -> 2;
7cost(pears) -> 9;
8cost(milk) -> 7;

shop1.erl:

1-module(shop1).
2-export([total/1]).
3
4total([What, N]|T) -> shop:cost(What) * N + total(T);
5total([]) -> 0.
1>c(shop).
2>c(shop1).
3>shop1:total([milk,3]).
421
5>shop1:total([{pears,6},{milk,3}]).
675

有点像递归调用地处理列表中的每个元组,其他的没有什么知识。这样可以创造for循环

基本的抽象单元fun

  • 函数式编程语言表示函数可以被用作其他函数的参数,也可以返回函数。操作其他函数的函数被称为高阶函数(higher-order function),而在Erlang中用于代表函数的数据类型被称为fun
  • funs是“匿名的”函数。其他编程语言称它们为lambda抽象。
  • fun可以有多个不同的子句。
  • 括号里的东西就是返回值。也可以说->后面的东西。

1.基本概念

 1> Double = fun(X) -> 2*X end.
 2#Fun<erl_eval.6.56006584>
 3> Double(2).
 44
 5
 6> Hypot = fun(X,Y) -> math:sqrt(X*X, Y*Y) end.
 7> Hypot(3,4).
 85.0	
 9> Hypot(3).
10** exception 元数不正确
11
12> TempConvert = fun({c,C}) -> {f, 32 + C*9/5};
13				   ({f,F}) -> {c, (F-32)*5/9}
14				end.
15>TempConvert({c,100}).
16{f,212.0}

2.以fun作为参数的函数示例

  • lists:map(F,L)。这个函数返回的是一个列表,它通过给列表L里的各个元素应用fun F生成。
  • lists:filter(P, L),它返回一个新的列表,内含L中所有符合条件的元素(条件是对元素E而言P(E)为true)。
  • =:=用来测试是否相等。
1> L = [1,2,3,4].
2> lists:map(fun(X) -> 2*X end, L).
3[2,4,6,8]
4
5> Even = fun(X) -> (X rem 2) =:= 0 end.
6> lists:filter(Even, [1,2,3,4,5,6,8]).
7[2,4,6,8]

3.返回fun的函数示例

对于给定列表,生成函数,用于判断单个变量是否在这个列表里面:

1> Fruit = [apple,pear,orange].
2> MakeTest = fun(L) -> (fun(X) -> lists:member(X,L) end) end.
3> IsFruit = MakeTest(Fruit).
4> IsFruit(pear).
5true
6> IsFruit(dog).
7false

指定倍数生成一个函数,这个函数的返回值是参数的指定倍数:

1> Mult = fun(Times) -> ( fun(X) -> X * Times end) end.
2> Triple = Mult(3).
3> Triple(5).
415

4.自定义for循环

  • 创建自己的控制结构能大大降低程序的大小,有时还能让它们更加清晰。这是因为你能精确地创建出解决问题所需要的控制结构,同时还不受编程语言自带的少量固定控制结构所限。

lib_misc.erl:

1% 执行for(1,10,F)会创建列表[F(1), F(2), ..., F(10)]
2for(Max, Max, F) -> [F(Max)];
3for(I, Max, F) -> [F(I)|for(I+1, Max, F)].
1lib_misc:for(1,10,fun(I) -> I end).
2lib_misc:for(1,10,fun(I) -> I*I end).

优化结算程序

mylists.erl:

1-module(mylists)
2-export([map/2, sum/1]).
3
4sum([H|T]) -> H + sum(T);
5sum([]) -> 0.
6
7map(_,[]) ->[];
8map(F, [H|T]) -> [F(H)|map(F,T)].

shop2.erl:

1-module(shop2).
2-export([total/1]).
3-import(mylists, [map/2,sum/1]).
4
5total(L) ->
6	sum(map(fun({What,N}) -> shop:cost(What) * N end, L)).

列表推导

1.基础概念

  • 列表推导(list comprehension)是无需使用fun、map或filter就能创建列表的表达式。它让程序变得更短,更容易理解。

  • 列表推导最常规的形式是下面这种表达式:

    1[X || Qualifier1, Qualifier2, ...]
    

    X是任意一条表达式,后面的限定符(Qualifier)可以是生成器、位串生成器或过滤器。

    • 生成器(generator)的写法是Pattern <- ListExpr,其中的ListExp必须是一个能够得出列表的表达式。
    • 位 串 ( bitstring )生成器的写法是 BitStringPattern <= BitStringExpr , 其 中 的BitStringExpr必须是一个能够得出位串的表达式。
    • 过滤器(filter)既可以是判断函数(即返回true或false的函数),也可以是布尔表达式。
  • 这个限定符的运算顺序是从左往右的。

 1> L = [1,2,3,4,5].
 2> lists:map(fun(X) -> 2*X end, L).
 3> [2*X || X <- L].
 4[2,4,6,8,10]
 5
 6% 列表推导的购物结算程序
 7> Buy=[{oranges,4}.{newspaper,1},{apples,10},{pears,6},{milk,3}].
 8> [{Name, 2*Number} || {Name, Number} <- Buy].
 9[{oranges,8}, {newspaper,2},{apples,20},{pears,12},{milk,6}]
10% ||符号右侧的元组{Name, Number}是一个模式
11% 左侧的元组{Name, 2*Number}则是一个构造器(constructor)。
12> [{shop:cost(A)* B} || {A, B} <- Buy].
13[20,8,20,54,21]
14> lists:sum([shop:cost(A) * B || {A,B} <- Buy]).
15123

2.几个例子:

归并排序:

1qsort([]) -> [];
2qsort([Pivot|T]) ->
3		qsort([X || X <- T, X < Pivot])
4		++ [Pivot] ++
5		qsort([X || X <- T, X >= Pivot]).
  • ++是中缀插入操作符。
  • 将T分成两个列表,一个包含T里所有小于Pivot(中位数)的元素,另一个包含所有大于或等于Pivot的元素。所以这里第二个限定符就是过滤器。

查找勾股数(暴力遍历):

1pythag(N) ->
2	[ {A,B,C} ||
3		A <- lists:seq(1,N),
4		B <- lists:seq(1,N),
5		C <- lists:seq(1,N),
6		A+B+C =< N,
7		A*A+B*B =:= C*C
8	].
  • lists:seq(1, N)返回一个包含从1到N所有整数的列表。

单词字母的所有组合:

1perms([]) -> [[]];
2perms(L) -> [[H|T] || H <- L, T <- perms(L--[H])].
  • X -- Y是列表移除操作符,它从X里移除Y中的元素。
  • 穷尽一切可能从L里提取H(单个字母),然后穷尽一切可能从perms(L – [H])(即列表L移除H后的所有排列形式)里提取T,最后返回[H|T]。

内置函数

  • 内置函数简称为BIF(built-in function),是那些作为Erlang语言定义一部分的函数。有些内置函数是用Erlang实现的,但大多数是用Erlang虚拟机里的底层操作实现的。

  • 所有内置函数都表现得像是属于 erlang模块,但那些最常用的内置函数(例如 list_to_tuple)是自动导入的

  • 内置函数文档:http://www.erlang.org/doc/man/erlang.html

    (在erts/erlang模块里面)

  • 内置函数list_to_tuple/1能将一个列表转换成元组

  • time/0以{时,分,秒}的格式返回当前的时间。

关卡

  • 关卡(guard)是一种结构,可以用它来增加模式匹配的威力。通过使用关卡,可以对某个模式里的变量执行简单的测试和比较。
  • 关卡序列(guard sequence)是指单一或一系列的关卡,用分号(;)分隔。对于关卡序列G1;G2; …; Gn,只要其中有一个关卡(G1、G2……)的值为true,它的值就为true。
  • 关卡由一系列关卡表达式组成,用逗号(,)分隔。关卡GuardExpr1, GuardExpr2, … ,GuardExprN只有在所有的关卡表达式(GuardExpr1、GuardExpr2……)都为true时才为true。
  • 合法的关卡表达式是所有合法Erlang表达式的一个子集。
  • 关卡不能调用用户定义的函数,因为要确保它们没有副作用并能正常结束。
  • 合法的关卡表达式:
    • 原子true;
    • 其他常量(各种数据结构和已绑定变量)它们在关卡表达式里都会成为false;
    • 调用后面表1里的关卡判断函数和表2里的内置函数;
    • 数据结构比较(参见表6);
    • 算术表达式(参见表3);
    • 布尔表达式(参见8.7节);
    • 短路布尔表达式(参见8.23节)。
  • 短路布尔表达式:orelse和andalso
  • orelseandalso操作符存在的原因是布尔操作符and/or原本的定义是两侧参数都需要求值。在关卡里,(and与andalso)之间和(or与orelse)之间可能会存在差别。
  • 原子true可以被当作“来者不拒”的关卡

表1 关卡判断函数:

判断函数 意思
is_atom(X) X是一个原子
is_binary(X) X是一个二进制型
is_constant(X) X是一个常量
is_float(X) X是一个浮点数
is_function(X) X是一个fun
is_function(X, N) X是一个带有N个参数的fun
is_integer(X) X是一个整数
is_list(X) X是一个列表
is_map(X) X是一个映射组
is_number(X) X是一个整数或浮点数
is_pid(X) X是一个进程标识符
is_pmod(X) X是一个参数化模块的实例
is_port(X) X是一个端
is_reference(X) X是一个引用
is_tuple(X) X是一个元组
is_record(X,Tag) X是一个类型为Tag的记录
is_record(X,Tag,N) X是一个类型为Tag、大小为N的记录

表2 关卡内置函数:

函数 意思
abs(X) X的绝对值
byte_size(X) X的字节数,X必须是一个位串或二进制型
element(N, X) X里的元素N,注意X必须是一个元组
float(X) 将X转换成一个浮点数,X必须是一个数字
hd(X) 列表X的列表头
length(X) 列表X的长度
node() 当前的节点
node(X) 创建X的节点,X可以是一个进程、标识符、引用或端口
round(X) 将X转换成一个整数,X必须是一个数字
self() 当前进程的进程标识符
size(X) X的大小,它可以是一个元组或二进制型
trunc(X) 将X去掉小数部分取整,X必须是一个数字
tl(X) 列表X的列表尾
tuple_size(T) 元组T的大小
 1max(X,Y) when X > Y -> X;
 2max(X,Y) -> Y.
 3% 子句1会在X大于Y时匹配,结果是X。如果子句1不匹配,系统就会尝试子句2。
 4% 子句2总是返回第二个参数Y。Y必然是大于或等于X的,否则子句1就已经匹配了。
 5
 6f(X,Y) when is_integer(T), X>Y, Y<6 -> ...
 7% 当X是一个整数,X大于Y并且Y小于6时
 8is_tuple(T), tuple_size(T) =:= 6, abs(element(3,T)) > 5
 9% T是一个包含六个元素的元组,并且T中第三个元素的绝对值大于5
10element(4,X) =:= hd(L)
11% 元组X的第4个元素与列表L的列表头相同
12A >= -1.0 andalso A+1>B
13is_atom(L) orelse (is_list(L) andalse length(L) > 2)
14                                      
15f(X) when (X == 0) or (1/X > 2) -> ...
16g(X) when (X == 0) orelse (1/X > 2) -> ...
17% 当X为0时,f(X)里的关卡会失败,但g(X)里的关卡会成功。

case和if表达式

  • case表达式的语法如下:

    1case Expression of
    2	Pattern1 [when Guard1] -> Expr_seq1;
    3	Pattern2 [when Guard2] -> Expr_seq2;
    4	...
    5end
    

    首先,Expression被执行,假设它的值为Value。随后,Value轮流与Pattern1(带有可选的关卡Guard1)、Pattern2等模式进行匹配,直到匹配成功。一旦发现匹配,相应的表达式序列就会执行,而表达式序列执行的结果就是case表达式的值。如果所有模式都不匹配,就会发生异常错误(exception)。

  • 条件句式if,语法如下:

    1if Guard1 ->
    2	Expr_seq1;
    3   Guard2 ->
    4      Expr_seq2;
    5   ...
    6end
    

    首先执行Guard1。如果得到的值为true,那么if的值就是执行表达式序列Expr_seq1所得到的值。如果Guard1不成功,就会执行Guard2,以此类推,直到某个关卡成功为止。if表达式必须至少有一个关卡的执行结果为true,否则就会发生异常错误。

  • if是一种表达式,而所有的表达式都应该有值。为了避免可能的异常错误,Erlang程序员经常会在if表达式的最后添加一个true关卡。当然,如果他们想让异常错误生成,就会省略额外的true关卡。

  • if的最后一个分支不需要加符号.

使用case定义filter:

1filter(P, [H|T]) ->
2	case P(H) of
3		true -> [H|filter(P,T)];
4		false -> filter(P,T)
5	end;
6	
7filter(P, []) ->
8	[].

只使用模式匹配定义filter:

1filter(P, [H|T]) -> filter1(P(H), H, P, T);
2filter(P, []) -> [].
3
4filter1(true, H, P, T) -> [H|filter(P,T)];
5filter1(false,H, P, T) -> filter(P, T);

if例子:

1if
2	A>0 ->
3		do_this();
4	true ->
5		do_that();
6end

构建列表的方式

  • 构建列表最有效率的方式是向某个现成列表的头部添加元素,因此经常能看到包含以下模式的代码:

    1some_function([H|T],...,Result,...) ->
    2	H1 = ... H ...,
    3	some_function(T, ..., [H1|Result], ...);
    4some_function([], ..., Result, ...) ->
    5	{..., Result, ...}.
    

    这段代码会遍历一个列表,提取出列表头H并根据函数的算法计算出某个值(可以称之为H1),然后把H1添加到输出列表Result里。当输入列表被穷尽后,最后的子句匹配成功,函数返回输出变量Result。

  • 建议:

    • 总是向列表头添加元素。
    • 从输入列表的头部提取元素,然后把它们添加到输出列表的头部,形成的结果是与输入列表顺序相反的输出列表。
    • 如果顺序很重要,就调用lists:reverse/1这个高度优化过的函数。

归集器

两种将列表中的元素分为奇数和偶数的代码:

1.列表推导

1odds_and_evens1(L) ->
2    Odds = [X || X <- L, (X rem 2) =:= 1],
3    Evens = [X || X <- L, (X rem 2) =:= 0],
4    {Odds, Evens}.

2.归集器

 1odds_and_evens2(L) ->
 2    odds_and_evens_acc(L, [], []).
 3
 4odds_and_evens_acc([H|T], Odds, Evens) ->
 5    case (H rem 2) of
 6        1 -> odds_and_evens_acc(T,[H|Odds],Even);
 7        0 -> odds_and_evens_acc(T,Odds,[H|Even])
 8    end;
 9odds_and_evens_acc([],Odds,Evens) ->
10    {Odds, Evens}.
  • 第一段代码遍历了两次
  • 第二段程序只遍历列表一次,把奇偶参数分别添加到合适的列表里。这些列表被称为归集器(accumulator)。
  • 这段代码还有一个不太明显的额外的优点:带归集器的版本比[H || filter(H)]类型结构的版本更节省空间。
  • 第二段奇偶列表里的元素顺序是反转的。这是列表的构建方式所导致的结果。

练习

(1)

扩展geometry.erl。添加一些子句来计算圆和直角三角形的面积。添加一些子句来计算各种几何图形的周长。

 1-module(geometry).
 2-export([area/1, perimeter/1]).
 3
 4% 面积
 5area({rectangle, Width, Height}) -> Width * Height;
 6area({circle, Radius}) -> 3.14159 * Radius * Radius;
 7area({square, Side}) -> Side * Side;
 8area({right_triangle, Width, Height}) -> 0.5 * Width * Height.
 9
10% 周长
11perimeter({rectangle, Width, Height}) -> 2 * (Width + Height);
12perimeter({circle, Radius}) -> 2 * 3.14159 * Radius;
13perimeter({square, Side}) -> 4 * Side;
14perimeter({right_triangle, Width, Height}) -> Width + Height + math:sqrt(Width*Width + Height*Height).

(2)

内置函数 tuple_to_list(T) 能将元组 T 里的元素转换成一个列表。请编写一个名为my_tuple_to_list(T)的函数来做同样的事,但不要使用相同功能的内置函数。

erlang程序看似人畜无害, 实则非常难以编写.

1-module(mylist).
2-export([my_tuple_to_list/1]).
3
4my_tuple_to_list({}) -> [];
5my_tuple_to_list(T) ->
6    [element(1,T)|my_tuple_to_list(erlang:delete_element(1,T))].

(3)

查看 erlang:now/0 、 erlang:date/0 和 erlang:time/0 的定义 。 编写一个名为my_time_func(F)的函数,让它执行fun F并记下执行时间。编写一个名为my_date_string()的函数,用它把当前的日期和时间改成整齐的格式。

  • erlang:now/0已经过期, 使用erlang:timestamp/0替代, 获取当前Erlang系统时间。
  • erlang:date/0将当前日期返回为{年、月、日}。
  • erlang:time/0将当前时间返回为{小时、分钟、秒}。

由于找不到如何处理时间差, 这个问题暂时就不处理了.

(4)

高级练习:查找Python datetime模块的手册页。找出Python的datetime类里有多少方法可以通过erlang模块里有关时间的内置函数实现。在erlang的手册页里查找等价的函数。如果有明显的遗漏,就实现它。

时间开销太大, 留在这以后说.

(5)

编写一个名为math_functions.erl的模块,并导出函数even/1和odd/1。even(X)函数应当在X是偶整数时返回true,否则返回false。odd(X)应当在X是奇整数时返回true。

 1-module(math_functions).
 2-export([even/1,odd/1]).
 3
 4even(T) ->
 5    if 
 6        (T rem 2) =:= 0 andalso is_integer(T) ->
 7            true;
 8        true ->
 9            false
10    end.
11
12
13odd(T) ->
14    if
15        (T rem 2) =:= 1 andalso is_integer(T) ->
16            true;
17        true ->
18            false
19    end.

(6)

向math_functions.erl添加一个名为filter(F, L)的高阶函数,它返回L里所有符合条件的元素X(条件是F(X)为true)。

 1-module(math_functions).
 2-export([even/1,odd/1,filter/2]).
 3
 4even(T) ->
 5    if 
 6        (T rem 2) =:= 0 andalso is_integer(T) ->
 7            true;
 8        true ->
 9            false
10    end.
11
12
13odd(T) ->
14    if
15        (T rem 2) =:= 1 andalso is_integer(T) ->
16            true;
17        true ->
18            false
19    end.
20
21filter(F,L) -> 
22    [X || X <- L, F(X)].

(7)

向math_functions.erl添加一个返回{Even, Odd}的split(L)函数,其中Even是一个包含L里所有偶数的列表,Odd是一个包含L里所有奇数的列表。请用两种不同的方式编写这个函数,一种使用归集器,另一种使用在练习6中编写的filter函数。

由于归集器在教材中的写法已经满足了本题的需要, 故只编写第二种.

注意分号;是用来分隔子句的.

 1-module(math_functions).
 2-export([filter/2,split/1]).
 3
 4
 5filter(F,L) -> 
 6    [X || X <- L, F(X)].
 7
 8split(L) ->
 9    Even = fun(T) ->
10    	if 
11        	(T rem 2) =:= 0 andalso is_integer(T) ->
12            	true;
13        	true ->
14            	false
15    	end
16    end,
17
18
19	Odd = fun(T) ->
20    		if
21        		(T rem 2) =:= 1 andalso is_integer(T) ->
22           	 		true;
23        		true ->
24            		false
25            end
26    end,
27    
28    Odds = filter(Odd, L),
29    Evens = filter(Even, L),
30    {Odds, Evens}.

第5章 记录与映射组

  • 记录其实就是元组的另一种形式。通过使用记录,可以给元组里的各个元素关联一个名称。
  • 映射组是键值对的关联性集合。键可以是任意的Erlang数据类型。
  • 与其记住某个数据项在复杂数据结构里的存放位置,不如使用该项的名称,让系统找到数据存放的位置。记录使用一组固定且预定义的名称,而映射组可以动态添加新的名称。

映射组/记录的选择

记录:

  • 当你可以用一些预先确定且数量固定的原子来表示数据时;
  • 当记录里的元素数量和元素名称不会随时间而改变时;
  • 当存储空间是个问题时,典型的案例是你有一大堆元组,并且每个元组都有相同的结构。

映射组:

  • 当键不能预先知道时用来表示键-值数据结构;
  • 当存在大量不同的键时用来表示数据;
  • 当方便使用很重要而效率无关紧要时作为万能的数据结构使用;
  • 用作“自解释型”的数据结构,也就是说,用户容易从键名猜出值的含义;
  • 用来表示键-值解析树,例如XML或配置文件;
  • 用JSON来和其他编程语言通信。

记录的使用

  1. 记录声明

    1-record(Name, {
    2	%% 下面两个键带有默认值
    3	key1 = Default1,
    4	key2 = Default2,
    5	...
    6	%% 相当于未定义
    7	key3,
    8	...
    9}).
    

    Name是记录名。key1、key2这些是记录所含各个字段的名称,它们必须是原子。记录里的每个字段都可以带一个默认值,如果创建记录时没有指定某个字段的值,就会使用默认值。

  2. 引用记录定义

    记录的定义既可以保存在Erlang源代码文件里,也可以由扩展名为.hrl的文件保存,然后包含在Erlang源代码文件里

    文件包含是唯一能确保多个Erlang模块共享相同记录定义的方式。

    records.hrl:

    1-record(todo, {status=reminder,who=joe,text}).
    
  3. shell里,必须先把记录的定义读入shell,然后才能创建记录。我们将用shell函数rr(read records的缩写,即读取记录)来实现。

    1> rr("records.hrl")
    2[todo]
    
  4. 创建记录

    1   #todo{key1=Val1, ..., keyN=ValN}
    

    所有的键都是原子,而且必须与记录定义里所用的一致。

    如果省略了一个键,系统就会用记录定义里的值作为该键的默认值。

    1> #todo{}
    2#todo{status = reminder,who = joe,text = undefined}
    3> X1 = #todo{status=urgent, text="Fix errata in book"}.
    4#todo{status = urgent,who = joe,text = "Fix errata in book"}
    
  5. 复制记录

    这么做生成的是原始记录的一个副本,原始记录没有变化。

    1> X2 = X1#todo{status=done}
    2%% 创建一个X1的副本(类型必须是todo),并修改字段status的值为done。
    
  6. 提取记录字段

    使用模式匹配

    1> #todo{who=W, text=Txt} = X2.
    2> W.
    3joe
    4> Txt.
    5"Fix errata in book"
    
  7. 函数中模式匹配记录

    以编写模式匹配记录字段或者创建新记录的函数:

    1clear_status(#todo{status=S,who=W} = R) ->
    2	R#todo{status=finished}
    3                                               
    4%% S和W绑定了记录里的字段值
    5%% R是整个记录
    

    匹配某个类型的记录:

    1do_something(X) when is_record(X, todo) ->
    2	%% ...
    

    使用is_record函数匹配todo类型的记录。

  8. 记录是元组的另一种形式

    1> X2.
    2#todo{status = done,who = joe,text = "Fix errata in book"}
    3> rf(todo).
    4ok
    5> X2.
    6{todo,done,joe,"Fix errata in book"}
    

    rf(todo)命令使shell忘了todo记录的定义。现在打印X2时,shell将X2显示成一个元组

映射组的使用

  1. 属性

    • 映射组的语法与记录相似,不同之处是省略了记录名,并且键值分隔符是=>或:=。
    • 映射组是键-值对的关联性集合。
    • 映射组里的键可以是任何全绑定的Erlang数据类型(即数据结构里没有任何未绑定变量)。
    • 映射组里的各个元素根据键进行排序。
    • 在不改变键的情况下更新映射组是一种节省空间的操作。
    • 查询映射组里某个键的值是一种高效的操作。
    • 映射组有着明确的顺序。
  2. 创建映射组

    1#{Key1 Op Val1, Key2 Op Val2, ..., KeyN Op ValN}
    

    Op是=>或:=这两个符号的其中一个。

    键和值可以是任何有效的Erlang数据类型。

    1> F1 = #{a=>1, b=>2}.
    2#{a=>1, b=>2}.
    3> Facts = #{{wife,fred}=>"Sue", {age,fred}=>45}.
    
  3. 映射组在系统内部是作为有序集合存储的,打印时总是使用各键排序后的顺序,与映射组的创建方式无关。

    1> F2 = #{b=>1, a=>2}.
    2#{a=>2, b=>1}.
    
  4. 基于现有的映射组更新一个映射组:

    1NewMap = OldMap #{K1 Op V1, ..., Kn Op Vn}
    

    表达式K => V有两种用途,一种是将现有键K的值更新为新值V,另一种是给映射组添加一个全新的K-V对。这个操作总是成功的。

    表达式K := V的作用是将现有键K的值更新为新值V。如果被更新的映射组不包含键K,这个操作就会失败。

    使用映射组的最佳方式是在首次定义某个键时总是使用Key => Val,而在修改具体某个键的值时都使用Key := Val。

    (操作是深拷贝的,这两个变量是不相关的)

  5. 模式匹配映射组字段

    用来编写映射组的=>语法还可以作为映射组模式使用。和之前一样,映射组模式里的键不能包含任何未绑定变量,但是值现在可以包含未绑定变量了(在模式匹配成功后绑定)。

    (TODO 我测试这段代码失败了,不管怎么匹配都是错误的,这里留一个坑。)

    1> Henry8 = #{class=>king,born=>1491,died=>1547}.
    2> #{born => B} = Henry8.
    3> B.
    41491
    5> #{ D => 1547} = Henry8.
    6unbound
    
  6. 函数头部使用包含模式的映射组

    count_characters(Str)函数,让它返回一个映射组,内含某个字符串里各个字符的出现次数。

     1%% 入口
     2count_characters(Str) ->
     3	count_characters(Str, #{}).
     4   	
     5%% 核心
     6count_characters([H|T], #{ H => N}=X) ->
     7	count_characters(T,X#{ H := N+1});
     8%% 上一条没匹配成功,表明这个字母的计数是第一次
     9count_characters([H|T], X) ->
    10	count_characters(T,X#{ H => 1});
    11%% 处理完毕
    12count_characters([], X) ->
    13	X.
    
  7. 映射组api:

    • map:new() -> #{} 返回一个新的空映射组。
    • erlang:is_map(M) -> bool() 如果M是映射组就返回true,否则返回false。它可以用在关卡测试或函数主体中。
    • maps:to_list(M) -> [{K1,V1, ..., {Kn,Vn}] 把映射组M里的所有键和值转换成一个键值列表。键在生成的列表里严格按升序排列。
    • maps:from_list([{K1,V1}, ..., {Kn,Vn}]) -> M把一个包含键值对的列表转换成映射组M。如果同样的键不止一次出现,就使用列表里第一个键所关联的值,后续的值都会被忽略。
    • maps:map_size(Map) -> NumberOfEntries返回映射组里的条目数量。
    • maps:is)key(Key,Map)-> bool()如果映射组包含一个键为Key的项就返回true,否则返回false。
    • maps:get(Key,Map)->Val返回映射组里与Key关联的值,否则抛出一个异常错误。
    • maps:find(Key,Map)->{ok,Value}|error返回映射组里与Key关联的值,否则返回error。
    • maps:keys(Map)->[Key1,...,KeyN]返回映射组所含的键列表,按升序排列。
    • maps:remove(Key,M)->M1返回一个新映射组M1,除了键为Key的项(如果有的话)被移除外,其他与M一致。
    • maps:without([Key1,...,KeyN],M)->M1返回一个新映射组M1,它是M的复制,但移除了带有[Key1,…,KeyN]列表里这些键的元素。
    • maps:difference(M1,M2)->M3M3是M1的复制,但移除了那些与M2里的元素具有相同键的元素。
  8. 映射组排序规则

    映射组在比较时首先会比大小,然后再按照键的排序比较键和值。

    • 如果A和B是映射组,那么当maps:size(A) < maps:size(B)时A < B。
    • 如果A和B是大小相同的映射组,那么当maps:to_list(A) < maps:to_list(B)时A < B。
    • 当映射组与其他Erlang数据类型相比较时,因为我们认为映射组比列表或元组“更复杂”,所以映射组总是会大于列表或元组。
    1A = #{age => 23, person => "jim"} < B = # {email => "[email protected]", name => "sue"}
    2   
    3%% A的最小键(age)比B的最小键(email)更小。
    
  9. 映射组可以通过io:format里的~p选项输出,并用io:readfile:consult读取。

  10. 映射组和JSON相关api

    • maps:to_json(Map) ->Bin 把一个映射组转换成二进制型,它包含用JSON表示的该映射组。
    • maps:from_json(Bin) -> Map 把一个包含JSON数据的二进制型转换成映射组。
    • maps:safe_from_json(Bin) -> Map 把一个包含JSON数据的二进制型转换成映射组。Bin里的任何原子必须在调用此内置函数前就已存在,否则就会抛出一个异常错误。这样做是为了防止创建大量的新原子。出于效率的原因,Erlang不会垃圾回收(garbage collect)原子,所以连续不断地添加新原子会(在很长一段时间后)让Erlang虚拟机崩溃。

    上面两种定义里的Map都必须是json_map()类型的实例。

    (目前maps模块已经没有这些函数了,还是需要另外查询)

  11. JSON对象与Erlang值的映射关系:json_map()类型

    1-type json_map() = [{json_key(), json_value()}].
    2    
    3-type json_key() = 
    4	atom() | binary() | io_list()
    5    	
    6-type json_value() = 
    7	integer() | binary() | float() | atom() | [json_value()] | json_map()
    
    • JSON的数字用Erlang的整数或浮点数表示。
    • JSON的字符串用Erlang的二进制型表示。
    • JSON的列表用Erlang的列表表示。
    • JSON的true和false用Erlang的原子true和false表示。
    • JSON的对象用Erlang的映射组表示,但是有限制:映射组里的键必须是原子、字符串或二进制型,而值必须可以用JSON的数据类型表示。

    当来回转换JSON数据类型时,应当注意一些特定的转换限制。Erlang对整数提供了无限的精度。所以,Erlang会很自然地把映射组里的某个大数转换成JSON数据里的大数,而解码此JSON数据的程序不一定能理解它。

练习

(1)

配置文件可以很方便地用JSON数据表示。请编写一些函数来读取包含JSON数据的配置文件,并将它们转换成Erlang的映射组。再编写一些代码,对配置文件里的数据进行合理性检查。

json:

1{
2	{username, liming},
3	{password, 123456},
4	{location, beijing},
5	{male, true}
6}

等到以后再来。

(2)

编写一个map_search_pred(Map, Pred) 函数,让它返回映射组里第一个符合条件的{Key,Value}元素(条件是Pred(Key, Value)为true)。

 1-module(map_search).
 2-export([map_search_pred/2]).
 3
 4map_search_pred(Map, Pred) ->
 5	list_search_pred(maps:to_list(Map), Pred).
 6
 7list_search_pred([H|T], Pred) -> 
 8	{K,V} = H,
 9	case Pred(K,V) of
10		true -> {K,V};
11		false -> list_search_pred(T, Pred)
12	end;
13list_search_pred([], Pred) ->
14	"None of them matchs".

测试:

1Pred = fun(a,V) -> V=:=1;
2		  (b,V) -> V=:=2
3	   end.
4	   
5M1 = #{a=>2, b=>2}.

第6章 顺序程序的错误处理

书本内容

  1. 在Erlang里,单个进程的崩溃就不那么重要了,前提是其他某些进程能察觉这个崩溃,并接手崩溃进程原本应该做的事情。

  2. Erlang里,防御式编程是内建的。在描述函数的行为时应该只考虑合法的输入参数,其他所有参数都将导致内部错误并自动被检测到。永远不能让函数对非法的参数返回值,而是应该抛出一个异常错误。这条规则被称为“任其崩溃”。

  3. 显式生成错误:

    • exit(Why) 当你确实想要终止当前进程时就用它。如果这个异常错误没有被捕捉到,信号{‘EXIT’,Pid,Why}就会被广播给当前进程链接的所有进程。
    • throw(Why) 抛出一个调用者可能想要捕捉的异常错误。在这种情况下,我们注明了被调用函数可能会抛出这个异常错误。有两种方法可以代替它使用:可以为通常的情形编写代码并且有意忽略异常错误,也可以把调用封装在一个try…catch表达式里,然后对错误进行处理。
    • error(Why) 这个函数的作用是指示“崩溃性错误”,也就是调用者没有准备好处理的非常严重的问题。它与系统内部生成的错误差不多。
  4. Erlang有两种方法来捕捉异常错误。第一种是把抛出异常错误的调用函数封装在一个try...catch表达式里,另一种是把调用封装在一个catch表达式里。

  5. try catch语法

     1try FuncOrExpressionSeq of
     2	Pattern1 [when Guard1] -> Expressions1;
     3	Pattern2 [when Guard2] -> Expressions2;
     4	...
     5catch
     6	ExceptionType1: ExPattern1 [when ExGuard1] -> ExExpressions1;
     7	ExceptionType2: ExPattern2 [when ExGuard2] -> ExExpressions2;
     8	...
     9after
    10	AfterExoressuibs
    11end
    

    首先执行FuncOrExpessionSeq。如果执行过程没有抛出异常错误,那么函数的返回值就会与Pattern1(以及可选的关卡Guard1)、Pattern2等模式进行匹配,直到匹配成功。如果能匹配,那么整个try…catch的值就通过执行匹配模式之后的表达式序列得出。

    如果FuncOrExpressionSeq在执行中抛出了异常错误,那么ExPattern1等捕捉模式就会与它进行匹配,找出应该执行哪一段表达式序列。ExceptionType是一个原子(throw、exit和error其中之一),它告诉我们异常错误是如何生成的。如果省略ExceptionType,就会使用默认值throw。

    关键字after之后的代码是用来在FuncOrExpressionSeq结束后执行清理的。这段代码一定会被执行 , 哪怕有异常错误抛出也是如此。after区块的代码会在try或catch区块里的Expressions代码完成后立即运行。AfterExpressions的返回值会被丢弃。

  6. try catch简写

    1try F
    2catch
    3	...
    4end
    
  7. try catch样例

    try_test.erl:

     1generate_exception(1) -> a;
     2generate_exception(2) -> throw(a);
     3generate_exception(3) -> exit(a);
     4generate_exception(4) -> {'EXIT', a};
     5generate_exception(5) -> error(a);
     6   
     7demo1() ->
     8    [catcher(I) || I <- [1,2,3,4,5]].
     9   
    10catcher(N) ->
    11    try generate_exception(N) of
    12        Val -> {N, normal, Val}
    13    catch
    14        throw:X -> {N, caught, thrown, X};
    15        exit:X -> {N, caught, exited, X};
    16        error:X -> {N, caught, error, X}
    17    end.
    
    1[{1,normal,a},
    2 {2,caught,thrown,a},
    3 {3,caught,exited,a},
    4 {4,normal,{'EXIT',a}},
    5 {5,caught,error,a}]
    
  8. catch捕获异常

    try_test.erl:

    1demo2() ->
    2	[{I, (catch generate_exception(I))} || I <- [1,2,3,4,5]]
    
    1[{1,a},
    2 {2,a},
    3 {3,{'EXIT',a}},
    4 {4,{'EXIT',a}},
    5 {5,{'EXIT',
    6         {a,[try_test,genera....]}}}]
    

    提供了详细的栈跟踪信息。

  9. 针对异常错误的代码模式

    • 改进错误消息

      1sqrt(X) when X < 0 ->
      2    error({squareRootNegativeArgument, X});
      3sqrt(X) ->
      4    math:sqrt(X).
      
    • 函数经常返回错误时该这么写:

      1case f(X) of
      2	{ok, Val} ->
      3       do_some_thing_with(Val);
      4     	
      5	{error, Why} ->
      6       %% 处理错误
      7end,
      8...
      
    • 错误罕见但可能有该这么写:

      1try my_func(X)
      2catch
      3	throw:{thisError, X} -> ...
      4	throw:{someOtherError, X} -> ...
      5end
      
    • 捕捉一切可能的异常:

      1try Expr
      2catch
      3	_:_ -> ... 处理所有异常错误的代码
      4end
      

      如果漏写了标签_ -> ...,就不会捕捉到所有的错误,因为在这种情形下系统会假设标签是默认的throw。

  10. 栈跟踪信息

    捕捉到一个异常错误后,可以调用erlang:get_stacktrace()来找到最近的栈跟踪信息。

    1demo3() ->
    2	try generate_exception(5)
    3	catch
    4        error:X ->
    5            {X, erlang:get_stacktrace()}
    6	end.
    
  11. 要牢记的一个重点是任其崩溃。永远不要在函数被错误参数调用时返回一个值,而是要抛出一个异常错误。要假定调用者会修复这个错误。在Erlang里,当系统内部或程序逻辑检测出错误时,正确的做法是立即崩溃并生成一段有意义的错误消息。立即崩溃是为了不让事情变得更糟。错误消息应当被写入永久性的错误日志,而且要包含足够多的细节,以便过后查明是哪里出了错。

练习

(1)

file:read_file(File)会返回{ok, Bin}或者{error, Why},其中File是文件名,Bin则包含了文件的内容。请编写一个myfile:read(File)函数,当文件可读取时返回Bin,否则抛出一个异常错误。

 1-module(myfile).
 2-export([read/1])
 3
 4read(File) ->
 5	case file:read_file(File) of
 6		{ok, Bin} ->
 7			Bin;
 8		{error, Why} ->
 9			throw(Why)
10	end.
11

(2)

重写try_test.erl里的代码,让它生成两条错误消息:一条文明的消息给用户,另一条详细的消息给开发者。

 1generate_exception(1) -> a;
 2generate_exception(2) -> throw("遇到2,程序异常");
 3generate_exception(3) -> exit("遇到3,程序退出");
 4generate_exception(4) -> {'EXIT', a};
 5generate_exception(5) -> error("遇到5,程序错误");
 6
 7demo1() ->
 8    [catcher(I) || I <- [1,2,3,4,5]].
 9
10catcher(N) ->
11    try generate_exception(N) of
12        Val -> {N, normal, Val}
13    catch
14        throw:X -> {N, caught, thrown, X, "程序暂不可用"};
15        exit:X -> {N, caught, exited, X, "程序退出"};
16        error:X -> {N, caught, error, X, "程序错误"}
17    end.

第7章 二进制型与位语法

二进制型

  • 二进制型的编写和打印形式是双小于号与双大于号之间的一列整数或字符串。

    在二进制型里使用整数时,它们必须属于0至255这个范围 。

    如果某个二进制型的内容是可打印的字符串,shell就会将这个二进制型打印成字符串,否则就打印成一列整数。

    1> <<5,10,20>>.
    2<<5,10,20>>
    3> <<"hello">>.
    4<<"hello">>
    5> <<65,66,67>>.
    6<<"ABC">>
    
  • 操作二进制型的内置函数(binary模块里的函数):

    • list_to_binary(L) -> B list_to_binary返回一个二进制型,它是通过把io列表(iolist)L里的所有元素压扁后形成的(压扁的意思是移除列表里所有的括号)。
    • split_binary(Bin, Pos) -> {Bin1, Bin2} 这个函数在Pos处把二进制型Bin一分为二。
    • term_to_binary(Term) -> Bin 这个函数能把任何Erlang数据类型转换成一个二进制型。数据类型通过term_to_binary转换成二进制型后可以被保存在文件里,作为消息通过网络发送,等等,而转换前的初始数据类型可以在稍后重建。对于在文件里保存复杂数据结构,或者向远程机器发送复杂数据结构而言,这是极其有用的。
    • binary_to_term(Bin) -> Term 这是term_to_binary的逆向函数。
    • byte_size(Bin) -> Size 这个函数返回二进制型里的字节数。

位语法

1.概念

  • 位语法表达式:

    1<<>>
    2<<E1,E2,...,En>>
    

    每个Ei元素都标识出二进制型或位串里的一个片段。单个Ei元素可以有4种形式:

    1Ei = Value | 
    2   Value:Size |
    3   Value/TypeSpecifierList | 
    4   Value:Size/TypeSpecifierList
    

    如果表达式的总位数是8的整数倍,就会构建一个二进制型,否则构建一个位串。

    Size的值指明了片段的大小。它的默认值取决于不同的数据类型,对整数来说是8,浮点数则是64,如果是二进制型就是该二进制型的大小。在模式匹配里,默认值只对最后那个元素有效。如果未指定片段的大小,就会采用默认值。

  • TypeSpecifierList (类型指定列表)是一个用连字符分隔的列表,形式为 End-Sign-Type-Unit。前面这些项中的任何一个都可以被省略,各个项也可以按任意顺序排列。如果省略了某一项,系统就会使用它的默认值。

    • End可以是big | little | native

      它指定机器的字节顺序。native是指在运行时根据机器的CPU来确定。默认值是big,也就是网络字节顺序(network byte order)。这一项只和从二进制型里打包和解包整数与浮点数有关。

      term_to_binary和binary_to_term可以帮你搞定打包和解包整数的工作。因此,你可以在高位优先(big-endian)的机器上创建一个包含整数的元组,然后用term_to_binary把它转换成二进制型并发送至低位优先(little-endian)的机器。最后,在低位优先的机器上运行binary_to_term,这样元组里所有整数的值都会是正确的。

    • Sign可以是signed|unsigned

      这个参数只用于模式匹配。默认值是unsigned。

    • Type可以是integer|float|binary|bytes|bitstring|bits|utf8|utf16|utf32。

      默认值是integer。

    • Unit的写法是unit:1|2|…256 integer、float和bitstring的Unit默认值是1,binary则是8。utf8、utf16和utf32类型无需提供值。

2.例子

  • 寻找MPEG数据里的同步帧

    mp3_sync.erl:

     1find_sync(Bin, N) ->
     2    case is_header(N, Bin) of
     3        {ok, Len1, _} ->
     4            case is_header(N+Len1, Bin) of
     5                {ok, Len2, _} ->
     6                    case is_header(N+Len1+Len2, Bin) of
     7                        {ok,_ ,_} ->
     8                            {ok, N};
     9                        error ->
    10                            find_sync(Bin,N+1)
    11                     end;
    12                error ->
    13                    find_sync(Bin,N+1)
    14             end;
    15        error ->
    16            find_sync(Bin,N+1)
    17    end.
    18  
    19is_header(N, Bin) ->
    20    unpack_header(get_word(N, Bin)).
    21  
    22%% 提取32位数据进行分析
    23get_word(N, Bin) ->
    24    {_, <<C:4/binary,_/binary>>} = split_binary(Bin,N),
    25    C.
    26  
    27unpack_header(X) ->
    28    try decode_header(X)
    29    catch
    30      _:_ -> error
    31    end.
    32            
    33%% 2#11111111111是个二进制整数,因此这个模式匹配11个连续的1位,指派给B2位,指派给C2位,以此类推。请注意,这段代码严格遵循之前给出的位级MPEG头规范。
    34decode_header(<<2#11111111111,B:2,C:2,_D:1,E:4,F:2,G:1,Bits:9>>) ->
    35    Vsn = case B of
    36              0 -> {2,5};
    37              1 -> exit(badVsn);
    38              2 -> 2;
    39              3 -> 1
    40           end,
    41    Layer = case C of
    42                0 -> exit(badLayer);
    43                1 -> 3;
    44                2 -> 2;
    45                3 -> 1
    46             end,
    47    %% Protection = D,
    48    BitRate = bitrate(Vsn, Layer, E) * 1000,
    49    SampleRate = samplerate(Vsn, F),
    50    Padding = G,
    51    FrameLength = framelength(Layer, BitRate, SampleRate, Padding),
    52    if
    53        FrameLength < 21 ->
    54            exit(frameSize);
    55        ture ->
    56            {ok, FrameLength, {Layer,BitRate,SampleRate,Vsn,Bits}}
    57    end;
    58decode_header(_) ->
    59    exit(badHeader).
    

    这个例子中<<C:4/binary,_/binary>>需要注意,这里C是匹配了32位。

  • 解包COFF数据

    要展开这些宏,需要使用?DWORD和?LONG之类的语法。举个例子,宏?DWORD会展开成文本字面量32/unsigned-little-integer。

    1-define(DWORD, 32/unsigned-little-integer).
    2-define(LONG, 32/unsigned-litter-integer).
    3-define(WORD, 16/unsigned-litter-integer).
    4-define(BYTE, 8/unsigned-litter-integer).
    5  
    6unpack_image_resource_directory(Dir) ->
    7    <<Characteristics : ?DWORD,
    8      TimeDateStamp : ?DWORD,
    9      MajorVersion: ?WORD,...
    

位串

  • 没有按照8位边界对齐的数据或者可变长度数据,它们的数据长度用位而不是字节来表示。

  •  1> B1 = <<1:8>>.
     2<<1>>
     3> byte_size(B1).
     41
     5  
     6> B2 = <<1:17>>.
     7<<0,0,1:1>>
     8> byte_size(B2).
     93
    10> bit_size(B2).
    1117
    12  
    13%% B1是一个二进制型,而B2是一个位串,因为它的长度是17位。
    14%% 我们用语法<<1:17>>构建了B2,它被打印成<<0,0,1:1>>,也就是说,作为一个二进制型字面量,它的第三个片段是一个长度为1的位串。
    15%% B2的位大小是17,而字节大小是3(这是包含该位串的二进制型的实际大小)。
    
  • 位推导

    1> B = <<16#5f>>.
    2> [X || <<X:1>> <= B].
    3[0,1,0,1,1,1,1,1]
    4> << <<X>> || <<X:1>> <= B >>.
    5<<0,1,0,1,1,1,1,1>>
    

练习

(1)

编写一个函数来反转某个二进制型里的字节顺序。

反转的是字节顺序而不是位的顺序

 1reverseByte(Bin) ->
 2	list_to_binary(reverseByteList(Bin)).
 3	
 4reverseByteList(Bin) when size(Bin) =:= 1 ->
 5	<<Val>> = Bin,
 6	Val;
 7reverseByteList(Bin) ->
 8	{Bf, Bl} = split_binary(Bin,1),
 9	<<Val:8>> = Bf,
10	[reverseByteList(Bl) , Val]. 
11	

(2)

编写一个term_to_packet(Term) -> Packet函数,通过调用term_to_binary(Term)来生成并返回一个二进制型,它内含长度为4个字节的包头N,后跟N个字节的数据。

1term_to_packet(Term) ->
2	B = term_to_binary(Term),
3	<<Head:4/binary, Data>> = B,
4	{packet, Head, Data}.

(3)

编写一个反转函数 packet_to_term(Packet) -> Term,使它成为前一个函数的逆向函数。

1packet_to_term(Packet) ->
2	{_, Head, Data} = Packet,
3	binary_to_term(<<Head, Data>>).

(4)

按照4.1.3节的样式编写一些测试,测一下之前的两个函数是否能正确地把数据类型编码成数据包(packet),以及通过解码数据包来复原最初的数据类型。

不知道如何构造数据包,这道题就算了。

(5)

编写一个函数来反转某个二进制型所包含的位。

1reversebit(Bit) ->
2	Res = [ X || <<X:1>> <= Bit ],
3	list_to_binary(list:reverse(Res)).

第8章 Erlang顺序编程补遗

书本内容

本章知识比较杂乱,一些地方没有记,需要时翻原书。

  1. 内置函数apply(Mod, Func, [Arg1, Arg2, ..., ArgN])会将模块Mod里的Func函数应用到Arg1, Arg2, … ArgN这些参数上。

    所有的Erlang内置函数也可以通过apply进行调用,方法是假定它们都属于erlang模块。

    apply的Mod参数不必非得是一个原子,也可以是一个元组。这种机制可以用来创建“有状态的模块”(将在24.3节里讨论)和“适配器模式”(将在24.4节里讨论)。

  2. 算术表达式

    数字的意思是此参数可以是整数或浮点数

    操作符 描述 参数类型 优先级
    + X + X 数字 1
    - X - X 数字 1
    X * Y X * Y 数字 2
    X / Y X / Y(浮点除法) 数字 2
    bnot X 对X执行按位取反(bitwise not) 整数 2
    X div Y X被Y整除 整数 2
    X rem Y X除以Y的整数余数 整数 2
    X band Y 对X和Y执行按位与(bitwise and) 整数 2
    X + Y X + Y 数字 3
    X - Y X - Y 数字 3
    X bor Y 对X和Y执行按位或(bitwise or) 整数 3
    X bxor Y 对X和Y执行按位异或(bitwise xor) 整数 3
    X bsl N 把X向左算术位移(arithmetic bitshift)N位 整数 3
    X bsr N 把X向右算术位移N位 整数 3
  3. 一个函数的元数(arity)是该函数所拥有的参数数量。在Erlang里,同一模块里的两个名称相同、元数不同的函数是完全不同的函数。除了碰巧使用同一个名称外,它们之间毫不相关。

  4. 模块属性的语法是-AtomTag(…),它们被用来定义文件的某些属性。(注意:-record(…)和-include(…)有着类似的语法,但是不算模块属性。)

  5. -compile(export_all).这个编译器选项经常会在调试程序时用到。它会导出模块里的所有函数,无需再显式使用-export标识了。

  6. 块表达式用于以下情形:代码某处的Erlang语法要求单个表达式,但我们想使用一个表达式序列。

    在一个形式为[E || …]的列表推导中,语法要求E是单个表达式,但我们也许想要在E里做不止一件事情。

    1begin
    2	Expr1,
    3	Expr2,
    4	...
    5	ExprN
    6end
    
  7. Erlang没有单独的布尔值类型。不过原子true和false具有特殊的含义,可以用来表示布尔值。

  8. 布尔表达式not,and,or,xor

  9. Erlang里的注释从一个百分号字符(%)开始,一直延伸到行尾。Erlang没有块注释。Erlang里的注释从一个百分号字符(%)开始,一直延伸到行尾。Erlang没有块注释。

  10. 动态代码载入每当调用someModule:someFunction(…)时,调用的总是最新版模块里的最新版函数,哪怕当代码在模块里运行时重新编译了该模块也是如此。

    在任一时刻,Erlang允许一个模块的两个版本同时运行:当前版和旧版。重新编译某个模块时,任何运行旧版代码的进程都会被终止,当前版成为旧版,新编译的版本则成为当前版。

  11. 查看some_module.erl模块的预处理结果: erlc -P some_module.erl 这会生成一个名为some_module.P的清单文件。

  12. 可以在字符串和带引号的原子里使用转义序列来输入任何不可打印的字符。

    1> io:format("~w~n", ["\b\d\e\f\n\r\s\t\v"]).
    2[8,127,27,12,10,13,32,9,11]
    3% 格式字符串里的~w是指忠实地打印列表,而不对输出结果进行美化。
    
  13. 在Erlang里,任何可以执行并生成一个值的事物都被称为表达式(expression)。

    表达式序列(expression sequence)是一系列由逗号分隔的表达式。它们在->箭头之后随处可见。表达式序列E1, E2,…, En的值被定义为序列最后那个表达式的值,而该表达式在计算时可以使用E1, E2等表达式所创建的绑定。

  14. 包含文件-include(Filename)

    包含库的头文件-include_lib(Name)

    按照Erlang的惯例,包含文件的扩展名是.hrl。包含文件里经常会有记录的定义。如果许多模块需要共享通用的记录定义,就会把它们放到包含文件里,再由所有需要这些定义的模块包含此文件。

  15. ++和–是用于列表添加和移除的中缀操作符。 A ++ B使A和B相加(也就是附加)。 A – B从列表A中移除列表B。移除的意思是B中所有元素都会从A里面去除。请注意:如果符号X在B里只出现了K次,那么A只会移除前K个X。

    ++也可以用在模式里。在匹配字符串时,可以编写如下模式:

    1f("begin" ++ T) ->...
    

    模式会扩展成[$b,$e,$g,$i,$n|T]。

  16. 宏的写法:

    define(Constant, Replacement).

    define(Func(Var1, Var2,..., Var), Replacement).

    当Erlang的预处理器epp碰到一个?MacroName形式的表达式时,就会展开这个宏。宏定义里出现的变量会匹配对应宏调用位置的完整形式。

  17. 还有一些预定义宏提供了关于当前模块的信息。列举如下:

    • ?FILE展开成当前的文件名;
    • ?MODULE展开成当前的模块名;
    • ?LINE展开成当前的行号。
  18. 宏控制流。模块的内部支持下列指令,可以用它们来控制宏的展开。

    • -undef(Macro). 取消宏的定义,此后就无法调用这个宏了。
    • -ifdef(Macro). 仅当Macro有过定义时才执行后面的代码。
    • -ifndef(Macro). 仅当Macro未被定义时才执行后面的代码。
    • -else. 可用于ifdef或ifndef语句之后。如果条件为否,else后面的语句就会被执行。
    • -endif. 标记ifdef或ifndef语句的结尾。
  19. 用宏实现DEBUG:

     1-module(m1).
     2-export([loop/1]).
     3    
     4-ifdef(debug_flag),
     5-define(DEBUG(X), io:format("DEBUG ~p:~p ~p~n"m [?MODULE,?LINE,X])).
     6% io:format(String, [Args])会根据String里的格式信息在Erlang shell 中打印出[Args]所含的变量。
     7% 格式编码用一个~符号作为前缀。
     8% ~p是美化打印(pretty print)的简称,~n则会生成一个新行。
     9-else.
    10-define(DEBUG(X), void).
    11-endif
    12    
    13loop(0) ->
    14    done;
    15loop(N) ->
    16    ?DEBUG(X),
    17    loop(N-1).
    

    为了启用这个宏,我们在编译代码时设置了debug_flag。具体做法是给c/2添加一个额外参数如下:

    1> c(m1, {d,debug_flag}).
    2{ok,m1}
    3> m1:loop(4)
    4DEBUG m1:13 4
    5DEBUG m1:13 3
    6DEBUG m1:13 2
    7DEBUG m1:13 1
    8done
    
  20. 模式的匹配操作符(把这个模式指派给一个临时变量Z)

    1func([{tag, {one,A}=Z1, B}=Z2|T]) ->
    2	...
    3	...f(...,Z2,...),
    4	...g(...,Z1,...),
    
  21. 整数可以有三种不同的写法:

    • 传统写法
    • K进制整数 K#Digits
    • $ 写法:$C这种写法代表了ASCII字符C的整数代码。
  22. 进程字典:每个Erlang进程都有一个被称为进程字典(process dictionary)的私有数据存储区域。进程字典是一个关联数组(在其他语言里可能被称作map、hashmap或者散列表),它由若干个键和值组成。每个键只有一个值。

    API:

    • put(Key,Value) -> OldValue.

      给进程字典添加一个Key, Value组合。put的值是OldValue,也就是Key之前关联的值。如果没有之前的值,就返回原子undefined。

    • get(Key) -> Value.

      查找Key的值。如果字典里存在Key, Value组合就返回Value,否则返回原子undefined。

    • get() -> [{Key,Value}].

      返回整个字典,形式是一个由{Key,Value}元组所构成的列表。

    • get_keys(Value) ->[Key].

      返回一个列表,内含字典里所有值为Value的键。

    • erase(Key) -> Value.

      返回Key的关联值,如果不存在则返回原子undefined。最后,删除Key的关联值。

    • erase() -> [{Key,Value}].

      删除整个进程字典。返回值是一个由{Key,Value}元组所构成的列表,代表了字典删除之前的状态。

  23. 引用(reference)是一种全局唯一的Erlang数据类型。它们由内置函数erlang:make_ref()创建。引用的用途是创建独一无二的标签,把它存放在数据里并在后面用于比较是否相等。

  24. 短路布尔表达式(short-circuit boolean expression)是一种只在必要时才对参数求值的表达式。在对应的布尔表达式里(A or B和A and B),两边的参数总会被执行,即使表达式的真值只需要第一个表达式的值就能确定也是如此。

    1Expr1 orelse Expr2
    2% 它会首先执行Expr1。如果Expr1的执行结果是true,Expr2就不再执行。如果Expr1的执行结果是false,则会执行Expr2。
    3    
    4Expr1 andalso Expr2
    5% 它会首先执行Expr1。如果Expr1的执行结果是true,则会执行Expr2。如果Expr1的执行结果是false,Expr2就不再执行。
    
  25. 比较数据类型

    • 操作符 含义
      X > Y X大于Y
      X < Y X小于Y
      X =< Y X等于或小于Y
      X >= Y X大于或等于Y
      X == Y X等于Y
      X /= Y X不等于Y
      X =:= Y X与Y完全相同
      X =/= Y X与Y不完全相同
    • 数据类型做了全排序(total ordering)的定义:

      number < atom < reference < fun < port < pid < tuple(record) < map < list < binary

    • 所有的数据类型比较操作符(除了=:=和=/=)在参数全为数字时具有以下行为:

      如果一个参数是整数而另一个是浮点数,那么整数会先转换成浮点数,然后再进行比较。

      如果两个参数都是整数或者都是浮点数,就会“按原样”使用,也就是不做转换。

    • 如果==的参数不包含任何浮点数的话,那么这两个操作符的行为就是相同的。

    • 函数的子句匹配总是意味着精确的模式匹配,所以如果定义了一个fun F =fun(12) -> … end,那么试图执行F(12.0)就会出错。

  26. 下划线变量,_VarName 这种特殊语法代表一个常规变量(normalvariable),而不是匿名变量。

    下划线变量有两种主要的用途:

    • 命名一个我们不打算使用的变量。例如,相比open(File, _),open(File, _Mode)这种写法能让程序的可读性更高。

    • 用于调试:

      1some_func(X) ->
      2	{P,Q} = some_other_func(X),
      3	%% io:format("Q = ~p~n", [Q]),
      4	P.
      5% 如果编译它,编译器就会生成一个变量Q未使用的警告。
      
      1some_func(X) ->
      2	{P,_Q} = some_other_func(X),
      3	%% io:format("_Q = ~p~n", [_Q]),
      4	P.
      5% 编译器也不会再抱怨了。
      

      一般来说,当某个变量在子句里只使用了一次时,编译器会生成一个警告,因为这通常是出错的信号。但如果这个只用了一次的变量以下划线开头,就不会有错误消息。

练习

(2)

code:all_loaded()命令会返回一个由{Mod,File}对构成的列表,内含所有Erlang系统载入的模块。使用内置函数Mod:module_info()了解这些模块。编写一些函数来找出哪个模块导出的函数最多,以及哪个函数名最常见。编写一个函数来找出所有不带歧义的函数名,也就是那些只在一个模块里出现过的函数名。

我们从code:all_loaded()开始:

 12> code:all_loaded().
 2[{erpc,"/usr/lib/erlang/lib/kernel-8.5.4.2/ebin/erpc.beam"},
 3 {rpc,"/usr/lib/erlang/lib/kernel-8.5.4.2/ebin/rpc.beam"},
 4 {os,"/usr/lib/erlang/lib/kernel-8.5.4.2/ebin/os.beam"},
 5 {inet_parse,"/usr/lib/erlang/lib/kernel-8.5.4.2/ebin/inet_parse.beam"},
 6 {prim_inet,preloaded},
 7 {erts_dirty_process_signal_handler,preloaded},
 8 {socket_registry,preloaded},
 9 {error_logger,"/usr/lib/erlang/lib/kernel-8.5.4.2/ebin/error_logger.beam"},
10 {logger_proxy,"/usr/lib/erlang/lib/kernel-8.5.4.2/ebin/logger_proxy.beam"},
11 {logger_backend,"/usr/lib/erlang/lib/kernel-8.5.4.2/ebin/logger_backend.beam"},
12 {proplists,"/usr/lib/erlang/lib/stdlib-4.3.1.3/ebin/proplists.beam"},
13 {gb_trees,"/usr/lib/erlang/lib/stdlib-4	.3.1.3/ebin/gb_trees.beam"},
14 {logger_handler_watcher,"/usr/lib/erlang/lib/kernel-8.5.4.2/ebin/logger_handler_watcher.beam"},
15 {logger_sup,"/usr/lib/erlang/lib/kernel-8.5.4.2/ebin/logger_sup.beam"},
16 {erl_parse,"/usr/lib/erlang/lib/stdlib-4.3.1.3/ebin/erl_parse.beam"},
17 {init,preloaded},
18 {erl_features,"/usr/lib/erlang/lib/stdlib-4.3.1.3/ebin/erl_features.beam"},
19 {application_controller,"/usr/lib/erlang/lib/kernel-8.5.4.2/ebin/application_controller.beam"},
20 {user_sup,"/usr/lib/erlang/lib/kernel-8.5.4.2/ebin/user_sup.beam"},
21 {io,"/usr/lib/erlang/lib/stdlib-4.3.1.3/ebin/io.beam"},
22 {edlin,"/usr/lib/erlang/lib/stdlib-4.3.1.3/ebin/edlin.beam"},
23 {erl_distribution,"/usr/lib/erlang/lib/kernel-8.5.4.2/ebin/erl_distribution.beam"},
24 {prim_socket,preloaded},
25 {logger_filters,"/usr/lib/erlang/lib/kernel-8.5.4.2/ebin/logger_filters.beam"},
26 {io_lib,"/usr/lib/erlang/lib/stdlib-4.3.1.3/ebin/io_lib.beam"},
27 {error_handler,"/usr/lib/erlang/lib/kernel-8.5.4.2/ebin/error_handler.beam"},
28 {prim_buffer,preloaded},
29 {zlib,...},
30 {...}|...]

返回的以一个列表,里面都是模块名+位置组成的元组,我们只需要模块名即可。

拿到一个模块名,我们就调用Mod:module_info():

 14> erlang:module_info().
 2[{module,erlang},
 3 {exports,[{binary_to_atom,1},
 4           {binary_to_existing_atom,1},
 5           {binary_to_integer,1},
 6           {binary_to_integer,2},
 7           {check_process_code,2},
 8           {check_process_code,3},
 9           {alias,0},
10           {garbage_collect,1},
11           {garbage_collect,2},
12           {garbage_collect_message_area,0},
13           {is_alive,0},
14           {list_to_integer,2},
15           {process_display,2},
16           {process_flag,3},
17           {setnode,3},
18           {suspend_process,2},
19           {suspend_process,1},
20           {trace_pattern,2},
21           {spawn,2},
22           {spawn_link,2},
23           {spawn_monitor,2},
24           {spawn_monitor,3},
25           {spawn_opt,2},
26           {spawn_opt,...},
27           {...}|...]},
28 {attributes,[]},
29 {compile,[]},
30 {md5,<<192,119,22,112,208,85,113,68,95,199,120,159,236,
31        64,31,14>>}]
32

所以每一个调用又返回一个列表,我们需要的是{export,[]}这个,里面全是函数名和元数,我们拿到这些东西就可以进行处理了。

三个问题:

  1. 找出哪个模块导出的函数最多
  2. 哪个函数名最常见
  3. 找出所有不带歧义的函数名,也就是那些只在一个模块里出现过的函数名

先就做到这程度。编程里面涉及到严重的问题,普通循环和遍历到底该怎么写?还有Erlang中的变量不可再绑定这也是个问题,只能将我们想要的值输入或者输出了。

 1-module(analyse_loaded).
 2-export([get_max_count/0]).
 3
 4% code:all_loaded/0本身返回会不完全,这一点我们不处理
 5init() ->
 6	% 拿到所有模块
 7	M = [ Module || {Module, _Location} <- code:all_loaded()],
 8	% 使用apply拿到每个模块导出的函数信息
 9	get_func(M).
10	
11	
12get_func([]) ->
13	[];
14get_func(M) ->
15	[H|T] = M,
16	% 由于module_info格式是固定的,可以这样做不过有点丑
17	[_|Other]= apply(H, module_info,[]),
18	[{exports,Func} | _] = Other,
19	% 这样做会多一个列表符号
20	% Func = [ B || {A,B} <- apply(H, module_info,[]), A =:= exports],
21	[{H,Func}|get_func(T)].
22	
23	
24% 处理数目问题
25get_max_count() ->
26	Number = [size(list_to_tuple(Func)) || {Module, Func} <- init()],
27	lists:max(Number).
28		
29	

第9章 类型

Erlang 的类型表示法

1.语法

  • T1 :: A|B|C...

    类型定义可以使用的非正式语法。T1被定义为A、B或C其中之一。

  • type NewTypeName(TVar1, TVar2, ..., TVarN) :: Type.

    定义新的类型可以使用语法。TVar1至TVarN是可选的类型变量,Type是一个类型表达式。

 1-spec plan_route(point(),point()) -> route().
 2% 如果调用plan_route/2函数时使用了两个类型为point()的参数,此函数就会返回一个类型为route()的对象。
 3-type direction() :: north | south | east | west.
 4% 引入一个名为direction()的新类型,它的值是下列原子之一:north、south、east或west。
 5-type point () :: {integer(),integer()}.
 6% 指point()类型是一个包含两个整数的元组(integer()是预定义类型)。
 7-type route() :: [{go,direction(),integer()}].
 8% 将route()类型定义为一个由三元组(3-tuple)构成的列表,每个元组都包含一个原子go,一个类型为direction的对象和一个整数。[X]这种表示法的意思是一个由X类型构成的列表。
 9
10
11-type onOff() :: on | off.
12-type person() :: {person, name(), age()}.
13-type people() :: [person()].
14-type name() :: {firstname, string()}.
15-type age() :: integer().
16-type dict(Key,Val) :: [{Key,Val}].

2.预定义类型

 1-type term() :: any().
 2-type boolean() :: true | false.
 3-type byte() :: 0..255.
 4-type char() :: 0..16#10ffff.
 5-type number() :: integer() | float().
 6-type list() :: [any()].
 7-type maybe_improper_list() :: maybe_improper_list(any(),any()).
 8-type maybe_improper_list(T) :: maybe_imperper_list(T,any()).
 9-type string() :: [char()].
10-type nonempty_string() :: [char(),...].
11-type module() ::atom().
12-type mfa() :: {atom(),atom(), atom()}.
13-type node() :: atom().
14-type timeout() :: infinity | non_neg_integer().
15-type no_return() :: none().
  • .. 表示范围。
  • maybe_improper_list用于指定带有非空(non-nil)最终列表尾的列表类型。这样的列表很少会用到,但是指定它们的类型是可能的!
  • non_neg_integer()是一个非负的整数
  • pos_integer()是一个正整数
  • neg_integer()是一个负整数。
  • [X,…]这种表示法的意思是一个由X类型构成的非空列表(可能0也可能很多)。

3.指定函数的输入输出类型

1-spec functionName(T1, T2, ..., Tn) -> Tret when
2	Ti :: Typei,
3	Tj :: Typej,
4	...

T1, T2,…, Tn描述了某个函数的参数类型,Tret描述了函数返回值的类型。如果有必要,可以在可选关键字when后面引入额外的类型变量。

类型变量可以在参数里使用,如下所示:

1-spec lists:map(fun(A) -> B, [A]) -> [B].
2% map函数接受一个从A类型到B类型的函数和一个由A类型对象组成的列表,然后返回一个由B类型对象组成的列表
3-spec lists:filter(fun((X) -> bool()),[X]) -> [X].

4.导出类型和本地类型

用注解-export_type(…)导出:

1-module(a).
2-type rich_text() :: [{font(), char()}].
3-type font() :: integer().
4-export_type([rich_text/0, font/0]).
5...
6% 不仅声明了一个富文本和一个字体类型,还用注解-export_type(...)导出了它们。

引用:

1-module(b).
2...
3-sepc rich_text_length(a:rich_text()) -> integer().
4% rich_text_length的输入参数使用了完全限定的类型名a:rich_text()

5.不透明类型

希望隐藏富文本数据结构的内部细节,使得只有创建此数据结构的模块才了解类型的细节

1-module(a).
2-opaque rich_text() :: [{font(),char()}].
3% 创建了一个名为rich_text()的不透明类型(opaque type)。
4-export_type([rich_text/0]).
5
6-export([make_text/1, bounding_box/1]).
7-spec make_text(string()) -> rich_text().
8-spec bountding_box(rich_text()) -> {Height::integer(), Width::integer()}.
9% 使用不透明类型为函数的输出输入类型

引用不透明类型:

1-module(b).
2...
3do_this() ->
4	X = a:make_text("hello word").
5	{W,H} = a:bounding_box(X)
6    % b模块永远不需要知道变量X的内部结构
1-module(c).
2...
3
4fonts_in(Str) ->
5    X = a:make_text(Str),
6    [F || {F,_} <- X].

我们不该知道此类型的任何内部结构信息。利用此类型的内部结构信息被称为抽象违规(abstraction violation),如果正确声明了相关函数的类型可见性,这一违规就可以被dialyzer检测出来。

dialyzer

1.创建PLT(Persistent Lookup Table(持久性查询表)),PLT应当包含标准系统里所有类型的缓存。

1dialyzer --build_plt --apps erts kernel stdlib
2# 生成erts、stdlib和kernel的PLT。

出现未知函数的警告是因为列出的函数存在于外部的应用中,它们不再plt中。

2.使用dialyzer

分析程序:

1dialyzer app.erl

例子中分析了错误使用内置函数的返回值;内置函数的错误参数;错误的程序逻辑这几种错误。

进行函数的类型推断:

1typer test3.erl

3.注意事项

使用dialyzer的最佳方式是将它用于每一个开发阶段。开始编写一个新模块时,应该首先考虑类型并声明它们,然后再编写代码。要为模块里所有的导出函数编写类型规范。先完成这一步再开始写代码。可以先注释掉未完成函数的类型规范,然后在实现函数的过程中取消注释。

如果函数是导出的,就加上类型规范;如果不是,就要考虑添加类型规范是否有助于类型分析或者能帮助我们理解程序(请记住,类型注解是很好的程序文档)。如果dialyzer发现了任何错误,就应该停下来思考并找出错误的准确含义。

4.干扰dialyzer的情况

  • 避免使用-compile(export_all)。
  • 为模块导出函数的所有参数提供详细的类型规范。尽量给导出函数的参数设置最严格的限制。
  • 为记录定义里的所有元素提供默认的参数。
  • 把匿名变量用作函数的参数经常会导致结果类型不如你预想得那么精确。要尽可能地给变量添加限制。

类型推断过程

类型推断(type inference)是指通过分析代码得出函数类型的过程。要做到这一点,我们会分析程序,寻找约束条件。用这些约束条件构建出一组约束方程式,然后求解。得到的一组类型就被称为此程序的成功分型(success typing)。

我们把函数的推断类型称为合格类型的原因,从字面上讲就是“要让函数能成功执行,它的参数就必须属于这个类型”。

出现的一些约束条件:

  • 乘法操作符的左右参数都必须是数字
  • 加法操作符的左右参数都必须是数字
  • is_integer(H)说明H必须是整数
  • 如果函数之后调用了一个函数,那么里面这个函数的约束条件通过参数扩散到外部函数。

一个反例:

1myand1(true, true) -> true;
2myand1(false, _) -> false;
3myand1(_, false) -> false.
4
5bug1(X,Y) ->
6	case myand1(X,Y) of
7		true ->
8			X+Y
9	end.

在这个模块上运行dialyzer,它是不会返回错误的。因为它推断myand1函数可以接收任何值,当然数字也可以了。这个例子展示了参数类型规范的不到位(即把_当作类型而非boolean())会导致分析程序时无法发现的错误。

练习

第10章 编译和运行程序

设置载入代码的搜索路径

1.获取当前载入的路径值

1code:get_path().

2.操作载入路径的函数

1-spec code:add_patha(Dir) -> true | {error,bad_directory}
2% 向载入路径的开头添加一个新目录Dir。
3-spec code:add_pathz(Dir) -> true | {error,bad_directory}
4% 向载入路径的末端添加一个新目录Dir

3.返回一个已加载模块的总列表

1code:all_loaded()

4.怀疑载入了错误的模块,帮助调查哪里出了错。

1code:clash()

5.启动erl时添加载入目录

1erl -pa Dir1 -pa Dir2... .. -pz DirK1 -pz DirK2
2
3#-pa Dir标识会把Dir添加到代码搜索路径的开头
4#-pz Dir则会把此目录添加到代码路径的末端。

在系统启动时执行一组命令

我们可以在主目录的.erlang文件里添加上一节的代码设置载入路径。

事实上,你可以把任意的Erlang代码放入这个文件。启动Erlang时,它会首先读取并执行此文件里的所有命令。

 1$cat .erlang 
 2io:format("This is your home .erlang file print!~n").
 3
 4$erl
 5Erlang/OTP 25 [erts-13.2.2.5] [source] [64-bit] [smp:6:6] [ds:6:6:10] [async-threads:1] [jit:ns]
 6
 7This is your home .erlang file print!
 8Eshell V13.2.2.5  (abort with ^G)
 91> q().
10ok

如果Erlang启动时的当前目录里已经有一个.erlang文件 ,它就会优先于主目录里的.erlang。通过这种方式,可以让Erlang根据不同的启动位置表现出不同的行为。这对特定的应用程序来说可能很有用。

获取主目录:

11> init:get_argument(home).
2{ok,[["/home/joe"]]}

程序运行的三种方式

hello.erl:

1-module(hello).
2-export([start/0]).
3
4start() ->
5	io:format("Hello world~n").

1.Erlang Shell里编译运行

1erl
2> c(hello).
3> hello:start().

2.命令行里运行

erl -noshell …命令可以放在shell脚本里,所以通常会制作一个shell脚本来运行程序,里面会设置路径(用-pa Directory)并启动程序。在这个例子里用了两个-s ..命令。命令行里的函数数量是不受限制的。每个-s …命令都由一个apply语句执行,运行完毕后再执行下一个命令。

1erlc hello.erl
2# erlc hello.erl编译了hello.erl文件,生成了一个名为hello.beam的目标代码文件
3
4erl -noshell -s
5#-noshell以不带交互式shell的方式启动Erlang(因此不会看到Erlang的“徽标”,也就是通常系统启动时首先显示的那些信息)。
6#-s hello start运行hello:start()函数。注意:使用-s Mod ...选项时,Mod必须是已编译的
7#-s init stop在之前的命令完成后执行init:stop()函数,从而停止系统。	

快速脚本编程,编辑+执行代码:

1erl -eval 'io:format("Memory: ~p~n", [erlang:memory(total)]).'\
2    -noshell -s init stop

3.作为Escript运行

可以用escript来让程序直接作为脚本运行,无需事先编译它们

hello:

1#!/usr/bin/env escript
2
3main(Args) ->
4	io:format("Hello world~n").

这个文件必须包含一个main(Args)函数。当它从操作系统的shell里调用时,Args会包含一列用原子表示的命令行参数。这个文件的文件模式必须设置为“可执行”

带命令行参数程序的运行

fac.erl:

1-module(fac).
2-export([fac/1]).
3
4fac(0) -> 1;
5fac(N) -> N*fac(N-1).

1.Erlang Shell

1erl
2c(fac).
3fac:fac(25).

2.命令行

fac1.erl:

 1-module(fac1).
 2-export([main/1]).
 3
 4main([A]) ->
 5	I = list_to_integer(atom_to_list(A)),
 6	F = fac(I),
 7	io:format("factorial ~w = ~w~n",[I,F]),
 8	init:stop().
 9
10fac(0) -> 1;
11fac(N) -> N*fac(N-1).
1erlc fac1.erl
2erl -noshell -s fac1 main 25

这个函数的名称main没有什么特殊含义,你可以给它取任何名字。重点是函数名和命令行里的名称要一致。

3.Escript

无需编译就可以运行它

factorial:

1#!/usr/bin/env escript
2
3main([A]) ->
4	I = list_to_integer(atom_to_list(A)),
5	F = fac(I),
6	io:format("factorial ~w = ~w~n",[I,F]),
7
8fac(0) -> 1;
9fac(N) -> N*fac(N-1).

使用make使编译自动化

一个makefile模板

makefile.template

 1#别碰这几行
 2.SUFFIXES: .erl .beam .yrl
 3
 4.erl.veam:
 5		erlc -W $<
 6		
 7-yrl.erl:
 8		erlc -W $<
 9		
10ERL = erl -boot start_clean
11
12#这里是一个想要编译的Erlang模块列表。
13#如果这些模块在一行里放不下,
14#就在行尾添加一个 \ 字符然后在下一行继续。
15#编辑下面这几行
16MOD = module1 module2\
17	  module3 ... special1 ...\
18	  ...
19	  moduleN
20
21#任何makefile里的第一个目标就是默认的目标。
22#如果只输入了"make",系统就会假定为"make all"。
23#(因为"all"是这个makefile里的第一个目标)
24all: compile
25
26compile: ${MODS:%=%.beam} subdirs
27
28#此处添加特殊的编译要求
29special1.beam: special1.erl
30		${ERL} -Dflag1 -W0 special1.erl
31		
32#从makefile里运行应用程序
33application1: compile
34		${ERL} -pa Dir1 -s application1 start Arg1 Arg2
35		
36#subdir目标会编译子目录里的代码
37subdirs:
38		cd dir1; $(MAKE)
39		cd dir2; $(MAKE)
40		...
41		
42#移除所有代码
43clean:
44		rm -rf *.beam erl_crash.dump
45		cd dir1; $(MAKE) clean
46		cd dir2; $(MAKE) clean

精简makefile模板

 1.SUFFIXED: .erl .beam
 2
 3.erl.beam:
 4	erlc -W $<
 5	
 6ERL = erl -boot start_clean
 7
 8MODS = module1 module2 module3
 9
10all: compile
11	${ERL} -pa '/home/joe/.../this/dir' -s module1 start
12	
13compile: ${MODS:%=%.beam}
14
15clean:
16	rm -rf *.beam erl_crash.dump

第11章 现实世界中的并发

  • Erlang进程没有共享内存,每个进程都有它自己的内存。要改变其他某个进程的内存,必须向它发送一个消息,并祈祷它能收到并理解这个消息。
  • Erlang进程就像人类一样,有时会死去。但和人类不同的是,当它们死亡时,会用尽最后一口气喊出导 致它们死亡的准确原因。
  • Erlang的错误检测正是使用的这种方式:进程可以相互连接。如果其中一个进程挂了,另一 个进程就会得到一个说明前者死亡原因的错误消息。
  • Erlang程序由大量进程组成。这些进程间能相互发送消息。这些消息也许能被其他进程收到和理解,也许不能。如果想知道某个消息是否已被对方进程收到和理解,就必须向该进程发送一个消息并等待回复。

第12章 并发编程

在Erlang里:

  • 创建和销毁进程是非常快速的;
  • 在进程间发送消息是非常快速的;
  • 进程在所有操作系统上都具有相同的行为方式;
  • 可以拥有大量进程;
  • 进程不共享任何内存,是完全独立的;
  • 进程唯一的交互方式就是消息传递。

基本并发函数

1.Pid = spawn(Mod, Func, Args)

创建一个新的并发进程来执行apply(Mod, Func, Args)。这个新进程和调用进程并列运行。spawn返回一个Pid(process identifier的简称,即进程标识符)。可以用Pid来给此进程发送消息。

请注意,元数为length(Args)的Func函数必须从Mod模块导出。

当一个新进程被创建后,会使用最新版的代码定义模块。

2.Pid = spawn(Fun)

创建一个新的并发进程来执行Fun()。这种形式的spawn总是使用被执行fun的当前值,而且这个fun无需从模块里导出。

这两种spawn形式的本质区别与动态代码升级有关。12.8节会讨论如何从这两种spawn形式中做出选择。

3.Pid ! Message

向标识符为Pid的进程发送消息Message。消息发送是异步的。发送方并不等待,而是会继续之前的工作。!被称为发送操作符。

Pid ! M被定义为M。因此,Pid1 ! Pid2 !…! Msg的意思是把消息Msg发送给Pid1、Pid2等所有进程。

4.receice... end

接收发送给某个进程的消息。它的语法如下:

1receive
2	Pattern1 [when Guard1] ->
3		Expressions1;
4	Pattern2 [when Guard2] ->
5		Expression2;
6	...
7end

当某个消息到达进程后,系统会尝试将它与Pattern1(以及可选的关卡Guard1)匹配,如果成功就执行Expressions1。如果第一个模式不匹配,就会尝试Pattern2,以此类推。如果没有匹配的模式,消息就会被保存起来供以后处理,进程则会开始等待下一条消息。

接收语句里的模式和关卡和我们定义函数时使用的模式和关卡具有相同的语法形式和含义。

选择MFA还是Fun进行分裂

用显式的模块、函数名和参数列表(称为MFA)来分裂一个函数是确保运行进程能够正确升级为新版模块代码(即使用中被再次编译)的恰当方式。动态代码升级机制不适用于fun的分裂,只能用于带有显式名称的MFA上。

如果你不关心动态代码升级,或者确定程序不会在未来进行修改,就可以使用 spawn 的spawn(Fun)形式。如果有疑问,就使用spawn(MFA)。

简单进程示例

代码

 1-module(area_server0).
 2-export([loop/0]).
 3
 4loop() ->
 5    receive
 6        {rectangle, Width, Ht} ->
 7            io:format("Area of ractangle is ~p~n", [Width * Ht]),
 8            loop();
 9        {square, Side} ->
10            io:format("Area of square is ~p~n", [Side * Side]),
11            loop()
12    end.

测试

11> Pid = spawn(area_server0, loop, []).
2<0.36.0>
32> Pid ! {rectangle, 6, 10}.
4Area of ractangle is 60
5{rectangle, 6, 10}

最后,shell打印出{rectangle, 6, 10},这是因为Pid ! Msg的值被定义为Msg。

客户端-服务器示例

发送请求的进程通常称为客户端。接收请求并回复客户端的进程称为服务器。

1.初步实例

area_server1.erl:

 1-module(area_server1).
 2-export([loop/0,rpc/2]).
 3
 4% 客户端
 5rpc(Pid, Request) ->
 6    Pid ! {self(), Request},
 7    receive
 8        Response ->
 9            Response
10    end.
11
12%服务器
13loop() ->
14    receive
15        {From, {rectangle, Width, Ht}} ->
16            From ! Width * Ht,
17            loop();
18        {From, {circle, R}} ->
19            From ! 3.14159 * R * R,
20            loop();
21        {From, Other} ->
22            From ! {error, Other},
23            loop()
24    end.

测试

1> Pid = spawn(area_server1, loop, []).
2<0.36.0>
3> area_server1:rpc(Pid, {rectangle, 6, 8}).
448
5> area_server1:rpc(Pid, {circle, 6}).
6113.097
7> area_server1:rpc(Pid, socks).
8{error, socks}
  • 最佳实践是确认发送给进程的每一个消息都已收到。如果发送给进程的消息不匹配原始接收语句里的任何一个模式,这条消息就会遗留在进程邮箱里,永远无法接收。

    为了解决这个问题,我们在接收语句的最后加了一个子句,让它能匹配所有发送给此进程的消息

  • 小问题:客户端向服务器发送请求然后等待响应。但我们并不是等待来自服务器的响应,而是在等待任意消息。

2.改进使客户端只接收对应服务器的消息

area_server2.erl:

 1-module(area_server2).
 2-export([loop/0,rpc/2]).
 3
 4% 客户端
 5rpc(Pid, Request) ->
 6    Pid ! {self(), Request},
 7    receive
 8        {Pid, Response} ->
 9            Response
10    end.
11
12%服务器
13loop() ->
14    receive
15        {From, {rectangle, Width, Ht}} ->
16            From ! {self(), Width * Ht},
17            loop();
18        {From, {circle, R}} ->
19            From ! {self(), 3.14159 * R * R},
20            loop();
21        {From, Other} ->
22            From ! {self(), {error, Other}},
23            loop()
24    end.

这段代码客户端多了Pid匹配,服务器多了Pid发送。

3.封装,使spawn和rpc函数隐藏

 1-module(area_server_final).
 2-export([area/2, start/0, loop/0]).
 3
 4% 客户端
 5area(Pid, What) ->
 6    rpc(Pid, What).
 7rpc(Pid, Request) ->
 8    Pid ! {self(), Request},
 9    receive
10        {Pid, Response} ->
11            Response
12    end.
13
14%服务器
15start() ->
16    spawn(area_server_final, loop, []).
17loop() ->
18    receive
19        {From, {rectangle, Width, Ht}} ->
20            From ! {self(), Width * Ht},
21            loop();
22        {From, {circle, R}} ->
23            From ! {self(), 3.14159 * R * R},
24            loop();
25        {From, Other} ->
26            From ! {self(), {error, Other}},
27            loop()
28    end.

请注意,还需要把spawn的参数(也就是loop/0)从模块中导出。这是一种好的做法,因为它能让我们在不改变客户端 代码的情况下修改服务器的内部细节。(我想可能是加参数的一些东西)

计算进程平均创建时间

processes.erl

 1-module(processes).
 2-export([max/1]).
 3
 4% max(N)
 5% 创建N个进程然后销毁
 6
 7max(N) ->
 8    Max = erlang:system_info(process_limit),
 9    io:format("Maximum allowed processes: ~p~n", [Max]),
10    statistics(runtime),
11    statistics(wall_clock),
12    L = for(1, 
13            N, 
14            % 创建进程wait函数
15            fun() -> 
16                spawn(fun() 
17                          -> wait() 
18                      end) 
19            end),
20    {_, Time1} = statistics(runtime),
21    {_, Time2} = statistics(wall_clock),
22    lists:foreach(
23                  fun(Pid) -> 
24                      Pid ! die end, 
25                  L),
26    U1 = Time1 * 1000 / N,
27    U2 = TIme2 * 1000 / N,
28    io:format("Process spawn time=~p(~p) microseconds~n", [U1, U2]).
29
30wait() ->
31    receive
32        die -> void
33    end.
34
35for(N,N,F) -> [F()];
36for(I,N,F) -> [F()|for(I+1,N,F)].
  • 内置函数 erlang:system_info(process_limit)来找出所允许的最大进程数量。其中有一些是系统保留的进程,所以你的程序实际上不能用那么多。当超出限制值时,系统会拒绝启动更多的进程并生成一个错误报告

  • 系统内设的限制值是262 144个进程。要超越这一限制,必须用+P标识启动Erlang仿真器

    1erl +P 3000000
    

    随着进程数量的增加,进程分裂时间也在增加。如果继续增加进程的数量,最终会耗尽物理内存,导致系统开始把物理内存交换到硬盘上,运行速度明显变慢。

  • statistics(wall_clock):Returns information about wall clock. wall_clock can be used in the same manner as runtime, except that real time is measured as opposed to runtime or CPU time.

  • statistics(runtime):Returns information about runtime, in milliseconds.This is the sum of the runtime for all threads in the Erlang runtime system and can therefore be greater than the wall clock time.

超时的接收

  1. 给接收语句增加一个超时设置,设定进程等待接收消息的最长时间:

    1receive
    2	Pattern1 [when Guard1] ->
    3        Expressions1;
    4	Pattern2 [when Guard2] ->
    5        Expressions2;
    6	...
    7after Time ->
    8          Expressions
    9end
    

    如果在进入接收表达式的Time毫秒后还没有收到匹配的消息,进程就会停止等待消息,转而执行Expressions。

    当然如果在规定时间受到匹配消息,这条语句就结束了,receive end只会处理一条语句。具体原理看下一节

  2. 只带超时的接收:

    可以编写一个只有超时部分的receive。通过这种方法,我们可以定义一个sleep(T)函数,它会让当前的进程挂起T毫秒:

    1sleep(T) ->
    2    receive
    3    after T ->
    4            true
    5    end.
    
  3. 超时值为 0 的接收:

    超时值为0会让超时的主体部分立即发生,但在这之前,系统会尝试对邮箱里的消息进行匹配。(先对消息匹配,匹配完之后让超时部分立即发生)

    请记住,只有当邮箱里的所有条目都进行过模式匹配后,才会检查after部分。

    对大的邮箱使用优先接收是相当低效的,所以如果你打算使用这一技巧,请确保邮箱不要太满。

    可以用它来定义一个flush_buffer函数,它会清空进程邮箱里的所有消息:

    1flush_buffer() ->
    2    receive
    3        _Any ->
    4            flush_buffer()
    5    % 如果没有超时子句,flush_buffer就会在邮箱为空时永远挂起且不返回。
    6    after 0 ->
    7            true
    

    还可以使用零超时来实现某种形式的“优先接收”:

     1priority_receive() ->
     2    receive
     3        % 如果存在匹配{alarm, X}的消息,这个消息就会被立即返回。
     4        % 如果没有after 0语句,警告(alarm)消息就不会被首先匹配。
     5        {alarm,X} ->
     6            {alarm,X}
     7    after 0 ->
     8            % 如果邮箱里不存在匹配{alarm, X}的消息,priority_receive就会接收邮箱里的第一个消息。
     9            % 如果没有任何消息,它就会在最里面的接收语句处挂起,并返回它收到的第一个消息。
    10            receive
    11                Any ->
    12                    Any
    13            end
    14    end.
    
  4. 超时值为无穷大的接收:

    如果接收语句里的超时值是原子infinity(无穷大),就永远不会触发超时。这对那些在接收语句之外计算超时值的程序可能很有用。有时候计算的结果是返回一个实际的超时值,其他的时候则是让接收语句永远等待下去。

  5. 可以用接收超时来实现一个简单的定时器:

    stimer.erl:

     1-module(stimer).
     2-export([start/2, cancel/1]).
     3   
     4start(Time, Fun) -> spawn(fun() -> timer(Time,Fun) end).
     5% 向进程发送消息cancel
     6cancel(Pid) -> Pid ! cancel.
     7timer(Time, Fun) ->
     8    receive
     9        % 在Time前收到消息cancel则会关闭这个定时器
    10        cancel ->
    11            void
    12    after Time ->
    13            Fun()
    14    end.
    
    1> Pid = stimer:start(5000, fun() -> io:format("timer event~n") end).
    2<0.42.0>
    3timer event
    
    1> Pid1 = stimer:start(25000, fun() -> io:format("timer event~n") end).
    2<0.49.0>
    3> stimer:cancel(Pid1).
    4cancel
    

receive的工作方式

  1. 进入receive语句时会启动一个定时器(但只有当表达式包含after部分时才会如此)。

  2. 取出邮箱里的第一个消息,尝试将它与Pattern1、Pattern2等模式匹配。

  3. 如果匹配成功,系统就会从邮箱中移除这个消息,并执行模式后面的表达式。

    如果receive语句里的所有模式都不匹配邮箱的第一个消息,系统就会从邮箱中移除这个消息并把它放入一个“保存队列

    然后继续尝试邮箱里的第二个消息。这一过程会不断重复,直到发现匹配的消息或者邮箱里的所有消息都被检查过了为止。

  4. 如果邮箱里的所有消息都不匹配,进程就会被挂起并重新调度,直到新的消息进入邮箱才会继续执行。新消息到达后,保存队列里的消息不会重新匹配,只有新消息才会进行匹配。

  5. 后面一旦某个消息匹配成功,保存队列里的所有消息就会按照到达进程的顺序重新进入邮箱。如果设置了定时器,就会清除它。

  6. 如果定时器在我们等待消息时到期了,系统就会执行表达式ExpressionsTimeout,并把所有保存的消息按照它们到达进程的顺序重新放回邮箱。

注册进程

如果想给一个进程发送消息,就需要知道它的PID,但是当进程创建时,只有父进程才知道它的PID。系统里没有其他进程知道它的存在。

Erlang有一种公布进程标识符的方法,它让系统里的任何进程都能与该进程通信。这样的进程被称为注册进程(registered process)。管理注册进程的内置函数有四个:

  • register(AnAtom, Pid)

    用AnAtom(一个原子)作为名称来注册进程Pid。如果AnAtom已被用于注册某个进程,这次注册就会失败。

  • unregister(AnAtom)

    移除与AnAtom关联的所有注册信息。如果某个注册进程崩溃了,就会自动取消注册。

  • whereis(AnAtom) -> Pid | undefined

    检查AnAtom是否已被注册。如果是就返回进程标识符Pid,如果没有找到与AnAtom关联的进程就返回原子undefined。

  • registered() -> [AnAtom::atom()]

    返回一个包含系统里所有注册进程的列表。

示例:

一旦名称注册完成,就可以用注册的原子给它发送消息

1> Pid = spawn(area_server0, loop, []).
2<0.51.0>
3> register(area, Pid).
4true
5> area ! {rectangle, 4, 5}.
6Area of rectangle is 20
7{rectangle, 4, 5}

一个模拟时钟的进程:

 1-module(clock).
 2-export([start/2, stop/0]).
 3
 4% 创建一个tick进程并注册为clock
 5start(Time, Fun) ->
 6    register(clock, spawn(fun() -> tick(Time, Fun) end)).
 7% 给名为clock的注册进程发送消息stop
 8stop() -> clock ! stop.
 9%每隔Time执行一次Fun,然后重复
10tick(Time, Fun) ->
11    receive
12        stop ->
13            void
14    after Time ->
15            Fun(),
16            tick(Time, Fun)
17    end.
1> clock:start(5000, fun() -> io:format("TICK ~p~n", [erlang:now()]) end).	
2
3> clock:stop().

尾递归

如果你仔细观察,就会发现每当我们收到消息时就会处理它并立即再次调用loop()。这一过程被称为尾递归(tail-recursive)。可以对一个尾递归的函数进行特别编译,把语句序列里的最后一次函数调用替换成跳至被调用函数的开头。这就意味着尾递归的函数无需消耗栈空间也能一直循环下去。

假设编写了以下(不正确的)代码:

 1loop() ->
 2    receive
 3        {From, {rectangle, Width, Ht}} ->
 4            From ! {self(), Width * Ht},
 5            loop(),
 6            someOtherFunc();
 7        {From, {circle, R}} ->
 8            ....
 9	end
10end

我们在第5行里调用了loop(),但是编译器必然推断出“当我调用loop()后必须返回这里,因为我得调用第6行里的someOtherFunc()”。于是它把someOtherFunc的地址推入栈,然后跳到loop的开头。这么做的问题在于loop()是永不返回的,它会一直循环下去。所以,每次经过第5行,就会有一个返回地址被推入控制栈,最终系统的空间会消耗殆尽。

避免这个问题的方法很简单,如果你编写的函数F是永不返回的(就像loop()一样),就要确保在调用F之后不再调用其他任何东西,并且别把F用在列表或元组构造器里

并发程序模板

 1-module(ctemplate).
 2-compile(export_all).
 3
 4start() ->
 5    spawn(?MODULE, loop, []).
 6
 7rpc(Pid, Request) ->
 8    Pid ! {self(), Request},
 9    receive
10        {Pid, Response} ->
11            Response
12    end.
13
14loop(X) ->
15    receive
16        Any ->
17            io:format("Received:~p~n", [Any]),
18            loop(X)
19    end.

给接收循环添加一个匹配模式并重新运行程序。这一技巧在相当程度上决定了我编写程序的顺序:从一个小程序开始,逐渐扩展它,并在开发过程中不断进行测试。

练习

(3)

编写一个环形计时测试。创建一个由N个进程组成的环。把一个消息沿着环发送M次,这样总共发送的消息数量是N * M。记录不同的N和M值所花费的时间。

创建这些进程是非常麻烦的,如果是同步创建,那么不能绑定Pid,只能在发送的时候寻找Pid,那么就必须要注册,而注册使用的原子需要是不一样且有规律的,所以说需要字符串处理拼接。

字符串拼接不熟悉,先放这。

 1%%%-------------------------------------------------------------------
 2%%% @author bnaod1
 3%%% @copyright (C) 2024, <COMPANY>
 4%%% @doc
 5%%%
 6%%% @end
 7%%% Created : 12. 12月 2024 15:25
 8%%%-------------------------------------------------------------------
 9-module(ring_timer).
10-author("bnaod1").
11
12%% API
13-export([]).
14
15
16init(N) ->
17  % 创建N个进程
18  L = for(1, N, fun() -> spawn(fun() -> ring() end) end),
19  % 注册进程
20
21
22registerN([]) ->
23  void;
24registerN(L) ->
25  [H|T] = L,
26  register(,H),
27  registerN(T).
28
29
30ring() ->
31  receive
32    _Any ->
33      % 将消息发给下一个进程
34      Pid = find_next(self()),
35      Pid ! _Any,
36      io:format("Send msg ~p to ~p~n", [Pid, _Any]),
37      ring()
38  end.
39
40
41
42for(N,N,F) -> [F()];
43for(I,N,F) -> [F()|for(I+1,N,F)].
44
45

第13章 并发程序中的错误

在Erlang里,我们有大量的进程可供支配,因此任何单进程故障都不算特别重要。通常只需编写少量的防御性代码,而把重点放在编写纠正性代码上。我们采取各种措施检测错误,然后在错误发生后纠正它们。

检测错误和找出故障原因内建于Erlang虚拟机底层的功能,也是Erlang编程语言的一部分。标准OTP库提供了构建互相监视的进程组和在检测到错误时采取纠正措施的功能,23.5节中会进行相关介绍。这一章介绍的是语言层面的错误检测和恢复。

错误处理的理念

  • 让其他进程修复错误:要让一个进程监控另一个,就必须在它们之间创建一个连接(link)或监视(monitor)。如果被连接或监视的进程挂了,监控进程就会得到通知。

    这可以作为顺序代码错误处理的延伸。虽然可以捕捉顺序代码里的异常并尝试纠正错误(这是第6章的主题),但如果失败了或者整台机器出了故障,就要让其他进程来修复错误。

  • 任其崩溃:如果在错误发生后第一时间举旗示意,就能得到非常好的错误诊断。在错误发生后继续运行经常会导致更多错误发生,让调试变得更加困难。

错误处理的术语

  • 进程

    进程有两种:普通进程和系统进程。

    spawn创建的是普通进程。

    普通进程可以通过执行内置函数process_flag(trap_exit, true)变成系统进程。

  • 连接

    进程可以互相连接。如果A和B两个进程有连接,而A出于某种原因终止了,就会向B发送一个错误信号,反之亦然。

  • 连接组

    进程P的连接组是指与P相连的一组进程。

  • 监视

    监视和连接很相似,但它是单向的。如果A监视B,而B出于某种原因终止了,就会向A发送一个“宕机”消息,而不是退出信号。但反过来就不行了

    当你想要不对称的错误处理时,可以使用监视,对称的错误处理则适合使用连接。监视通常会被服务器用来监视客户端的行为。

  • 消息和错误信号

    进程协作的方式是交换消息或错误信号。

    消息是通过基本函数send发送的,错误信号则是进程崩溃或进程终止时自动发送的。错误信号会发送给终止进程的连接组。

  • 错误信号的接收

    当系统进程收到错误信号时,该信号会被转换成{‘EXIT’, Pid, Why}形式的消息。Pid是终止进程的标识,Why是终止原因(有时候被称为退出原因)。如果进程是无错误终止,Why就会是原子normal,否则Why会是错误的描述。(基于这个特性,系统进程可以充当防火墙的作用)

    当普通进程收到错误信号时,如果退出原因不是normal,该进程就会终止。当它终止时,同样会向它的连接组广播一个退出信号

  • 显式错误信号

    任何执行exit(Why)的进程都会终止(如果代码不是在catch或try的范围内执行的话),并向它的连接组广播一个带有原因Why的退出信号

    进程可以通过执行exit(Pid, Why)来发送一个“虚假”的错误信号。在这种情况下,Pid会收到一个带有原因Why的退出信号。调用exit/2的进程则不会终止(这是有意如此的)。

  • 不可捕捉的退出信号

    系统进程收到摧毁信号(kill signal)时会终止。摧毁信号是通过调用exit(Pid, kill)生成的。这种信号会绕过常规的错误信号处理机制,不会被转换成消息。摧毁信号只应该用在其他错误处理机制无法终止的顽固进程上。

基本错误处理函数

  • -spec spawn_link(Fun) -> Pid

    -spec spawn_link(Mod, Fnc, Args) -> Pid

    它们的行为类似于spawn(Fun)和spawn(Mod,Func,Args),同时还会在父子进程之间创建连接。

  • -spec spawn_monitor(Fun) -> {Pid, Ref}

    -spec spawn_monitor(Mod, Func, Args) -> {Pid, Ref}

    它与spawn_link相似,但创建的是监视而非连接。Pid是新创建进程的进程标识符,Ref是该进程的引用。如果这个进程因为Why的原因终止了,消息{‘DOWN’,Ref,process,Pid,Why}就会被发往父进程。

  • -spec process_flag(trap_exit, true)

    它会把当前进程转变成系统进程。系统进程是一种能接收和处理错误信号的进程。

  • -spec link(Pid) -> true

    它会创建一个与进程Pid的连接。连接是双向的。如果进程A执行了link(B),就会与B相连。实际效果就和B执行link(A)一样。

    如果进程Pid不存在,就会抛出一个noproc退出异常。

    如果执行link(B)时A已经连接了B(或者相反),这个调用就会被忽略。

  • -spec unlink(Pid) -> true

    它会移除当前进程和进程Pid之间的所有连接。

  • -spec erlang:monitor(process, Item) -> Ref

    它会设立一个监视。Item可以是进程的Pid,也可以是它的注册名称。

  • -sepc demonitor(Ref) -> true

    它会移除以Ref作为引用的监视。

  • -spec exit(Why) -> none()

    它会使当前进程因为Why的原因终止。如果执行这一语句的子句不在catch语句的范围内,此进程就会向当前连接的所有进程广播一个带有参数Why的退出信号。它还会向所有监视它的进程广播一个DOWN消息。

  • -spec exit(Pid, Why) -> true

    它会向进程Pid发送一个带有原因Why的退出信号。执行这个内置函数的进程本身不会终止。它可以用于伪造退出信号。

容错式编程示例

1.在进程终止时执行操作

1on_exit(Pid, Fun) ->
2    spawn(fun() ->
3         	Ref = monitor(process, Pid),
4         	receive
5         		{'DOWN', Ref, process, Pid, Why} ->
6         			Fun(Why)
7         	end
8    	 end).
 11> F = fun() ->
 2			receive
 3				X -> list_to_atom(X)
 4			end
 5		end.
 62> Pid = spawn(F).
 7
 83> lib_misc:on_exit(Pid, 
 9					fun(Why) ->
10						io:format(" ~p died with:~p~n", [Pid, Why])
11					end).
12# 如果向Pid发送一个原子,这个进程就会挂掉(因为它试图对非列表类型执行list_to_atom)
134> Pid ! hello.
14hello
155>
16=ERROR REPORT====
  • 进程挂掉时触发的函数可以执行任何它喜欢的计算:它可以忽略错误,记录错误或者重启应用程序。这个选择完全取决于程序员。

2.让一组进程共同终止

1start(Fs) ->
2    spawn(fun() ->
3              	% 创建进程并且建立连接
4         		[spawn_link(F) || F <- Fs],
5         		receive
6         		after
7         			infinity -> true
8         		end
9         end).
1Pid = start([F1,F2,...]),
2on_exit(Pid, fun(Why) ->
3					...
4					...
5			end)
  • 各个进程通过start函数创建的进程连接,于是一个挂了发送的退出信号全部都会接收,于是全都会终止

3.生成一个永不终止的进程

1keep_alive(Name,Fun) ->
2    register(Name, Pid = spawn(Fun)),
3    on_exit(Pid, fun(_Why) -> keep_alive(Name, Fun) end).
  • 可能会在register和on_exit这两个语句之间挂掉。如果进程在on_exit被执行之前终止,就不会创建连接,on_exit进程的行为就会和预计的不同。如果有两个程序同时尝试用相同的Name值执行keep_alive,这个错误就会发生这被称为竞争状况(race condition):两段代码(都是这段)和on_exit里执行连接操作的代码片段正在互相竞争。如果这里出了错,程序就可能会表现出你预料之外的行为。
  • 一般语言解决并发程序竞争时可以加锁或者设置信号量,不过Erlang语言的全局变量不太好搞定。

练习

(1)

编写一个my_spawn(Mod, Func, Args)函数。它的行为类似spawn(Mod, Func, Args),但有一点区别。如果分裂出的进程挂了,就应打印一个消息,说明进程挂掉的原因以及在此之前存活了多长时间。

使用spawn函数需要注意,模块里面的函数使用spawn最好时MFA的形式,而匿名函数使用Fun的形式,不然会有一些奇怪的问题。

 1%%%-------------------------------------------------------------------
 2%%% @author bnaod1
 3%%% @copyright (C) 2024, <COMPANY>
 4%%% @doc
 5%%%
 6%%% @end
 7%%% Created : 13. 12月 2024 09:10
 8%%%-------------------------------------------------------------------
 9-module(my_spawn).
10-author("bnaod1").
11
12%% API
13-export([my_spawn/3, test/0]).
14
15
16my_spawn(Mod, Func, Argc) ->
17  Pid = spawn(Mod, Func, Argc),
18  io:format("Process ~p start.~n", [Pid]),
19  statistics(runtime),
20  Ref = monitor(process, Pid),
21  receive
22    {'DOWN', Ref, process, Pid, Why} ->
23      {_, Time} = statistics(runtime),
24      U = Time,
25      io:format("Process ~p survived ~p seconds.~n", [Pid, U]),
26      io:format("Process ~p died for reason ~p~n", [Pid, Why])
27  end
28  .
29
30
31
32test() ->
33    receive
34      X -> list_to_atom(X)
35    end.
 11> c(my_spawn).
 2{ok,my_spawn}
 32> Pid = spawn(my_spawn, my_spawn, [my_spawn, test, []]).
 4Process <0.90.0> start.
 5<0.89.0>
 63> <0.90.0> ! e1.
 7Process <0.90.0> survived 9 seconds.
 8=ERROR REPORT==== 13-Dec-2024::10:38:36.634989 ===
 9Error in process <0.90.0> with exit value:
10{badarg,[{erlang,list_to_atom,
11                 [e1],
12                 [{error_info,#{module => erl_erts_errors}}]},
13         {my_spawn,test,0,[{file,"my_spawn.erl"},{line,34}]}]}
14
15e1
16Process <0.90.0> died for reason {badarg,
17                                  [{erlang,list_to_atom,
18                                    [e1],
19                                    [{error_info,
20                                      #{module => erl_erts_errors}}]},
21                                   {my_spawn,test,0,
22                                    [{file,"my_spawn.erl"},{line,34}]}]}
234> 

(2)

用本章前面展示的on_exit函数来完成上一个练习。

这个题就是count函数,如果传函数定义的话是不行的,只能使用匿名函数。

破案了,原来是spawn里面参数不能加括号。

 1%%%-------------------------------------------------------------------
 2%%% @author bnaod1
 3%%% @copyright (C) 2024, <COMPANY>
 4%%% @doc
 5%%%
 6%%% @end
 7%%% Created : 13. 12月 2024 10:40
 8%%%-------------------------------------------------------------------
 9-module(question2).
10-author("bnaod1").
11
12%% API
13-export([time/0,on_exit/2,test/0,sleep/2]).
14
15
16time() ->
17  %% 创建测试进程
18  Pid = spawn(question2, test, []),
19  %% 开始计时
20  statistics(wall_clock),
21  %% 创建监视进程,一旦死亡执行count计算时间
22  on_exit(Pid, fun() ->
23                    {_, Time} = statistics(wall_clock),
24                    io:format("Duration: ~p~n", [Time])
25               end),
26  %% 创建一个进程,隔一定时间使进程死亡
27  sleep(Pid, 3000),
28  done.
29
30%% 监视器
31on_exit(Pid, Fun) ->
32  spawn(fun() ->
33    Ref = monitor(process, Pid),
34    receive
35      {'DOWN', Ref, process, Pid, Why} ->
36        Fun()
37    end
38  end).
39
40%% 计算存活时间
41%%count() ->
42%%  {_, Time} = statistics(runtime),
43%%  io:format("Duration: ~p~n", [Time]).
44
45%% 隔一定时间发送死亡通知
46sleep(Pid, T) ->
47  spawn(fun() ->
48          receive
49          after T ->
50            Pid ! seterror
51          end
52        end
53  ).
54
55
56%% 测试函数
57test() ->
58  receive
59    X -> list_to_atom(X)
60  end.

(3)

编写一个my_spawn(Mod, Func, Args, Time)函数。它的行为类似spawn(Mod, Func,Args),但有一点区别。如果分裂出的进程存活超过了Time秒,就应当被摧毁。

使用spawn函数时,放入的函数只能是名字,不能加括号。

 1%%%-------------------------------------------------------------------
 2%%% @author bnaod1
 3%%% @copyright (C) 2024, 四川农业大学
 4%%% @doc
 5%%%
 6%%% @end
 7%%% Created : 13. 12月 2024 11:19
 8%%%-------------------------------------------------------------------
 9-module(question3).
10-author("bnaod1").
11
12%% API
13-export([start/0,my_spawn/4,test/0]).
14
15start() ->
16  spawn(question3, my_spawn, [question3, test, [], 3000]).
17
18my_spawn(Mod, Func, Args, Time) ->
19  Pid = spawn(Mod, Func, Args),
20  receive
21  after Time ->
22    Pid ! shutdown
23  end.
24
25test() ->
26  io:format("test is running.~n"),
27  receive
28    shutdown ->
29      exit("Time limit~n")
30  after 1000 ->
31    test()
32  end.
33

(4)

编写一个函数,让它创建一个每隔5秒就打印一次“我还在运行”的注册进程。

编写一个函数来监视这个进程,如果进程挂了就重启它。

启动公共进程和监视进程,然后摧毁公共进程,检查它是否会被监视进程重启。

 1%%%-------------------------------------------------------------------
 2%%% @author bnaod1
 3%%% @copyright (C) 2024, 四川农业大学
 4%%% @doc
 5%%%
 6%%% @end
 7%%% Created : 13. 12月 2024 14:35
 8%%%-------------------------------------------------------------------
 9-module(question4).
10-author("bnaod1").
11
12%% API
13-export([start/0,make/0,print/0,monitor_print/0]).
14
15start() ->
16  make(),
17  spawn(question4, monitor_print, []).
18
19
20make() ->
21  register(period, spawn(question4, print, [])).
22
23print() ->
24  receive
25    {_, die} ->
26      io:format("died~n"),
27      exit("command die");
28    {Pid, comfirm} ->
29      Pid ! {self(), "still alive"}
30  after 5000 ->
31    io:format("Im still running.~n"),
32    print()
33  end.
34
35monitor_print() ->
36  Ref = monitor(process, period),
37  receive
38    {'DOWN', Ref, process, Pid, Why} ->
39      io:format("Process period died, restart...~n"),
40      make(),
41      spawn(question4, monitor_print, [])
42  end.
43
44
45

(5)

编写一个函数来启动和监视多个工作进程。如果任何一个工作进程非正常终止,就重启它。

1

编写一个函数来启动和监视多个工作进程。如果任何一个工作进程非正常终止,就摧毁所有工作进程,然后重启它们。

1

第14章 分布式编程

分布式优势

  • 性能:可以通过安排程序的不同部分在不同的机器上并行运行来让程序跑得更快。
  • 可靠性:可以通过让系统运行在数台机器上来实现容错式系统。如果一台机器出了故障,可以在另一台机器上继续。
  • 可扩展性:随着我们把应用程序越做越大,即使机器的处理能力再强大也迟早会耗尽。到那时,就必须添加更多的机器来提升处理能力。添加一台新机器应当是一次简单的操作,不需要对应用程序的架构做出大的修改。

分布式Erlang示例

本质上都是通过rpc在远程节点上执行操作然后返回。注意这里就有节点的称呼了。

不同阶段处理的问题主要是最开始的连通问题,而不是代码问题,他们都是使用rpc对远程服务器进行的方法调用。所以本质来说我认为只有一个服务器节点。

第1阶段:双方在同一节点

一个简单的名称服务器。这步的代码和我们之前所学的一样

socket_dist/kvs.erl:

 1-module(kvs).
 2-export([start/0, store/2, lookup/1]).
 3
 4%注册服务器进程为kvs
 5start() -> register(kvs, spawn(fun() -> loop() end)).
 6
 7%服务器:一直在接收消息中
 8loop() ->
 9    receive
10        {From, {store, Key, Value}} ->
11            put(Key, {ok, Value}),
12            From ! {kvs, true},
13            loop();
14        {From, {lookup, Key}} ->
15            From ! {kvs, get(Key)},
16            loop()
17    end.
18
19%封装客户端的消息
20store(Key, Value) -> rpc({lookup, Key}).
21lookup(Key) -> rpc({lookup, Key}).
22
23%客户端:每次进行一次操作然后等待服务器回信
24rpc(Q) ->
25    kvs ! {self(), Q},
26    receive
27        {kvs, Reply} ->
28            Reply
29    end.
30
31

测试:

 1> kvs:start().
 2true
 3> kvs:store({location, joe}, "Stockholm").
 4true
 5> kvs:store(weather, raining).
 6true
 7> kvs:lookup(weather).
 8{ok, raining}
 9> kvs:lookup({location, joe}).
10{ok, "Stockholm"}
11> kvs:lookup({location, jane}).
12undefined

第2阶段:双方在同一主机不同节点

终端1:服务器

1$ erl -sname gandalf
2(gandalf@localhost) 1> kvs:start().
3true

终端2:客户端

1$ erl -sname bilbo
2(bilbo@localhost) 1> rpc:call(gandalf@localhost, kvs, store, [weather, fine]).
3true
4(bilbo@localhost) 2> rpc:call(gandalf@localhost, kvs, lookup, [weather]).
5{ok, fine}
  • erl -sname gandalf

    参数-sname gandalf的意思是“在本地主机上启动一个名为gandalf的Erlang节点”。注意一下Erlang shell是如何把Erlang节点名打印在命令提示符前面的。

    节点名的形式是Name@Host。Name和Host都是原子,所以如果它们包含任何非原子的字符,就必须加上引号。

  • rpc:call(Node, Mod, Func, [Arg1, Arg2, .., ArgN])

    rpc是一个标准的Erlang库模块。这个函数在Node上执行一次远程过程调用。调用的函数是Mod:Func(Arg1, Arg2, …, ArgN)。

  • 上面这个过程是客户端远程执行服务器的函数

第3阶段:双方在同一局域网不同机器上

填入节点名称时需要加上单引号。然后有可能会报错,使用创建节点:

1erl -name [email protected] -setcookie abc

机器1终端:服务器

1doris $ erl -name gandalf -setcookie abc
2(gandalf@doris.myerl.example.com) 1> kvs:start().
3true

机器2终端:客户端

1george $ erl -name bilbo -setcookie abc
2(bilbo@george.myerl.example.com) 1> rpc:call(gandalf@doris.myerl.example.com, kvs, store, [weather, cold]).
3true
4(bilbo@george.myerl.example.com) 2> rpc:call(gandalf@doris.myerl.example.com, kvs, lookup, [weather]).
5{ok, cold}
  • -name参数启动Erlang。

    我们在同一台机器上运行两个节点时使用了“短”(short)名称(通过-sname标识体现)。但如果它们属于不同的网络,我们就要使用-name。

    当两台机器位于同一个子网时我们也可以使用-sname。而且如果没有DNS服务,-sname就是唯一可行的方式。

  • 使用命令行参数-setcookie abc, 确保两个节点拥有相同的cookie。

    当我们在同一台机器上运行两个节点时,因为它们都能访问同一个cookie文件$HOME/.erlang.cookie,所以我们不需要在Erlang命令行里添加cookie。

  • 确保相关节点的完全限定主机名(fully qualified hostname)可以被DNS解析。对于我来说,域名myerl.example.com完全属于我的家庭网络,通过在/etc/hosts里添加一个条目来实现本地解析。

  • 确保两个系统拥有相同版本的代码和相同版本的Erlang。如果不这么做,就可能会得到严重而离奇的错误。

第4阶段:双方在互联网不同主机上

原则上,这和第3阶段是一样的,但现在我们必须更加关注安全性。

  • 确保4369端口对TCP和UDP流量都开放。这个端口会被一个名为epmd的程序使用(它是Erlang Port Mapper Daemon的缩写,即Erlang端口映射守护进程)。

  • 选择一个或一段连续端口给分布式Erlang使用,并确保这些端口是开放的。如果这些端口位于Min和Max之间(只想用一个端口就让Min=Max),就用以下命令启动Erlang:

    1$ erl -name ... -setcookie ... -kernel inet_dist_listen_min Min  inet_dist_listen_max Max
    

两种分布式模型

1.分布式Erlang

  • 编写的程序会在Erlang的节点(node)上运行。节点是一个独立的Erlang系统,包含一个自带地址空间和进程组的完整虚拟机。
  • 分布式Erlang应用程序运行在一个可信环境中。因为任何节点都可以在其他Erlang节点上执行任意操作,所以这涉及高度的信任。虽然分布式Erlang应用程序可以运行在开放式网络上,但它们通常是运行在属于同一个局域网的集群上,并受防火墙保护。

2.基于套接字的分布式模型

  • 可以用TCP/IP套接字来编写运行在不可信环境中的分布式应用程序。这个编程模型不如分布式Erlang那样强大,但是更安全。

一、分布式编程的库和内置函数

在我的理解中,Node也是进程。

分布式相关库:

  1. rpc提供了许多远程过程调用服务。

    call(Node, Mod ,Function, Args) -> Result | {badrpc, Reason}: 它会在Node上执行apply(Mod, Function, Args),然后返回结果Result,如果调用失败则返回{badrpc, Reason}。

  2. global里的函数可以用来在分布式系统里注册名称和加锁,以及维护一个全连接网络。

  3. erlang模块有很多基本函数

erlang库中的分布式基本函数:

  • net_adm:ping(NodeName) 连接节点

  • -spec monitor_node(Node, Flag) -> true

    如果Flag是true就会开启监视,Flag是false就会关闭监视。如果开启了监视,那么当Node加入或离开Erlang互连节点组时,执行这个内置函数的进程就会收到{nodeup, Node}或{nodedown, Node}的消息。

  • -spec node() -> Node

    它会返回本地节点的名称。如果节点不是分布式的则会返回nonode@nohost。

  • -spec node(Arg) ->Node

    它会返回Arg所在的节点。Arg可以是PID、引用或者端口。如果本地节点不是分布式的,则会返回nonode@nohost。

  • -spec nodes() -> [Node]

    它会返回一个列表,内含网络里其他所有与我们相连的节点。

  • spec is_alive() -> bool()

    如果本地节点是活动的,并且可以成为分布式系统的一部分,就返回true,否则返回false。

  • -spec spawn(Node, Fun) -> Pid

    它的工作方式和spawn(Fun)完全一致,只是新进程是在Node上分裂的进程。

  • -spec spawn(Node, Mod, Func, ArgList) -> Pid

    它的工作方式和spawn(Mod, Func, ArgList)完全一致,只是新进程是在Node上分裂的。spawn(Mod, Func, Args)会创建一个执行apply(Mod, Func, Args)的新进程。它会返回这个新进程的PID。

    这种形式的spawn比spawn(Node, Fun)更加健壮。如果运行在多个分布式节点上的特定模块不是完全相同的版本,spawn(Node, Fun)就可能会出错。

  • -spec spawn_link(Node, Fun) -> Pid

    它的工作方式和spawn_link(Fun)完全一致,只是新进程是在Node上分裂的。所以新分裂的进程会连接Node节点。

  • -spec spawn_link(Node, Mod, Func, ArgList) -> Pid

    它的工作方式类似spawn(Node, Mod, Func, ArgList),但是新进程会与当前进程相连接。

  • -spec disconnect_node(Node) -> bool() | ignored

    它会强制断开与某个节点的连接。

远程分裂示例

这个示例在本质上也是rpc调用。

dist_demo.erl

 1-module(dist_demo).
 2-export([rpc/4, start/1]).
 3
 4start(Node) ->
 5    spawn(Node, fun() -> loop() end).
 6
 7rpc(Pid, M, F, A) ->
 8    Pid ! {rpc, self(), M, F, A},
 9    receive
10        {Pid, Response} ->
11            Response
12    end.
13
14%% 服务器根据匹配的消息创建进程
15loop() ->
16    receive
17        {rpc, Pid, M, F, A} ->
18            Pid ! {self(), (catch apply(M, F, A))},
19            loop()
20    end.
  • (catch apply(M, F, A)):就是运行MFA,然后有异常捕获异常

测试1:

  • 主机1:服务器
1doris $ erl -name gandalf -setcookie abc
2([email protected]) 1>
  • 主机2:客户端
1george $ erl -name bilbo -setcookie abc
2([email protected]) 1> Pid = dist_demo:start('[email protected]').
3<5094.40.0>
4# spawn让远程节点(gandalf)分裂一个进程
5# Pid是这个远程节点进程的标识符
6([email protected]) 2> dist_demo:rpc(Pid, erlang, node, []).
7'[email protected]'
8%% 远程分裂:在远程节点上执行erlang:node()并返回一个值。

测试2:文件服务器

由于可以在远程主机执行任何erl代码,所以可以充当文件服务器:

1([email protected]) 1> Pid = dist_demo:start('[email protected]').
2([email protected]) 2> dist_demo:rpc(Pid, file, get_cwd, []).
3{ok, "/home/joe/projects/book/code"}
4([email protected]) 3> dist_demo:rpc(Pid, file, list_dir, ["."]).
5{ok, ["adapter_db1.erl", "processes.erl", ...]}
6([email protected]) 4> dist_demo:Pid, file, read_file, ["dist_demo.erl"]).
7{ok, <<"-module..."}

使用file模块里的三个函数来访问gandalf主机的文件系统 :

  • file:get_cwd() 返回文件服务器的当前工作目录
  • file:list_dir(Dir)返回Dir里所有文件的列表
  • file:read_file(File) 读取文件File。

测试3:远程命令执行摧毁计算机

分布式Erlang适合编写那些可信任其他参与者的集群应用程序

分布式Erlang的主要问题在于客户端可以自行决定在服务器上分裂出各种进程。因此,要摧毁你的系统,只需执行下面的命令:

1rpc:multicall(nodes(), os, cmd, ["cd /' rm -rf *'"])

cookie系统让访问单个或一组节点变得更安全。每个节点都有一个cookie,如果它想与其他任何节点通信,它的cookie就必须和对方节点的cookie相同。为了确保cookie相同,分布式Erlang系统里的所有节点都必须以相同的“神奇”(magic)cookie启动,或者通过执行erlang:set_cookie把它们的cookie修改成相同的值。Erlang集群的定义就是一组带有相同cookie的互连节点。

cookie保护系统被设计用来创建运行在局域网(LAN)上的分布式系统,LAN本身应该受防火墙保护,与互联网隔开。

可以用三种方法设置cookie:

  1. 在文件$HOME/.erlang.cookie里存放相同的cookie。这个文件包含一个随机字符串,是Erlang第一次在你的机器上运行时自动创建的。这个文件可以被复制到所有想要参与分布式Erlang会话的机器上。也可以显式设置它的值。
  2. 当Erlang启动时,可以用命令行参数-setcookie C来把神奇cookie设成C。(这种方法Unix系统里的任何用户都可以用ps命令来查看你的cookie。)
  3. 内置函数erlang:set_cookie(node(), C)能把本地节点的cookie设成原子C。

cookie从不会在网络中明文传输,它只用来对某次会话进行初始认证。分布式Erlang会话不是加密的,但可以被设置成在加密通道中运行。

二、lib_chan模块

这个lib_chan模块并不是官方库,可能是作者自己写的,正式的服务器还是等到OTP再说。

lib_chan模块让用户能够显式控制自己的机器能分裂出哪些进程:

  • -spec start_server() -> true

    它会在本地主机上启动一个服务器。这个服务器的行为由文件$HOME/.erlang_config/lib_chan.conf决定。

  • -spec start_server(Conf) -> true

    它会在本地主机上启动一个服务器。这个服务器的行为由文件Conf决定,它包含一个由下列形式的元组所组成的列表:

    {port, NNNN}:它会开始监听端口号NNNN。

    {service, S, password, P, mfa, SomeMod, SomeFunc, SomeArgsS}:它会定义一个被密码P保护的服务S。如果这个服务启动了,就会通过分裂SomeMod:SomeFunc(MM, ArgsC, SomeArgsS)创建一个进程,负责处理来自客户端的消息。这里的MM是一个代理进程的PID,可以用来向客户端发送消息。参数ArgsC来自于客户端的连接调用。

  • -spec connect(Host, Port, S, P, ArgsC) -> {ok, Pid} | {error, Why}

    尝试开启主机Host上的端口Port,然后尝试激活被密码P保护的服务S。如果密码正确,就会返回{ok, Pid}。Pid是一个代理进程的标识符,可以用来向服务器发送消息。

    当客户端调用connect/5建立连接后,就会分裂出两个代理进程,一个在客户端,另一个在服务器端。这些代理进程负责把Erlang消息转换成TCP包数据,捕捉来自控制进程的退出信号,以及套接字关闭。

基于套接字的分布式模型示例

服务器

配置文件:

1{port, 1234}.
2{service, nameServer, password, "ABXy45", mfa, mod_name_server, start_me_up, notUsed}.

它的意思是我们将在自己机器的1234端口上提供一个名为nameServer的服务。这个服务被密码ABXy45保护。

socket_dist/mod_name_server.erl:

 1-module(mod_name_server).
 2-export([start_me_up/3]).
 3
 4start_me_up(MM, _ArgsC, _ArgS) ->
 5    loop(MM).
 6
 7loop(MM) ->
 8    receive
 9        {chan, MM, {store, K, V}} ->
10            kvs:store(K,V),
11            loop(MM);
12        {chan, MM, {lookup, K}} ->
13            MM ! {send, kvs:lookup(K)},
14            loop(MM);
15        {chan_closed, MM} ->
16            true
17    end.

mod_name_server遵循以下协议:

  • 如果客户端向服务器发送一个消息{send, X},这个消息在mod_name_server里就会变 成{chan, MM, X}的形式(MM是服务器代理进程的PID)。
  • 如果客户端终止或者用于通信的套接字出于任何原因关闭了,服务器就会收到一个 {chan_closed, MM}形式的消息。
  • 如果服务器想给客户端发送一个消息X,就会通过调用MM ! {send, X}实现。
  • 如果服务器想要显式关闭连接,就会通过执行MM ! close实现。

这个协议是一个中间人协议,客户端代码和服务器代码都遵循它。本书附录B里的“lib_chan_mm:中间人”一节会更详细地解释套接字中间人代码。

测试

服务器

11> kvs:start().
2true
32> lib_chan:start_server().
4Starting a port server on 1234....
5true

客户端

11> {ok, Pid} = lib_chan:connnect("localhost", 1234, nameServer, "ABXy45", "").
2{ok, <0.43.0>}
32> lib_chan:cast(Pid, {store, joe, "writing a book"}).
4{send, {store, joe, "writing a book"}}
53> lib_chan:rpc(Pid, {lookup, joe}).
6{ok, "writing a book"}
74> lib_chan:rpc(Pid, {lookup, jim}).
8undefined

在这个案例里,决定配置文件内容的是远程机器的所有者。配置文件指定了哪些应用程序是这台机器允许运行的,以及哪个端口是用来与这些应用程序通信的。

练习

(1) 在同一主机上启动两个节点。查询rpc模块的手册页。对这两个节点执行一些远程过程调用。

(2) 重复上一个练习,这次使用同一局域网里的两个节点。

(3) 重复上一个练习,这次使用不同网络里的两个节点。

(4) 用lib_chan里的库编写YAFS(Yet Another File Server的缩写,即“又一个文件服务器”) 。你会从中学到很多知识。给你的文件服务器添加一些“装饰品”。

附录:lib_chan模块实现原理

TODO,在附录

第15章 接口技术

可以用多种方式建立外部语言程序与Erlang之间的接口:

  • 让程序以外部操作系统进程的形式在Erlang虚拟机以外运行。这是一种安全的做法。即使外部语言的代码有问题,也不会让Erlang系统崩溃。Erlang通过一种名为端口(port)的对象来控制外部进程,与外部进程的通信则是通过一个面向字节的通信信道。Erlang负责启动和停止外部程序,还可以监视它,让在它崩溃后重启。外部进程被称为端口进程,因为它是通过一个Erlang端口控制的。
  • 在Erlang内部运行操作系统命令并捕捉结果。
  • 在Erlang虚拟机的内部运行外部语言代码。这涉及链接外部代码和Erlang虚拟机代码,是一种不安全的做法。外部语言代码里的错误可能会导致Erlang系统崩溃。虽然它不安全,但还是有用的,因为这么做比使用外部进程更高效。把代码链接到Erlang内核只适用于C这样能生成本地目标代码的语言,不适用于Java这样自身拥有虚拟机的语言。

端口概述

Erlang通过名为端口的对象与外部程序通信。如果向端口发送一个消息,此消息就会被发往与端口相连的外部程序。来自外部程序的消息则会变成来自端口的Erlang消息。

对程序员而言,端口的行为就像是一个Erlang进程。你可以向它发送消息,可以注册它(就像进程一样),诸如此类。如果外部程序崩溃了,就会有一个退出信号发送给相连的进程。如果相连的进程挂了,外部程序就会被关闭。

创建端口的进程被称为该端口的相连进程。相连进程有其特殊的重要性:所有发往端口的消息都必须标明相连进程的PID,所有来自外部程序的消息都会发往相连进程。

创建端口

-spec open_port(PortName, [Opt]) -> Port

其中PortName是下列选项中的一个:

  • {spawn, Command}

    启动一个外部程序。Command是这个外部程序的名称。除非能找到一个名为Command的内链驱动,否则Command会在Erlang工作空间之外运行。

  • {fd, In, Out}

    允许一个Erlang进程访问Erlang使用的任何当前打开文件描述符。文件描述符In可以用作标准输入,文件描述符Out可以用作标准输出。

Opt是下列选项中的一个:

  • {packet, N}

    数据包(packet)前面有N(1、2或4)个字节的长度计数

  • stream

    发送消息时不带数据包长度信息。应用程序必须知道如何处理这些数据包。

  • {line, Max}

    发送消息时使用一次一行的形式。如果有一行超过了Max字节,就会在Max字节处被拆分。

  • {cd, Dir}

    只适用于{spawn, Command}选项。外部程序从Dir里启动。

  • {env, Env}

    只适用于{spawn, Command}选项。外部程序的环境通过Env列表里的环境变量进行扩展。Env列表由若干个{VarName, Value}对组成,其中VarName和Value是字符串。

端口Api

PidC即是相连进程Pid

  • Port ! {PidC, {command, Data}}

    向端口发送Data(一个I/O列表)。

  • Port ! {PidC, {connect, Pid1}}

    把相连进程的PID从PidC改为Pid1。

  • Port ! {PidC, close}

    关闭端口。

  • 1receive
    2	{Port, {data, Data}} ->
    3      ... 数据从外部进程进来 ...
    

    相连进程可以用这种方式从外部程序接收消息。

用端口建立外部C程序接口

规定协议

我们的最终目的是从Erlang里调用C文件方法。

ports/example1.c:

1int sum(int x, int y) {
2	return x+y;
3}
4
5int twice(int x){
6	return 2*x;
7}

希望能像这样调用它们:

1X1 = example1:sum(12,23),
2Y1 = example1:twice(10),

对用户而言,example1是一个Erlang模块,因此所有与C程序接口有关的细节都应该隐藏在example1模块内部。

要实现它,需要把sum(12,23)和twice(10)这样的函数调用转变成字节序列,通过端口发送给外部程序。端口给字节序列加上长度信息,然后把结果发给外部程序。当外部程序回复时,端口接收回复,并把结果发给与端口相连的进程。外部C程序和Erlang程序都必须遵循这一协议。下面是具体协议:

  • 所有数据包都以2字节的长度代码(Len)开头,后接Len字节的数据。这个包头会被端口自动添加,因为打开端口时设置了参数{packet,2}。
  • 把sum(N, M)调用编码成字节序列[1,N,M]。
  • 把twice(N)调用编码成字节序列[2,N]。
  • 参数和返回值都被假定为1字节长。

调用过程

端口驱动这个东西和端口是一个整体,应该是在创建时就有了。

  1. 本地驱动把函数调用 sum(12,23) 编码成字节序列 [1,12,23] ,然后向端口发送 {self(),{command, [1,12,23]}}消息。
  2. 端口驱动给这个消息加上2字节的长度包头,然后把字节序列 0,3,1,12,23 发给外部程序。
  3. 外部程序从标准输入里读取这5个字节,解包,调用sum函数,得到结果再封包,然后把字节序列0,1,35写入标准输出。
  4. 端口驱动移除长度包头,然后向相连进程发送一个{Port, {data, [35]}}消息。
  5. 相连进程解码这个消息,然后把结果返回给调用程序。

相应C应用与驱动程序

这个就相当于外部程序了。

文件:

  • ports/example1.c:包含了我们想要调用的函数(之前已经见过它了)。

  • ports/example1_driver.c:管理字节流协议并调用example1.c里的方法。

     1#include <stdio.h>
     2#include <stdlib.h>
     3//定义1字节为byte
     4typedef unsigned char byte;
     5  
     6int read_cmd(byte *buff);
     7int write_cmd(byte *buff, int len);
     8int sum(int x, int y);
     9int twice(int x);
    10  
    11int main() {
    12    //模式,函数调用参数一二,结果
    13    int fn, arg1, arg2, result;
    14    byte buff[100];
    15      
    16    //read_cmd函数处理协议,返回的是字节序列 [1,12,23]或 [2,10]
    17    while (read_cmd(buff) > 0) {
    18        fn = buff[0];
    19          
    20        //模式匹配函数
    21        if (fn == 1) {
    22            arg1 = buff[1];
    23            arg2 = buff[2];
    24              
    25            //调试语句,打印到stderr
    26            //fprintf(stderr, "calling sum %i %i\n", arg1, arg2);
    27            result = sum(arg1, arg2);
    28        } else if (fn == 2) {
    29            arg1 = buff[1];
    30            result = twice(arg1);
    31        } else {
    32            //未知错误直接退出
    33            exit(EXIT_FAILURE);
    34        }
    35        buff[0] = result;
    36        //同样write_cmd进行封装协议
    37        write_cmd(buff, 1);
    38    }
    39}
    
  • ports/erl_comm.c:带有读取和写入内存缓冲区的方法。相当于应用驱动(协议处理)这段代码专门用于处理带有2字节长度包头的数据,因此它与提供给端口驱动程序的{packet, 2}选项匹配。

     1#include <unistd.h>
     2typedef unsigned char byte;
     3  
     4//将0,3,1,12,23转化为1,12,23
     5int read_cmd(byte *buf);
     6//将35转化为0,1,35,len是写入的字节
     7int write_cmd(byte *buf, int len);
     8int read_exact(byte *buf, int len);
     9int write_exact(byte *buf, int len);
    10  
    11int read_cmd(byte *buf)
    12{
    13    int len;
    14    //读取长度计数,读取错误直接退出
    15    if (read_exact(buf, 2) != 2)
    16        return(-1);
    17    //这行计算实际的参数长度,无论第一字节值是多少,都取第二字节的值
    18    //执行位或运算,实际上取得就是第二字节的数字了。因为第一字节以及左移8位清零了。
    19    //0,3  那么是00000000 00000011。
    20    len = (buf[0] << 8) | buf[1];
    21    return read_exact(buf, len);
    22}
    23  
    24int write_cmd(byte *buf, int len)
    25{
    26    byte li;
    27    //这行取出len第二个字节的值 int类型是4 3 2 1字节
    28    li = (len >> 8) & 0Xff;
    29    write_exact(&li, 1);
    30    //这行取出len第一个字节的值(最低8位)
    31    li = len & 0xff;
    32    write_exact(&li, 1);
    33    return write_exact(buf, len);
    34}
    35  
    36int read_exact(byte *buf, int len)
    37{
    38    int i, got=0;
    39    //有可能量很大,所以循环读入。
    40    do {
    41        //读取,如果读取不正常直接结束
    42        if ((i = read(0, buf+got, len-got)) <= 0)
    43            return(i);
    44        got += i;
    45    } while (got<len);
    46    return(len);
    47}
    48  
    49int write_exact(byte *buf, int len)
    50{
    51    int i, wrote = 0;
    52    do {
    53        if ((i = write(1, buf+wrote, len-wrote)) <=0)
    54            return(i);
    55        wrote += i;
    56    } while (wrote<len);
    57    return (len);
    58}
    59  
    

涉及到的C标准库函数:

  • read (int __fd, void *__buf, size_t __nbytes)

    read 函数是C语言中用来读取文件数据的系统调用。

    参数一为文件描述符,其中0为stdin,1为stdout,2为stderr。

    参数二为存放读入的地方

    参数三为要读取的字节

    读取成功后,返回实际读取到的字节数;如果发生错误则返回-1。

  • ssize_t write(int handle, void *buf, int nbyte);

    write函数把buf中nbyte写入文件描述符handle所指的文档,成功时返回写的字节数,错误时返回-1.

    handle 是 文件描述符;

    buf是指定的缓冲区,即 指针,指向一段内存单元;

    nbyte是要写入文件指定的字节数;

    返回值:写入文档的字节数(成功);-1(出错)

  • write(const char* str,int n)

    这个没用到,不过是同名的写一下。

    str是 字符指针或字符数组,用来存放一个字符串。n是int型数,它用来表示输出显示字符串中字符的个数。

相应Erlang程序

ports/example1.erl:

 1-module(example1).
 2-export([start/0, stop/0]).
 3-export([twice/1, sum/2]).
 4
 5start() ->
 6    register(example1,
 7            spawn(fun() ->
 8                 %% 系统进程
 9                 process_flag(trap_exit, true),
10                 %% 创建端口
11                 Port = open_port({spawn, "./example1"}, [{packet, 2}]),
12                 %% 相连进程执行loop,通过loop操作端口
13                 loop(Port)
14               end)).
15
16stop() ->
17    %% ?MODULE是宏,展开成当前的模块名
18    %% 实际上是向系统进程发消息,因为模块名已经注册了。
19    ?MODULE ! stop.
20
21twice(X) -> call_port({twice, X}).
22sum(X,Y) -> call_port({sum, X, Y}).
23call_port(Msg) ->
24    ?MODULE ! {call, self(), Msg},
25    receive
26        {?MODULE, Result} ->
27            Result
28    end.
29
30loop(Port) ->
31    receive
32        {call, Caller, Msg} ->
33            Port ! {self(), {command, encode(Msg)}},
34            receive
35                {Port, {data, Data}} ->
36                    Caller ! {?MODULE, decode(Data)}
37            end,
38            loop(Port);
39        stop ->
40            Port ! {self(), close},
41            receive
42                {Port, closed} ->
43                    exit(normal)
44            end;
45        {'EXIT', Port, Reason} ->
46            exit({port_terminated, Reason})
47   end.
48
49encode({sum, X, Y}) -> [1, X, Y];
50encode({twice, X}) -> [2,X].
51
52decode([Int]) -> Int.

Makefile编译链接程序

prots/Makefile.mac

 1.SUFFIXES: .erl .beam .yrl
 2
 3.erl.beam:
 4		erlc -W $<
 5		
 6MODS = example1 example1_lid unit_test
 7
 8all:	${MODS:%=%.beam} example1 example1_drv.so
 9		@erl -noshell -s unit_test start
10example1: example1.c erl_comm.c example1_driver.c
11		gcc -o example1 example1.c erl_comm.c example1_driver.c
12example1_drv.so: example1_lid.c example1.c
13		gcc -arch i386 -I /usr/local/lib/erlang/usr/include\
14			-o example1_drv.so -fPIC -bundle -flat_namespace -undefined suppress\
15			example1.c example1_lid.c
16clean:
17		rm example1 example1_drv.so *.beam
  • .SUFFIXES:定义后缀,前者是前提,后者是目标
  • $<:第一个依赖文件

运行

11> example1:start().
2true
32> example1:su,(45, 32).
477

在 Erlang 里调用 shell 脚本

假设想要在Erlang里调用一个shell脚本。要做到这一点,可以使用库函数os:cmd(Str)。它会运行字符串Str里的命令并捕捉结果:

11> os:cmd("ifconfig").
2"lo0: flags..."

高级接口技术

  • 内链驱动

    这些程序和之前讨论的端口驱动遵循相同的协议,唯一的区别是它们的驱动代码被链接到Erlang内核中,因此会在Erlang的操作系统主进程内运行。要构建一个内链驱动,就必须添加少量代码来初始化它,驱动本身必须被编译和链接到Erlang虚拟机上。

  • NIF

    NIF是指原生实现函数(Natively Implemented Function)。这些函数是用C(或其他能编译成本地代码的语言)编写的,并且被链接到Erlang虚拟机中。NIF直接将参数传递到Erlang进程的栈上,还能直接访问所有的Erlang内部数据结构。

  • C-node

    C-node是用C实现的节点,它们遵循Erlang分布式协议。一个“真正的”分布式Erlang节点不仅能够与C-node通信,还会把它当作一个Erlang节点(前提是它不在C-node上做一些花哨的事情,比如发送Erlang代码让它执行)。

练习

(1) 下载之前给出的端口驱动代码,然后在你的系统上测试它。

(2) 打开git://github.com/erlang/linked_in_drivers.git,下载内链驱动的代码并在你的系统上测试。这里的难点是找到编译和链接代码的正确命令。如果不能完成这个练习,可以去Erlang邮件列表寻求帮助。

(3) 看看能否找到一个操作系统命令,用它查看你的计算机使用的是哪种CPU。如果能找到这样的命令,请编写一个函数来返回你的CPU类型,做法是用os:cmd函数调用这个操作系统命令。

第16章 文件编程

操作文件的模块

  • file

    它包含打开、关闭、读取和写入文件的方法,还有列出目录,等等。

  • filename

    这个模块里的方法能够以跨平台的方式操作文件名,这样就能在许多不同的操作系统上运行相同的代码了。

  • filelib

    这个模块是file的扩展。它包含的许多工具函数能够列出文件、检查文件类型,等等。其中大多数都是使用file里的函数编写的。

  • io

    这个模块有一些操作已打开文件的方法。它包含的方法能够解析文件里的数据,或者把格式化数据写入文件。

文件操作摘要(file模块):

函数 描述
change_group 修改某个文件所属的组
change_owner 修改某个文件的所有者
change_time 修改某个文件的最后修改或访问时间
close 关闭某个文件
consult 从某个文件里读取Erlang数据类型
copy 复制文件内容
del_dir 删除某个目录
delete 删除某个文件
eval 执行某个文件里的Erlang表达式
format_error 返回一个描述错误原因的字符串
get_cwd 获取当前工作目录
list_dir 列出某个目录里的文件
make_dir 创建一个目录
make_link 创建指向某个文件的硬链接(hard link)
make_symlink 创建指向某个文件或目录的符号链接(symbolic link)
open 打开某个文件
position 设立在文件里的位置
pread 对文件里的某个位置进行读取
pwrite 对文件里的某个位置进行写入
read 对某个文件进行读取
read_file 读取整个文件
read_file_info 获取某个文件的信息
read_link 获得某个链接指向的位置
read_link_info 获取某个链接或文件的信息
rename 重命名某个文件
script 执行并返回某个文件里Erlang表达式的值
set_cwd 设置当前工作目录
sync 同步某个文件在内存和物理介质中的状态
truncate 截断某个文件
write 对某个文件进行写入
write_file 写入整个文件
write_file_info 修改某个文件的信息

读取文件

文件data1.dat:

1{person, "joe", "armstrong",
2		[{occupation, programmer},
3		 {favoriteLanguage, erlang}]}.
4		 
5{cat, {name, "zorro"},
6	  {owner, "joe"}}.

读取所有数据类型

可调用file:consult来读取所有的数据类型。

file:consult(File)假定File包含一个由Erlang数据类型组成的序列。如果它能读取文件里的所有数据类型,就会返回{ok, [Term]},否则会返回{error, Reason}。

11> file:consult("data1.dat").
2{ok,[{person,"joe","armstrong",
3             [{occupation,programmer},{favoriteLanguage,erlang}]},
4     {cat,{name,"zorro"},{owner,"joe"}}]}

分次读取数据类型

如果想从文件里一次读取一个数据类型,就要首先用file:open打开文件,然后用io:read逐个读取数据类型,直到文件末尾,最后再用file:close关闭文件。

 13> {ok, S} = file:open("data1.dat", read).
 2{ok,<0.86.0>}
 34> io:read(S, '').
 4{ok,{person,"joe","armstrong",
 5            [{occupation,programmer},{favoriteLanguage,erlang}]}}
 65> io:read(S,'').
 7{ok,{cat,{name,"zorro"},{owner,"joe"}}}
 86> io:read(S, '').
 9eof
107> file:close(S).
11ok

涉及函数:

  • -spec file:oepn(File, read) -> {ok, IoDevice} | {error, Why}

    尝试打开File进行读取。如果它能打开文件就会返回{ok, IoDevice},否则返回{error,Reason}。IoDevice是一个用来访问文件的I/O对象。

  • -spec io:read(IoDevice, Prompt) -> {ok, Term} | {error, Why} | eof

    从 IoDevice 读取一个Erlang数据类型 Term 。如果 IoDevice 代表一个被打开的文件,Prompt就会被忽略。只有用io:read读取标准输入时,才会用Prompt提供一个提示符。

  • -spec file:close(IoDevice) -> ok | {error, Why}

    关闭IoDevice。

用以上方法实现file:consult:

 1consult(File) ->
 2    case file:open(File, read) of
 3        {ok, S} ->
 4            Val = consult1(S),
 5            file:close(S),
 6            {ok, Val};
 7        {error, Why} ->
 8            {error, Why}
 9     end.
10
11consult1(S) ->
12    case io:read(S, '') of
13        {ok, Term} -> [Term|consult1(S)];
14        eof -> [];
15        Error -> Error
16    end.

找到file.erl的源代码:

可使用code:which函数来找到它,该函数能定位所有已载入模块的目标代码。

11> code:which(file).
2"/usr/lib/erlang/lib/kernel-8.5.4.2/ebin/file.beam"

在标准分发套装里,每个库都有两个子目录。一个名为src,包含源代码;另一个名为ebin,包含编译后的Erlang代码。因此,file.erl的源代码应该是在下面这个目录里:

1/usr/lib/erlang/lib/kernel-8.5.4.2/src/file.erl

分次读取文件里的行

如果把io:read改成io:get_line,就可以分次读取文件里的行。io:get_line会一直读取字符,直到遇上换行符或者文件尾。

 11> {ok, S} = file:open("data1.dat", read).
 2{ok,<0.83.0>}
 32> io:get_line(S, ''). 
 4"{person, \"joe\", \"armstrong\",\n"
 53> io:get_line(S, '').
 6"\t\t[{occupation, programmer},\n"
 74> io:get_line(S, '').
 8"\t\t {favoriteLanguage, erlang}]}.\n"
 95> io:get_line(S, '').
10"\t\t \n"
116> io:get_line(S, '').
12"{cat, {name, \"zorro\"},\n"
137> io:get_line(S, '').
14"\t  {owner, \"joe\"}}.\n"
158> io:get_line(S, '').
16eof
179> file:close(S).
18ok

读取整个文件到二进制型中

可以用file:read_file(File)把整个文件读入一个二进制型,这是一次原子操作。

如果成功,file:read_file(File)就会返回{ok, Bin},否则返回{error, Why}。这是到目前为止最高效的文件读取方式。在大多数操作里,我会把整个文件一次性读入内存,然后操作内容并一次性保存文件(用file:write_file)。

110> file:read_file("data1.dat").
2{ok,<<"{person, \"joe\", \"armstrong\",\n\t\t[{occupation, programmer},\n\t\t {favoriteLanguage, erlang}]}.\n\t\t \n{cat, {name, "...>>}

按字节访问读取文件

如果想要读取的文件非常大,或者它包含某种外部定义格式的二进制数据,就可以用raw模式打开这个文件,然后用file:pread读取它的任意部分。

file:pread(IoDevice, Start, Len)会从IoDevice读取Len个字节的数据,读取起点是字节Start处(文件里的字节会被编号,所以文件里第一个字节的位置是0)。它会返回{ok, Bin}或者{error, Why}。

 11> {ok, S} = file:open("data1.dat", [read,binary,raw]).
 2{ok,{file_descriptor,prim_file,
 3                     #{handle => #Ref<0.3212107313.3749838861.229403>,
 4                       owner => <0.81.0>,r_ahead_size => 0,
 5                       r_buffer => #Ref<0.3212107313.3749838853.229337>}}}
 62> file:pread(S, 22, 26).
 7{ok,<<"rong\",\n\t\t[{occupation, pro">>}
 83> file:pread(S, 1, 10).
 9{ok,<<"person, \"j">>}
104> file:pread(S, 2, 10).
11{ok,<<"erson, \"jo">>}
125> file:close(S).
13ok

读取MP3元数据案例

MP3是一种二进制格式,用来保存压缩过的音频数据。MP3文件本身并不包含有关文件内容的信息。比如说,在一个包含音乐数据的MP3文件里,音频数据并不包含录制音乐的艺术家姓名。这类数据(曲目名和艺术家姓名等)以一种被称为ID3的标签块格式保存在MP3文件中。ID3标签是由一位名叫Eric Kemp的程序员发明的,用来保存描述音频文件内容的元数据。ID3格式实际上有很多种,但基于我们的目的,这里只会编写代码来访问ID3标签的两种最简单的形式,即ID3v1和ID3v1.1标签。

ID3v1标签的结构很简单:文件最后的128个字节包含了一个固定长度的标签。前三个字节包含ASCII字符TAG,接下来是一些固定长度的字段。整个128字节数据是按照以下方式打包的:

长度 内容
3 包含TAG字符的标签头
30 标题
30 艺术家
30 专辑
4 年份
30 备注
1 流派

ID3v1的标签里没有地方可以添加曲目编号。Michael Mutschler在ID3v1.1格式里建议了一种 做法,把30个字节的备注字段改成下面这样:

长度 内容
28 评论
1 0(一个零)
1 曲目编号

读取MP3文件里的ID3v1标签的程序:

lib_find:files/3lib_misc:dump/2函数在后面。

 1-module(id3_v1).
 2-import(lists, [filter/2, map/2, reverse/1]).
 3-export([test/0, dir/1, read_d3_tag]).
 4test() -> dir("/home/joe/music_keep").
 5
 6dir(Dir) ->
 7    %% 读取文件夹中的所有mp3文件名为一个列表
 8    Files = lib_find:files(Dir, "*.mp3", true),
 9    L1 = map(fun(I) ->
10            {I, (catch read_id3_tag(I))}
11            end, Files),
12    %% L1 = [{File, Parse}], 其中Parse = error | [{Tag, Val}].
13    %% Tag是ID3v1或ID3v1.1
14    %% 现在将所有Parse = error的条目从L中移出
15    %% 可以用一次filter操作实现
16    L2 = filter(fun({_,error}) ->false;
17               (_) -> true
18               end, L1),
19    lib_misc:dump("mp3data", L2).
20
21%% 对每个文件,取出ID3v1标签,交由parse_v1_tag处理
22%% 不能打开文件则为error
23read_id3_tag(File) ->
24    case file:open(File, [read,binary,raw]) of
25        {ok, S} ->
26                  Size = filelib:file_size(File),
27            	  %% 读取的是最后128位
28                  {ok, B2} = file:pread(S, Size-128, 128),
29                  Result = parse_v1_tag(B2),
30                  file:close(S),
31                  Result;
32		_Error ->
33            error
34     end.
35
36%% 处理128位的ID3v1.1标签
37parse_v1_tag(<<$T,$A,$G,
38               Title:30/binary, 
39               Artist:30/binary, 
40               Album:30/binary, 
41               _Year:4/binary, 
42               _Comment:28/binary, 0:8, Track:8, 
43               _Genre:8>>) ->
44    {"ID3v1.1",
45     [{track, Track}, 
46      {title, trim(Title)}, 
47      {artist, trim(Artist)}, 
48      {album, trim(Album)}]};
49%% 处理128位的ID3v1标签
50parse_v1_tag(<<$T,$A,$G,
51               Title:30/binary, 
52               Artist:30/binary, 
53               Album:30/binary, 
54               _Year:4/binary, 
55               _Comment:30/binary,
56               _Genre:8>>) ->
57    {"ID3v1",
58     [{title, trim(Title)}, 
59      {artist, trim(Artist)}, 
60      {album, trim(Album)}]};
61%% 错误格式
62parse_v1_tag(_) ->
63    error.
64
65%% 处理字符串,去掉前面多余字符
66trim(Bin) ->
67    list_to_binary(trim_blanks(binary_to_list(Bin))).
68trim_blanks(X) -> reverse(skip_blanks_and_zero(reverse(X))).
69
70skip_blanks_and_zero([$\s|T]) -> skip_blanks_and_zero(T);
71skip_blanks_and_zero([o|T]) -> skip_blanks_and_zero(T);
72skip_blanks_and_zero(X) -> X.

写入文件

把数据列表写入文件

假设想要创建一个能用file:consult读取的文件。标准库里实际上并没有这样的函数,所以我们将自己编写它。不妨把这个函数称为unconsult。

lib_misc.erl:

1unconsult(File, L) ->
2    {ok, S} = file:oepn(File, write),
3    lists:foreach(fun(X_ -> io:format(S, "~p,~n", [X]) end, L))

-spec io:format(IoDevice, Format, Args) -> ok

其中ioDevice是一个I/O对象(必须以write模式打开),Format是一个包含格式代码的字符串,Args是待输出的项目列表。Args里的每一项都必须对应格式字符串里的某个格式命令。格式命令以一个波浪字符(~)开头。这里有一些最常用的格式命令:

  • ~n 输出一个换行符。~n很智能,会输出一个符合平台标准的换行符。比如说,~n在Unix机器上会把ASCII(10)写入输出流,在Windows机器上则会把回车换行ASCII(13, 10)写入输出流。
  • ~p把参数打印为美观的形式。
  • ~s参数是一个字符串、I/O列表或原子,打印时不带引号。
  • ~w用标准语法输出数据。它被用于输出各种Erlang数据类型。

格式字符串大概有几亿个参数,一个正常思维的人是记不住的。可以在io模块的手册页里找到完整的参数清单。

Format Result
`io:format(" ~10s
`io:format(" ~-10s
`io:format(" ~10.3.+s
`io:format(" ~10.10.+s
`io:format(" ~10.7.+s

把各行写入文件

还是使用io:format,每一个最后加一个~n即可。

 11> {ok, S} = file:open("test2.dat", write).
 2{ok,<0.83.0>}
 32> io:format(S, "~s~n", ["Hello readers"]).
 4ok
 53> io:format(S, "~w~n", [123]).            
 6ok
 74> io:format(S, "~s~n", ["that's it"]).
 8ok
 95> file:close(S).
10ok
116> q().
12ok
137> z@Ubuntu:~/code/erlang/programming-erlang/chapter16
14$cat test2.dat 
15Hello readers
16123
17that's it

一次性写入整个文件

这是最高效的写入文件方式。file:write_file(File, IO)会把IO里的数据(一个I/O列表)写入File。(I/O列表是一个元素为I/O列表、二进制型或0到255整数的列表。I/O列表在输出时会被自动“扁平化”,意思是所有的列表括号都会被移除。)这种方式极其高效,也是我经常用的。

扫描html中的链接案例

scavenge_urls.erl:

 1-module(scavenge_urls).
 2-export([urls2htmlFile/2, bin2urls/1]).
 3-import(lists, [reverse/1, reverse/2, map/2]).
 4
 5%% urls2htmlFile(Urls, File)接受一个URL列表并创建HTML文件,在文件里为每个URL各创建一个可点击的链接
 6url2htmlFIle(Urls, File) ->
 7    file:write_file(File, urls2html(Urls)).
 8
 9%% bin2urls(Bin)遍历一个二进制型,然后返回一个包含该二进制型内所有URL的列表
10bin2urls(Bin) ->gather_urls(binary_to_list(Bin), []).
11
12%% URL列表制作封装
13urls2html(Urls) -> [h1("Urls"), make_list(Urls)].
14
15%% 制作标题
16h1(Title) -> ["<h1>", Title, "</h1>\n"].
17
18%% 制作列表
19make_list(L) ->
20    ["<ul>\n",
21     map(fun(I) -> ["<li>", I, "</li>\n"] end, L),
22     "</ul>\n"].
23
24%% 从html源文件中提取url
25gather_urls("<a href" ++ T, L) ->
26    %% L是装Url的列表,Url是单次提取的Url,T1是剩余的网页内容
27    {Url, T1} = collect_url_body(T, reverse("<a href")),
28    gather_urls(T1, [Url|L]);
29gather_urls([_|T], L) ->
30    gather_urls(T, L);
31gather_urls([], L) ->
32    L.
33
34%% 提取出Url以及后面剩余的html内容。
35collect_url_body("</a>" ++ T, L) -> {reverse(L, "</a>"), T};
36collect_url_body([H|T] , L) -> collect_url_boy(T, [H|L]);
37collect_url_body(p[, _) -> {[], []}.

TODO collect_url_body没看明白,特别是为什么要reverse,难道是我++的意思理解错了?

要运行它,需要有一些数据来解析。输入数据(一个二进制型)是HTML网页的内容,所以需要找一张HTML网页来操作。我们将通过socket_examples:nano_get_url(参见17.1.1节)来实现。

11> B = socket_examples:nano_get_url("www.erlang.rog"),
2   L = scavenge_urls:bin2urls(B),
3   scavenge_urls:urls2htmlFile(L, "gathered.html").
4ok

目录和文件操作

操作目录

file里有三个操作目录的函数:

  • list_dir(Dir) 生成一个Dir里的文件列表 ,列出的文件没有特定的顺序,不会告诉你它们是文件还是目录,也没有文件大小等信息。
  • make_dir(Dir) 创建一个新目录
  • del_dir(Dir) 删除一个目录

查找文件信息

要查找文件F的信息,我们会调用file:read_file_info(F)。如果F是一个合法的文件或目录名,它就会返回{ok, Info}。Info是一个#file_info类型的记录,此类型的定义如下:

 1-record(file_info,
 2       {size, % 文件的字节大小
 3        type, % 原子,是device, directory, regular, other其中一个
 4        access, % 原子,是read, write, read_write, none
 5        atime, % 最后一次文件被读的本地时间,{{Year, Mon, Day}, {Hour, Min, Sec}}
 6        mtime, % 最后一次文件被修改的本地时间
 7        ctime, % 取决于操作系统,Unix是文件/inode最后一次被修改,windows是创建时间
 8        mode, % 整数,文件权限
 9        links, % 文件的链接数
10        ...
11  }).

为了方便起见,filelib模块导出了一些小方法,比如file_size(File)is_dir(X)。它们只不过是 file:read_file_info 的接口。如果只想获得文件大小,更方便的做法是调用filelib:file_size,而不是调用file:read_file_info然后解包#file_info记录里的元素。

要查找某个文件的大小和类型(我们必须包含file.hrl,因为它内含#file_info记录的定义):

1-include_lib("kernel/include/file.hrl").
2file_size_and_type(File) ->
3    case file:read_file_info(File) of
4        {ok, Facts} ->
5            {Facts#file_info.type, Facts#file_info.size};
6        _ ->
7            error
8    end.

通过调用map和上面的函数,增强list_file返回的目录清单:

1ls(Dir) ->
2    {ok, L} = file:list_dir(Dir),
3    lists:map(fun(I) -> {I, file_size_and_type(I)} end, lists:sort(L)).
11> lib_misc:ls(".").

复制和删除文件

  • file:copy(Source, Destination) 把文件Source复制到Destination里。
  • file:delete(File) 删除File。

一个查找工具函数

file:list_dirfile:read_file_info来编写一个通用型“查找”工具。下面这段代码理解起来还是不难的,编程起来难。

lib_find.erl:

 1-module(lib_find).
 2-export([files/3, files/5]).
 3-import(lists, [reverse/1]).
 4
 5-include_lib("kernel/include/file.hrl").
 6
 7%% 这个函数是一个封装,为可以简单地使用这个基本功能
 8%% ShellRegExp是一个shell风格的通配符模式,比完整形式的正则表达式更容易编写。
 9%% Flag是是否需要递归查找 true/false
10files(Dir, Re, Flag) ->
11    Re1 = xmerl_regexp:sh_to_awk(Re),
12    reverse(files(Dir, Re1, Flag, fun(File, Acc) -> [File|Acc] end, [])).
13
14%% Dir 这个目录名是文件搜索的起点
15%% RegExp 这是一个shell风格的正则表达式,用于测试我们找到的文件。
16%% 如果遇到的文件匹配这个正则表达式,就会调用Fun(File, Acc),其中File是匹配正则表达式的文件名。
17%% Recursive = true | false 这个标记决定了搜索是否应该层层深入当前搜索目录的子目录。
18%% Fun(File, AccIn) -> AccOut 如果regExp匹配File,这个函数就会被应用到File上。
19%% Acc是一个初始值为Acc0的归集器。Fun在每次调用后必须返回一个新的归集值,这个值会在下次调用Fun时传递给它。
20%% 归集器的最终值就是lib_find:files/5的返回值。
21files(Dir, Reg, Recursive, Fun, Acc) ->
22    case file:list_dir(Dir) of
23        {ok, Files} -> find_files(Files, Dir, Reg, Recursive, Fun, Acc);
24        {error, _} ->Acc
25    end.
26
27find_files([File|T], Dir, Reg, Recursive, Fun, Acc0) ->
28    FullName = filename:join([Dir,File]),
29    case file_type(FullName) of
30        regular ->
31            case re:run(FullName, Reg, [{capture, none}]) of
32                match ->
33                    Acc = Fun(FullName, Acc0),
34                    find_files(T, Dir, Reg, Recursive, Fun, Acc);
35                nomatch ->
36                    find_files(T, DIr, Reg, Recursive, Fun, Acc0)
37            end;
38        directory ->
39            case Recursive of
40                true ->
41                    Acc1 = files(FullName, Reg, Recursive, Fun, Acc0),
42                    find_files(T, Dir, Reg, Recursive, Fun, Acc1);
43                false ->
44                    find_files(T, Dir, Reg, Recursive, Fun, Acc0)
45            end;
46        error ->
47            find_files(T, Dir, Reg, Recursive, Fun, Acc0)
48    end;
49find_files([], _, _, _, _, A) ->
50    A.
51
52%% 判别文件类型,从file_info记录解析
53%% 只接受regular和directory这两种类型
54file_type(File) ->
55    case file:read_file_info(File) of
56        {ok, Facts} ->
57            case Facts#file_info.type of
58                regular -> regular;
59                directory -> directory;
60                _ -> error
61                end;
62        _ -> 
63            error
64    end.

函数:

  • xmerl_regexp:sh_to_awk 估计已经过期了

  • filename:join/2 Joins two filename components with directory separators.

  • re:run(Subject, RE, Options) Executes a regular expression matching, and returns match/{match, Captured} or nomatch.

    这里的Options用的是{capture, none},If the capture options describe that no substring capturing is to be done ({capture, none}), the function returns the single atom match upon successful matching, otherwise the tuple {match, ValueList}

其他信息

请查阅手册

  • 文件模式 用file:open打开文件时,我们会以某个或某一组模式来打开它。事实上,模式的数量比我们想象的要多得多。举个例子,读取和写入gzip压缩文件时可以使用compressed这个模式标记。手册页里有完整的清单。
  • 修改时间、用户组、符号链接 可以用file里的一些方法来设置它们。
  • 错误代码 我曾经泛泛地说过所有错误都是{error, Why}这种形式。事实上,Why是一个原子(比如用enoent表示文件不存在,等等)。错误代码的数量其实有很多,手册页里对它们都进行了描述。
  • filename filename模块里有一些很有用的方法,比如拆分目录里的完整文件名来获得文件扩展名,以及用各个组成部分重建文件名,等等。所有这些都是以跨平台的方式实现的。
  • filelib filelib模块有一些小方法能给我们减少一点工作量。举个例子,filelib:ensure_dir(Name)会确保给定文件或目录名Name的所有上级目录都存在,必要的话会尝试创建它们。

练习

(1) 编译Erlang文件X.erl后会生成一个X.beam文件(如果编译成功的话)。编写一个程序来检查某个Erlang模块是否需要重新编译。做法是比较相关Erlang文件和beam文件的最后修改时间戳。

error的情况没处理。

 1%%%-------------------------------------------------------------------
 2%%% @author bnaod1
 3%%% @copyright (C) 2024, 四川农业大学
 4%%% @doc
 5%%%
 6%%% @end
 7%%% Created : 2024 12月 18. 16:40
 8%%%-------------------------------------------------------------------
 9-module(question1).
10-author("bnaod1").
11-include_lib("kernel/include/file.hrl").
12%% API
13-export([]).
14
15
16%% 编写一个程序来检查某个Erlang模块是否需要重新编译
17%% 扫描erl与对应beam文件,比较时间戳时候完全相等
18
19need_compile(Module) ->
20  Erl = Module ++ ".erl",
21  Beam = Module ++ ".beam",
22  ET = file_mtime(Erl),
23  BT = file_mtime(Beam),
24  case ET =:= BT of
25    true ->
26      io:format("beam is new, no need to update");
27    false ->
28      io:format("beam is old, need update")
29  end.
30  
31
32%% {{Year, Mon, Day}, {Hour, Min, Sec}}
33file_mtime(File) ->
34  case file:read_file_info(File) of
35    {ok, Facts} ->
36      Facts#file_info.mtime;
37    _ ->
38      error
39  end.
40

(2) 编写一个程序来计算某个小文件的MD5校验和,做法是用内置函数erlang:md5/1来计算文件数据的MD5校验和(有关这个内置函数的详情请参阅Erlang手册页)。

  • -spec md5(Data) -> Digest when Data :: iodata(), Digest :: binary().

    Computes an MD5 message digest from Data, where the length of the digest is 128 bits (16 bytes). Data is a binary or a list of small integers and binaries.

 1%%%-------------------------------------------------------------------
 2%%% @author bnaod1
 3%%% @copyright (C) 2024, 四川农业大学
 4%%% @doc
 5%%%
 6%%% @end
 7%%% Created : 2024 12月 19. 09:12
 8%%%-------------------------------------------------------------------
 9-module(question2).
10-author("bnaod1").
11
12%% API
13-export([md5check/1]).
14
15md5check(File) ->
16  {ok, B} = file:read_file(File),
17  erlang:md5(B).
18
14> question2:md5check("question2.erl").
2<<82,4,10,130,183,212,219,207,106,161,64,136,77,80,150,81>>

(3) 对一个大文件(比如几百MB)重复前面的练习。这次分小块读取该文件,并用erlang:md5_init、erlang:md5_update和erlang:md5_final计算该文件的MD5校验和。

(4) 用lib_find模块查找计算机里的所有.jpg文件。计算每一个文件的MD5校验和,然后比较校验和来看看是否存在两张相同的图片。

(5) 编写一种缓存机制,让它计算文件的MD5校验和,然后把结果和文件的最后修改时间一起保存在缓存里。当想要某个文件的MD5值时就检查缓存,看看是否已经计算过,如果文件的最后修改时间有变化就重新计算它。

以后做,现在赶时间。

(6) 一条推特刚好是140字节长。编写一个名为twit_store.erl的随机访问式推特存储模块,并导出下列函数:init(K)分配K条推特的空间。store(N, Buf)在存储区里保存第N(范围是1至K)条推特的数据Buf(一个140字节的二进制型)。fetch(N)取出第N条推特的数据。

这个练习听着很有趣,但是我不知道怎么分配空间。

第17章 套接字编程

UDP能让应用程序相互发送简短的消息(称为数据报),但是并不保证这些消息能成功到达。它们也可能会不按照发送顺序到达。而TCP能提供可靠的字节流,只要连接存在就会按顺序到达。用TCP发送数据的额外开销比用UDP发送数据更大。

套接字编程有两个主要的库:gen_tcp用于编写TCP应用程序,gen_udp用于编写UDP应用程序。

TCP的使用

TCP客户端

socket_examples.erl:

 1nano_get_url() ->
 2    nano_get_url("www.google.com").
 3
 4nano_get_url(Host) ->
 5    {ok, Socket} = gen_tcp:connect(Host, 80, [binary, {packet, 0}]),
 6    ok = gen_tcp:send(Socket, "GET / HTTP/1.0\r\n\r\n"),
 7    receive_data(Socket, []).
 8
 9receive_Data(Socket, SoFar) ->
10    receive
11        {tcp, Socket, Bin} ->
12            receive_data(Socket, [Bin|SoFar]);
13        {tcp_closed,Socket} ->
14            list_to_binary(reverse(SoFar))
15     end.
  • 调用gen_tcp:connect来打开一个到http://www.google.com 80端口的TCP套接字。

    连接调用里的binary参数告诉系统要以“二进制”模式打开套接字,并把所有数据用二进制型传给应用程序。

    {packet,0}的意思是把未经修改的TCP数据直接传给应用程序。

  • 调用gen_tcp:send,把消息GET / HTTP/1.0\r\n\r\n发送给套接字,然后等待回复。这个回复并不是放在一个数据包里,而是分成多个片段,一次发送一点。这些片段会被接收成为消息序列,发送给打开(或控制)套接字的进程。

  • 收到一个{tcp,Socket,Bin}消息。这个元组的

  • 第三个参数是一个二进制型,原因是打开套接字时使用了二进制模式。这个消息是Web服务器发送给我们的数据片段之一。把它添加到目前已收到的片段列表中,然后等待下一个片段。

  • 收到一个{tcp_closed, Socket}消息。这会在服务器完成数据发送时发生。

  • 当所有片段都到达后,因为它们的保存顺序是错误的,所以反转它们并连接所有片段。

测试:

1B = socket_examples:nano_get_url().
2io:format("~p~n", [B]).
3string:tokens(binary_to_list(B), "\r\n").
  • 这个二进制型是被截断的.如果想查看整个二进制型,可以用io:format打印它,或者用string:tokens把它分成几部分。

TCP服务器

  1. 数据传输:

    TCP套接字数据只不过是一个无差别的字节流。这些数据在传输过程中可以被打散成任意大小的片段,所以需要事先约定,这样才能知道多少数据代表一个请求或响应。

    我们在Erlang里使用了一种简单的约定,即每个逻辑请求或响应前面都会有一个N(1、2或4)字节的长度计数。这就是gen_tcp:connectgen_tcp:listen函数里参数{packet, N}的意思。packet这个词在这里指的是应用程序请求或响应消息的长度,而不是网络上的实际数据包。需要注意的是,客户端和服务器使用的packet参数必须一致。如果启动服务器时用了{packet,2},客户端用了{packet,4},程序就会失败。

    用{packet,N}选项打开一个套接字后,无需担心数据碎片的问题。Erlang驱动会确保所有碎片化的数据消息首先被重组成正确的长度,然后才会传给应用程序。

  2. 编码和解码:

    用term_to_binary编码Erlang数据类型,然后用它的逆函数binary_to_term解码数据。这个约定是这个程序的,而不是erlang规范。

socket_examples.erl:

 1start_nano_server() ->
 2    {ok, Listen} = gen_tcp:listen(2345, [binary, {packet, 4},
 3                                         {reuseaddr, true},
 4                                        {active, true}]),
 5    {ok, Socket} = gen_tcp:accept(Listen),
 6    gen_tcp:close(Listen),
 7    loop(Socket).
 8
 9loop(Socket) ->
10    receive
11        {tcp, Socket, Bin} ->
12            io:format("Server received binary = ~p~n", [Bin]),
13            Str = binary_to_term(Bin),
14            io:format("Server (unpacked) ~p~n", [Str]),
15            Reply = lib_misc:string2value(Str),
16            io:format("Server replying = ~p~n", [Reply]),
17            gen_tcp:send(Socket, term_to_binary(Replay)),
18            loop(Socket);
19        {tcp_closed, Socket} ->
20            io:format("Server socket closed~n")
21    end.
22
23
24%% 测试客户端程序
25nano_client_eval(Str) ->
26    {ok, Socket} = gen_tcp:connect("localhost", 2345, [binary, {packet, 4}]),
27    ok = gen_tcp:send(Socket, term_to_binary(Str)),
28    receive
29        {tcp, Socket, Bin} ->
30            io:format("Client received binary = ~p~n", [Bin]),
31            Val = binary_to_term(Bin),
32            io:format("Client result = ~p~n", [Val]),
33            gen_tcp:close(Socket)
34    end.
  • 调用gen_tcp:listen来监听2345端口的连接,并设置消息的打包约定。{packet,4}的意思是每个应用程序消息前部都有一个4字节的长度包头。然后gen_tcp:listen(..)会返回{ok, Listen}或{error, Why}

    程序在gen_tcp:listen返回{error, …}时抛出一个模式匹配异常错误。

    在成功的情况下,这个语句会绑定Listen到刚监听的套接字上。

    在我的理解中,listen函数更像是一种监听和通话配置。listen是监听套接字。

  • 调用gen_tcp:accept(Listen)。在这个阶段,程序会挂起并等待一个连接。当我们收到连接时,这个函数就会返回变量Socket,它绑定了可以与连接客户端通信的套接字。

    可以说accept就像门卫,有客人来了就请他到一个房间Socket去。

    accept是和connect配合的,一个听一个去连接,他们返回的socket就是他们沟通的桥梁。

  • 在accept返回后立即调用gen_tcp:close(Listen)。这样就关闭了监听套接字,使服务器不再接收任何新连接。这么做不会影响现有连接,只会阻止新连接

    close负责关闭accpet的监听。

  • close还可以负责切断两者通信的socket,它是通过向服务器发送{tcp_closed, Socket}做到的。

  • TODO 我是有疑问的,如果listen和connect中的opt有一个缺省了该怎么办,客户端可以肆意妄为吗?

测试:

两个窗口

1socket_examples:start_nano_server().

执行完这个后处在accpet的位置,等待链接。

1socket_examples:nano_client_eval("list_to_tuple([2+3*$, 10+20])").

执行完这句之后服务器窗口:

1Server received binary = <<131,107....
2Server (unpacked) "list_to_tuple([2+3*4, 10+20])"
3Server replying = {14, 30}

客户端窗口

1Client received binary = << 131...
2Client result = {14, 30}

最后服务器窗口

1Server socket closed

改进为顺序和并行服务器

顺序服务器:一次接收一个连接 并行服务器:同时接收多个并行连接

顺序服务器:

 1start_seq_server() ->
 2	{ok, Listen} = gen_tcp:listen(...),
 3	seq_loop(Listen).
 4
 5seq_loop(Listen) ->
 6    {ok, Socket} = gen_tcp:accept(Listen),
 7    loop(Socket),
 8    seq_loop(Listen).
 9
10loop(..) -> ...

在loop(Socket)完成后再次调用seq_loop(Listen),让它等待下一个连接。如果一个客户端尝试连接时服务器正忙于处理现有连接,该连接就会加入队列,直至服务器完成现有连接。如果排队的连接数量超过了监听缓冲区限制,该连接就会被拒绝。

停止服务器很简单(停止并行服务器也一样),只需终止启动单个或多个服务器的进程即可。gen_tcp自身会连接到控制进程上,如果控制进程终止,它就会关闭套接字。

并行服务器:

 1start-parallel_server() ->
 2	{ok, Listen} = gen_tcp:listen(...),
 3    spawn(fun() -> par_connect(Listen) end).
 4
 5par_connect(Listen) ->
 6    {ok, Socket} = gen_tcp:accept(Listen),
 7    spawn(fun() -> par_connect(Listen) end),
 8    loop(Socket).
 9
10loop(..) -> ...

注意事项

  • 创建某个套接字(通过调用gen_tcp:accept或gen_tcp:connect)的进程被称为该套接字的控制进程。所有来自套接字的消息都会被发送到控制进程。如果控制进程挂了,套接字就会被关闭。某个套接字的控制进程可以通过调用gen_tcp:controlling_process(Socket, NewPid)修改成NewPid。

  • 我们的并行服务器可能会创建出几千个连接,所以可以限制最大同时连接数。实现的方法可以是维护一个计数器来统计任一时刻有多少活动连接。每当收到一个新连接时就让计数器加1,每当一个连接结束时就让它减1。可以用它来限制系统里的同时连接总数。

  • 接受一个连接后,显式设置必要的套接字选项是一种很好的做法,就像这样:

    1{ok, Socket} = gen_tcp:accept(Listen),
    2inet:setopts(Socket, [{packet,4},binary,{nodelay,true},{active,true}]),
    3loop(Socket)    
    
  • Erlang 的R11B-3版开始允许多个Erlang进程对同一个监听套接字调用gen_tcp:accept/1。这让编写并行服务器变得简单了,因为你可以生成一个预先分裂好的进程池,让它们都处在gen_tcp:accept/1的等待状态。

  • 假设编写了某种在线服务器,并且发现有人持续向网站发送垃圾信息。为了尽量防止这 种事发生,我们需要知道连接的来源。可以调用inet:peername(Socket)进行查看。

    @spec inet:peername(Socket) -> {ok, {IP_Address, Port}} | {error, Why}

主动和被动套接字

TODO 一个套接字指的是Listen还是Socket?难道一个Socket会有多个连接吗?

Erlang的套接字可以有三种打开模式:主动(active)、单次主动(active once)或被动(passive)。这是通过在gen_tcp:connect(Address, Port, Options)或gen_tcp:listen(Port, Options)的Options参数里加入{active, true | false | once}选项实现的。

如果指定{active, true}就会创建一个主动套接字,指定{active, false}则是被动套接字。{active, once}创建的套接字只会主动接收一个消息,接收完之后必须重新启用才能接收下一个消息。

  • 当一个主动套接字被创建后,它会在收到数据时向控制进程发送{tcp, Socket, Data}消息。控制进程无法控制这些消息流。恶意的客户端可以向系统发送成千上万的消息,而它们都会被发往控制进程。控制进程无法阻止这些消息流。
  • 如果一个套接字是用被动模式打开的,控制进程就必须调用gen_tcp:recv(Socket, N)来从这个套接字接收数据。然后它会尝试从套接字接收N个字节。如果N = 0,套接字就会返回所有可用的字节。在这个案例里,服务器可以通过选择何时调用gen_tcp:recv来控制客户端所发的消息流。被动套接字的作用是控制通往服务器的数据流。

1.主动消息接收(非阻塞式):

只有在确信服务器能跟上客户端的需求时才会编写非阻塞式服务器。

 1{ok, Listen} = gen_tcp:listen(Port, [.., {active, true}...]),
 2{ok, Socket} = gen_tcp:accept(Listen),
 3loop(Socket).
 4
 5loop(Socket) ->
 6    receive
 7        {tcp, Socket, Data} ->
 8            ... 对数据进行操作 ...
 9		{tcp_closed, Socket} ->
10    		...
11	end.

2.被动消息接收(阻塞式):

服务器循环里的代码会在每次想要接收数据时调用gen_tcp:recv。客户端会一直被阻塞,直到服务器调用recv为止。请注意,操作系统有自己的缓冲设置,即使没有调用recv,客户端也能在阻塞前发送少量数据。

当我们处于被动模式时,只能等待来自单个套接字的数据。这对于编写那些必须等待来自多个套接字数据的服务器来说毫无用处。

 1{ok, Listen} = gen_tcp:listen(Port, [..,{active, false}...]),
 2{ok, Socket} = gen_tcp:accept(Listen),
 3loop(Socket).
 4
 5loop(Socket) ->
 6    case gen_tcp:recv(Socket, N) of
 7        {ok, B} ->
 8            ....
 9			loop(Socket);
10		{error, closed}
11			...
12	end.

3.混合消息接收(部分阻塞式):

套接字在这个模式下虽然是主动的,但只针对一个消息。当控制进程收到一个消息后,必须显式调用inet:setopts才能重启下一个消息的接收,在此之前系统会处于阻塞状态。

通过使用{active, once}选项,用户可以实现高级形式的流量控制(有时被称为流量整形),从而防止服务器被过多消息淹没。

 1{ok, Listen} = gen_tcp:listen(Port, [..,{active, once}...]),
 2{ok, Socket} = gen_tcp:accept(Listen),
 3loop(Socket).
 4
 5loop(Socket) ->
 6    receive
 7        {tcp, Socket, Data} ->
 8            ....
 9			%% 准备好启动下一个消息接收时
10			inet:setpots(Socket, [{active, once}]),
11    		loop(Socket);
12		{tcp_closed, Socket} ->
13    		...
14	end.

套接字错误处理

每个套接字都有一个控制进程(也就是创建该套接字的进程)。如果控制进程挂了,套接字就会被自动关闭。如果我们有一个客户端和一个服务器,而服务器因为程序错误挂了,那么服务器支配的套接字就会被自动关闭,同时向客户端发送一个{tcp_closed, Socket}消息。

实验:

 1error_Test() ->
 2    spawn(fun() -> error_test_server() end),
 3    lib_misc:sleep(2000),
 4    {ok, Socket} = gen_tcp:connect("localhost", 4321, [binary, {packet,2}]),
 5    io:format("connected to :~p~n", [Socket]),
 6    gen_tcp:send(Socket, <<"123">>),
 7    receive
 8        Any ->
 9            io:format("Any=~p~n",[Any])
10    end.
11
12error_test_server() ->
13    {ok, Listen} = gen_tcp:listen(4321, [binary, {packet,2}]),
14    {ok, Socket} = gen_tcp:accept(Listen),
15    error_test_server_loop(Socket).
16
17error_test_server_loop(Socket) ->
18    receive
19        {tcp, Socket, Data} ->
20            io:format("received:~p~n", [Data]),
21            _ = atom_to_list(Data),
22            error_test_server_loop(Socket)
23    end.

我们分裂出一个服务器并睡眠两秒钟来让它完成启动,然后向它发送一个包含二进制型«“123”» 的消息。 当这个消息到达服务器后,服务器尝试对二进制型Data 计算atom_to_list(Data),于是立即崩溃了。系统监视器打印出了你在shell里所见到的诊断信息。因为服务器端套接字的控制进程已经崩溃,所以(服务器端的)套接字就被自动关闭了。系统随后向客户端发送一个{tcp_closed, Socket}消息。

UDP

互联网上的机器能够通过UDP相互发送被称为数据报(datagram)的短消息。UDP数据报是不可靠的,这就意味着如果客户端向服务器发送一串UDP数据报,它们可能会不按顺序到达,不能成功到达或者不止一次到达。但是每一个数据报只要到达,就会是完好无损的。

UDP是一种无连接协议,意思是客户端向服务器发送消息之前不必建立连接。这就意味着UDP非常适合那些大量客户端向服务器发送简短消息的应用程序。

最简单的UDP服务器与客户端

服务器:

 1server(Port) ->
 2    {ok, Socket} = gen_udp:open(POrt, [binary]),
 3    loop(Socket).
 4
 5loop(Socket) ->
 6    receive
 7        {udp, Socket, Host, Port, Bin} ->
 8            BinReply = ...,
 9			gen_udp:send(Socket, Host, Port, BinReply),
10			loop(Socket)
11     end.

客户端:

必须设置一个超时,因为UDP是不可靠的,我们可能会得不到回复。

 1client(Request) ->
 2    {ok, Socket} = gen_udp:open(0, [binary]),
 3    ok = gen_udp:send(Socket, "localhost", 4000, Request),
 4    Value = receive
 5                {udp, Socket, _, _, Bin} ->
 6                    {ok, Bin}
 7            after 2000 ->
 8                    error
 9            end,
10    gen_udp:close(Socket),
11    Value.

一个UDP阶乘服务器

 1-module(udp_test).
 2-export([start_server0, client/1]).
 3
 4start_server() ->
 5    spawn(fun() -> server(4000) end).
 6
 7%% server
 8server(Port) ->
 9    {ok, Socket} = gen_udp:open(Port, [binary]),
10    io:format("server opened socket:~p~n", [Socket]),
11    loop(Socket).
12
13loop(Socket) ->
14    receive
15        {udp, Socket, Host, Port, Bin} = Msg ->
16            io:format("server received:~p~n", [Msg]),
17            N = binary_to_term(Bin),
18            Fac = fac(N),
19            gen_udp_send(Socket, Host, Port, term_to_binary(Fac)),
20            loop(Socket)
21    end.
22
23fac(0) -> 1;
24fac(N) -> N * fac(N-1).
25
26%% client
27client(N) ->
28    {ok, Socket} = gen_udp:open(0, [binary]),
29    io:format("client opened socket=~p~n", [Socket]),
30    ok = gen_udp:send(Socket, "localhost", 4000, term_to_binary(N)),
31    Value = receive
32                {udp, Socket, _, _, Bin} = Msg ->
33                    io:format("client received:~p~n", [Msg]),
34                    binary_to_term(Bin)
35            after 2000 ->
36                    0
37            end,
38    gen_udp:close(Socket),
39    Value.

测试:

1udp_test:start_server().
2udp_test:client(40).

UDP数据包须知

  • UDP是一种无连接协议,所以服务器无法通过拒绝读取来自某个客户端的数据来阻挡它。服务器对谁是客户端一无所知。
  • 大型UDP数据包可能会分段通过网络。当UDP数据经过网络上的路由器时,如果数据大小超过了路由器允许的最大传输单元(Maximum Transfer Unit,简称MTU)大小,分段就会发生。通常的建议是在调整UDP网络时从一个较小的数据包大小开始(比如大约500字节),然后逐步增大并测量吞吐量。如果吞吐量在某个时刻骤减,你就知道数据包太大了。
  • UDP数据包可以传输两次(这出乎一些人的意料之外),所以在编写远程过程调用代码时一定要小心。第二次查询得到的回复可能只是第一次查询回复的复制。为防止这类问题,可以修改客户端代码来加入一个唯一的引用,然后检查服务器是否返回了这个引用。要生成一个唯一的引用,需要调用Erlang的内置函数make_ref,它能确保返回一个全局唯一的引用。

一个ref防止重发的示例:

 1client(Request) ->
 2    {ok, Socket} = gen_udp:open(0, [binary]),
 3    Ref = make_ref(), %% 生成唯一引用
 4    B1 = term_to_binary({Ref, Request}),
 5    ok = gen_udp:send(Socket, "localhost", 4000, B1),
 6    wait_for_ref(Socket, Ref).
 7
 8wait_for_ref(Socket, Ref) ->
 9    receive
10        {udp, Socket, _, _, Bin} ->
11            case binary_to_term(Bin) of
12                {Ref, Val} ->
13                    %% correct
14                    Val;
15                {_SomeOtherRef, _} ->
16                    %% incorrect, drop
17                    wait_for_ref(Socket,Ref)
18            end;
19    after 1000 ->
20            ....
21	end.

广播

在这里需要两个端口,一个发送广播(广播方),另一个监听回应(接听方和广播发向的端口)。我们选择了5010端口来发送广播请求,6000端口用来监听广播

只有发送广播的进程才会打开5010端口,而网络上的所有机器都会调用broadcast:listen()来打开6000端口并监听广播消息。broadcast:send(IoList)会对局域网里的所有机器广播IoList。

大概意思就是从5010给局域网每台机器的6000端口发消息,想要接收就听6000端口。

 1-module(broadcase).
 2-compile(export_all).
 3
 4send(IoList) ->
 5    case inet:ifget("eth0", [broadaddr]) of
 6        {ok, [{broadaddr, Ip}]} ->
 7            {ok, S} = gen_udp:open(5010, [{broadcast, true}]),
 8            gen_udp:send(S, Ip, 6000, IoList),
 9            gen_udp:close(S);
10       	_ ->
11            io:format("Bad interface name, or\n"
12                      "broadcasting not supported\n")
13    end.
14
15listen() ->
16    {ok, _} = gen_udp:open(6000),
17    loop().
18loop() ->
19    receive
20        Any ->
21            io:format("received:~p~n", [Any]),
22            loop()
23    end.

案例:一个 SHOUTcast 服务器

SHOUTcast是由Nullsoft公司开发的协议,它被用于传输音频数据流。SHOUTcast使用HTTP作为传输协议来发送MP3或AAC编码的音频数据。

练习

没时间了,以后做。

(1) 修改nano_get_url/0的代码(17.1.1节),并在必要时添加合适的HTTP头或执行重定向来获取任意网页。在多个网站上测试它。

(2) 输入17.1.2节里的代码,然后修改此代码来接收一个{Mod, Func, Args}元组(而不是字符串),最后计算Reply = apply(Mod, Func, Args)并把值发回套接字。编写一个nano_client_eval(Mod, Func, Args)函数(类似于本章前面所展示的版本),让它用修改版服务器代码能理解的形式编码Mod、Func和Arity。测试客户端和服务器代码能否正常工作,首先在同一台机器上,然后在同一局域网的两台机器上,最后在互联网上的两台机器上。

(3) 用UDP代替TCP重复上一个练习。

(4) 添加一个加密层,做法是先编码二进制型再发送给输出套接字,并在输入套接字接收之后立即解码。

(5) 制作一个简单的“类电子邮件”系统。把Erlang数据类型作为消息存储在${HOME}/mbox目录里。

第18章 用WebSocket和Erlang进行浏览

其实就是一个前后端的概念

TODO 这章都是示例程序,待补。

为了给Erlang运行时系统建立WebSocket接口,就会运行一个名为cowboy(牛仔)的简单Erlang服务器,让它管理套接字和WebSocket协议。第25章会详细介绍如何安装cowboy。为了简化讨论,我们假定Erlang和浏览器之间传递的所有消息都是JSON格式的。

这些消息在应用程序的Erlang端体现为Erlang映射组(参见5.3节),在浏览器里则体现为JavaScript对象。

一个数字时钟

 1<script ... src=:./jquery...</script>
 2<script ... src="websock.js" .. </script>
 3<body>
 4    ...
 5</body>
 6
 7
 8<script>
 9	$(document).ready(function(){
10        connect("localhost", 2233, "clock1");
11    })
12</script>

websock.js包含了所有必需的代码来打开WebSocket和连接浏览器DOM对象到Erlang。它会做下列事情。

(1) 给网页里所有属于live_button类的按钮添加点击处理函数。这些点击处理函数会在按钮被点击时向Erlang发送消息。

(2) 尝试启动一个到http://localhost:2233的WebSocket连接。在服务器端会有一个新分裂 出 的 进 程 调 用 clock1:start(Browser) 函 数 。 所 有 这 些 都 是 通 过 调 用 JavaScript 函 数connect(“localhost”, 2233, “clock1”)实现的。2233这个数字没有什么特别的含义,任何大于1023的未使用端口号都可以用。

现在是Erlang代码:

 1-module(clock1).
 2-export([start/1, current_time/0]).
 3
 4start(Browser) ->
 5    Browser ! #{ cmd => fill_div, id => clock, txt => current_time()},
 6    running(Browser).
 7
 8running(Browser) ->
 9    receive
10        {Browser, #{clicked => <<"stop">>}} ->
11            idle(Browser)
12    after 1000 ->
13            Browser ! #{cmd => fill_div, id=> clock, txt => current_time()},
14            running(Browser)
15    end.
16
17idle(Browser) ->
18    receive
19        {Browser, #{clicked => <<"start">>}} ->
20            running(Browser)
21    end.
22
23current_time() ->
24    {Hour, Min, Sec} = time(),
25    list_to_binary(io_lib:format("~2.2.ow:~2.2.ow:~2.2.ow",[Hour,Min,Sec])).

若希望让浏览器做点什么,就向它发送一个消息。就像在Erlang里一样。我们驯服了浏览器,它看起来就像是一个Erlang进程。

初始化之后,clock1会调用running/1。如果收到一个{clicked => «“stop”»}消息,就会调用idle(Browser)。否则,会在一秒钟的超时到期后向浏览器发送一个更新时钟的命令,然后调用自身。idle/1等待一个start消息,然后调用running/1。

单方聊天框

它的工作方式类似于时钟示例。每当用户在输入框里按下回车键时,输入框就会发送一个包含输入文本的消息给浏览器。管理窗口的Erlang进程接收这个消息,然后向浏览器发回一个更新显示内容的消息。

 1-module(iunteract1).
 2-export([start/1]).
 3
 4start(Browser) -> running(Browser).
 5
 6running(Bowser) ->
 7    receive
 8        {Browser, #{entry => <<"input">>m txt => Bin}} ->
 9            Time = clock1:current_time(),
10            Browser ! #{cmd => append_div, id => scroll, txt => list_to_binary([Time, ">", Bin, "<Br>"])}
11    end,
12    running(Browser).
13        	

浏览器里的 Erlang shell

可以用接口模式里的代码制作一个在浏览器里运行的Erlang shell。

 1start(Browser) ->
 2    Browser ! #{cmd => append_div, id => scroll, txt => <<"Starting Erlang shell:<br>">>},
 3    B0 = erl_eval:new_bindings(),
 4    running(Browser, B0, 1).
 5running(Browser, B0, N) ->
 6    receive
 7        {Browser, #{entry =>  <<"input">>}, txt => Bin}} ->
 8            {Vaule, B1} = string2value(binary_to_list(Bin), B0),
 9            BV = bf("~w > <font color='red'>~s</font><br>~p<br>", [N, Bin, Value]),
10            Browser ! #{cmd => append_div, id => scroll, txt => BV},
11            running(Brwoser, B1, N+1)
12     end.
13
14string2value(Str, Bindings0) ->
15    case erl_scan:string(Str, 0) of
16        {ok, Tokens, _} ->
17            case erl_parse:parse_exprs(Tokens) of
18                {ok, Exprs} ->
19                    {value, Val, Bindings1} = erl_eval:exprs(Exprs, Binding0),
20                    {Val, Bindings1};
21                Other ->
22                    io:format("cannot parse:~p Reason=~p~n", [Tokens, Other]),
23                    {parse_error, Bindings0}
24             end;
25        Other ->
26            io:format("cannot tokenise:~p Reason=~p~n", [Str, Other])
27    end.

一个聊天小部件

 1-module(chat1).
 2-export([start/1]).
 3
 4start(Browser) ->
 5    running(Browser, []).
 6
 7running(Browser, L) ->
 8    receive
 9        {Brwoser, #{join => Who}} ->
10            Browser ! #{cmd => append_div, id =>scroll, 
11                        txt => list_to_binary([Who, " joined the group\n"])},
12            L1 = [Who, "<br>"|L],
13            Browser ! #{cmd => fill_div, id => users,
14                       txt => list_to_binary(L1)},
15            running(Browser, L1);
16        {Browser, #{entry => <<"tell">> ,txt => Txt}} ->
17            Browser ! #{cmd => append_div, id => scroll,
18                       txt => list_to_binary([" > ", Txt, "<br>"])},
19            running(Browser, L);
20        X ->
21            io:format("chat received:~p~n", [X])
22     end,
23    running(Browser, L).

简化版 IRC

IRC是Internet Relay Chat(互联网中继聊天)的缩写,它以客户端-服务器的形式进行文本消息传输。

上一节里的聊天小部件可以轻松扩展成一个更真实的聊天程序。

浏览器里的图形

浏览器-服务器协议

这是这一章所有程序用到的协议。

练习

(1) shell1.erl里启动的进程不够完善。如果它崩溃了,Web应用程序就会锁定并无法运行。给这个应用程序添加错误恢复代码。添加历史命令重放的功能。

(2) 阅读websockets.js里的代码,仔细追踪浏览器里某个活动按钮被点击后发生的事。跟着代码从JavaScript到WebSocket,再从WebSocket到Erlang。点击按钮后生成的消息是如何找到相应的Erlang控制进程的?

(3) 简化版IRC程序是一个功能完备的聊天程序。试着运行它并检查它的功能是否正常。你可能会发现防火墙之类的因素阻止它访问服务器,让它无法正常工作。如果是这样,就进行调查并看看能否开放防火墙。尝试找到真正的IRC协议规范,你会发现它比这里的版本长很多。为什么会这样?用某种用户身份验证系统来扩展这个IRC系统。

(4) IRC程序使用了一台中央服务器。能否修改这个程序,让它用点对点网络取代中央服务器?能否给聊天客户端添加SVG图形,或者用HTML5里的音频接口发送和接收声音数据?

第19章 用ETS和DETS存储数据

基本概念

  • ETS是Erlang Term Storage(Erlang数据存储)的缩写,DETS则是Disk ETS(磁盘ETS)的缩写。
  • ETS和DETS执行的任务基本相同:它们提供大型的键值查询表。ETS常驻内存,DETS则常驻磁盘。DETS提供了几乎和ETS一样的接口,但它会把表保存在磁盘上。因为DETS使用磁盘存储,所以它远远慢于ETS,但是运行时的内存占用也会小很多。ETS表里的数据保存在内存里,它们是易失的。当ETS表被丢弃或者控制它的Erlang进程终止时,这些数据就会被删除。保存在DETS表里的数据是非易失的,即使整个系统崩溃也能留存下来。DETS表在打开时会进行一致性检查,如果发现有损坏,系统就会尝试修复它
  • ETS和DETS表是把键和值关联到一起的数据结构。最常用的表操作是插入和查找。ETS或DETS表其实就是Erlang元组的集合。
  • 一些表被称为异键表(set),它们要求表里所有的键都是唯一的。另一些被称为同键表(bag),它们允许多个元素拥有相同的键。基本的表类型(异键表和同键表)各有两个变种,它们共同构成四种表类型:异键、有序异键(ordered set)、同键和副本同键(duplicate bag)。在异键表里,各个元组里的键都必须是独一无二的。在有序异键表里,元组会被排序。在同键表里可以有不止一个元组拥有相同的键,但是不能有两个完全相同的元组。在副本同键表里可以有多个元组拥有相同的键,而且在同一张表里可以存在多个相同的元组。

基本操作

  • 创建一个新表或打开现有的表 用ets:newdets:open_file实现。
  • 向表里插入一个或多个元组 这里要调用insert(TableId, X),其中X是一个元组或元组列表。insert在ETS和DETS里有着相同的参数和工作方式。
  • 在表里查找某个元组 这里要调用 lookup(TableID, Key)。得到的结果是一个匹配 Key的元组列表。 lookup在ETS和DETS里都有定义。lookup的返回值始终是一个元组列表,这样就能对异键表和同键表使用同一个查找函数。如果表的类型是同键,那么多个元组可以拥有相同的键。如果表的类型是异键,那么查找成功后的列表里只会有一个元素。如果表里没有任何元组拥有所需的键,就会返回一个空列表。
  • 丢弃某个表 用完某个表后可以告知系统,方法是调用dets:close(TableId)ets:delete(TableId)

ets_test.erl

 1-module(ets_test).
 2-export([start/0]).
 3
 4start() ->
 5    lists:foreach(fun test_ets/1,
 6                  [set, ordered_set, bag, duplicate_bag]).
 7
 8test_ets(Mode) ->
 9    TableId = ets:new(test, [Mode]),
10    ets:insert(TableId, {a,1}),
11    ets:insert(TableId, {b,2}),
12    ets:insert(TableId, {a,1}),
13    ets:insert(TableId, {a,3}),
14    List = ets:tab2list(TableId),
15    io:format("~-13w  => ~p~n", [Mode, List]),
16    ets:delete(TableId).
11> ets_test:start().
2set            => [{b,2},{a,3}]
3ordered_set    => [{a,3},{b,2}]
4bag			   => [{b,2},{a,1},{a,3}]
5duplicate_bag  => [{b,2},{a,1},{a,1},{a,3}]

影响 ETS 表效率的因素

TODO 待补

lib_trigrams.erl:

 1for_each_trigram_in_the_english_language(F, A0) ->
 2    {ok, Bin0} = file:read_file("354984si.ngl.gz"),
 3    Bin = zlib:gunzip(Bin0),
 4    scan_word_list(binary_to_list(Bin), F, A0).
 5
 6scan_word_list([], _, A) ->
 7    A;
 8scan_word_list(L, F, A) ->
 9    {Word, L1} = get_next_word(L, []),
10    A1 = scan_trigrams([$\s|Word], F, A).
11
12%% 扫描单词,寻找\r\n。
13%% 第二个参数是(反转的)单词,
14%% 所以必须在找到\r\n或扫描完字符时把它反转回来
15%% 于是这个函数,第一个参数是一个字符串(每一行一个单词),第二个参数是结果,目的是将字符串反转(abc ->cba)
16
17get_next_word([$\r,$\n|T], L) -> {reverse([$\s|L]), T};
18get_next_word([H|T], L) -> get_next_word(T, [H|L]);
19get_next_word([], L) -> {reverse([$\s|L]), []}.
20
21scan_trigrams([X, Y, Z], F, A) ->
22    F([X, Y, Z], A);
23scan_trigrams([X, Y, Z|T], F, A) ->
24    A1 = F([X,Y,Z], A),
25    scan_trigrams([Y,Z|T], F, A1);
26scan_trigrams(_, _, A) ->
27    A.
 1%% 封装异键,有序异键的创建和持久化的名称
 2make_ets_ordered_set() -> make_a_set(ordered_set, "trigramsOS.tab").
 3make_ets_set()         -> make_a_set(set, "trigramsS.tab").
 4
 5make_a_set(TYpe, FileName) ->
 6    %% 建表
 7    Tab = ets:new(table, [Type]),
 8    %% 插入二进制型到表
 9    F = fun(Str, _) -> ets:insert(Tab, {list_to_binary(Str)}) end,
10    for_each_trigram_in_the_english_language(F, 0),
11    %% 持久化到文件
12    ets:tab2file(Tab, FileName),
13    Size = ets:info(Tab, size),
14    ets:delete(Tab),
15    Size.
1%% 使用Erlang:sets创建一个包含所有三字母组合的异键表
2make_mod_set() ->
3    D = sets:new(),
4    F = fun(Str, Set) -> sets:add_element(list_to_binary(Str), Set) end,
5    D1 = for_each_trigram_in_the_english_language(F,D),
6    file:write_file("trigrams.set", [term_to_binary(D1)]).
1timer_tests() ->
 1%% 封装字符串,在左右两边加个空格
 2is_word(Tab, Str) -> is_word1(Tab, "\s" ++ Str ++ "\s").
 3%% 只有3个字母
 4is_word1(Tab, [_,_,_]=X) -> is_this_a_trigram(Tab, X);
 5%% 4个及更多的字母
 6is_word1(Tab, [A,B,C|D]) ->
 7    case is_this_a_trigram(Tab, [A,B,C]) of
 8        true -> is_word1(Tab, [B,C|D]);
 9        false -> false
10    end;
11%% 匹配到这直接错误
12is_word1(_, _) ->
13    false.
14
15%% 封装查询
16is_this_a_trigram(Tab, X) ->
17    case ets:lookup(Tab, list_to_binary(X)) of
18        [] -> false;
19        _  -> true
20    end.
21
22%% 封装打开之前生成的字典ets
23open() ->
24    File = filename:join(filename:dirname(code:which(?MODULE)),
25                         "/trigramsS.tab"),
26    {ok, Tab} = ets:file2tab(File),
27    Tab.
28close(Tab) -> ets:delete(Tab).

lib_filenames_dets.erl:

 1-module(lib_filenames_dets).
 2-export([open/1, close/0, test/0, filename2index/1, index2filename/1]).
 3
 4open(File) ->
 5    io:format("dets opened:~p~n", [File]),
 6    %% 检查文件名的文件是否存在
 7    Bool = filelib:is_file(File),
 8    case dets:open_file(?MODULE, [{file, File}]) of
 9        {ok, ?MODULE} ->
10            case Bool of
11                %% 打开一个现有文件
12                true -> void;
13                %% 新建文件,初始化dets表,1代表目前1的索引为空白的
14                false -> ok = dets:insert(?MODULE, {free,1})
15            end,
16            true;
17        {error,Reason} ->
18            io:format("cannot open dets table~n"),
19            exit({eDetOpen, FIle, Reason})
20    end.
21
22close() -> dets:close(?MODULE).
23
 1filename2index(FileName) when is_binary(FileName) ->
 2    case dets:lookup(?MODULE, FileName) of
 3        [] ->
 4            %% 查询空白索引
 5            [{_,Free}] = dets:lookup(?MODULE, free),
 6            %% 插入数据,同时更新空白索引为下一个位置
 7            ok = dets:insert(?MODULE, 
 8                             [{Free,FileName},{FileName,Free},{free,Free+1}]),
 9            Free;
10        [{_,N}] ->
11            N
12    end.
1%% 根据索引查询文件名
2index2filename(Index) when is_integer(Index) ->
3    case dets:lookup(?MODULE, Index) of
4        [] -> error;
5        [{_, Bin}] -> Bin
6    end.

练习

(1) Mod:module_info(exports)会返回Mod模块里所有导出函数的列表。用这个函数找出Erlang系统库里导出的所有函数。制作一个键值查询表,其中键是一个{Function,Arity}对,值是一个模块名。把这些数据储存在ETS和DETS表里。

提示 使用code:lib_dir()和code:lib_dir(LibName)来找出系统里所有模块的名称。

(2) 制作一个共享的ETS计数表。实现一个名为count:me(Mod,Line)的函数,通过在你的代码里添加count:me(?MODULE, ?LINE)来调用它。每当这个函数被调用时,就给记录自身执行次数的计数器加1。编写一些函数来初始化和读取计数器。

(3) 编写一个检测文本抄袭的程序。用一个双遍历(two-pass)算法来实现它。第一次遍历时,把文本打散成40个字符的小块并计算各个块的校验和,然后把校验和与文件名保存在一个ETS表里。第二次遍历时,计算数据里各个40字符块的校验和,并把它们与ETS表里的校验和进行比较。

提示 要做到这一点,需要计算“滚动校验和” 。举个例子,假如C1 = B1 + B2 + … B40并且C2 = B2 + B3 + … B41,你就可以快速计算出C2,因为通过观察能发现C2 = C1 +B41 - B1。

第20章 Mnesia:Erlang数据库

1erl
2> mnesia:create_schema([node()]).
3ok
4> init:stop().
5ok
6ls
7Mnesia.nonode@nohost
1erl -name joe
2([email protected]) 1> mnesia:create_schema([node()]).
3ok
4([email protected]) 2> init:stop().
5ok
6ls
7[email protected]
1erl -mnesia dir '"/home/joe/some/path/to.Mnesia.company"'
21> mnesia:create_schema([node()]).
3ok
42> init:stop().
5ok

test_mnesia.erl:

 1-record(shop, {item, quantity, cost}).
 2-record(cost, {name, price}).
 3
 4do_this_once() ->
 5    mnesia:create_schema([node()]),
 6    mnesia:start(),
 7    mnesia:create_table(shop, [{attributes, record_info(fields, shop)}]),
 8    mnesia:create_table(cost, [{attributes, record_info(fields, cost)}]),
 9    mnesia:create_table(design, [{attributes, record_info(fields, design)}]),
10    mnesia:stop().
1%% SQL
2%% SELECT * FROM shop;
3
4demo(select_shop) ->
5    do(qlc:q([X || X <- mnesia:table(shop)]));
 11> test_mnesia:start().
 2ok
 32> test_mnesia:reset_tables().
 4{atomic, ok}
 53> test_mnesia:demo(select_shop).
 6[{shop,potato,2456,1.2},
 7{shop,orange,100,3.8},
 8{shop,apple,20,2.3},
 9{shop,pear,200,3.6},
10{shop,banana,420,4.5}]
1%% SQL
2%% SELECT item, quantity FROM shop;
3
4demo(select_some) ->
5    do(qlc:q([{X#shop.item, X#shop.quantity} || X <- mnesia:table(shop)]));
6%% [{orange,100},{pear,200},{banana,420},{potato,2456},{apple,20}]
1%% SQL
2%% SELECT shop.item FROM shop
3%% WHERE shop.quantity < 250;
4
5demo(reorder) ->
6    do(qlc:q([X#shop.item || X <- mnesia:table(shop), 
7              X#shop.quantity < 250
8             ]));
9%% [orange, pear, apple]
 1%% SQL
 2%% SELECT shop.item FROM shop, cost
 3%% WHERE shop.item = cost.name
 4%% 	 AND cost.price < 2 AND shop.quantity < 250
 5
 6demo(join) ->
 7    do(qlc:q([X#shop.item || X <- mnesia:table(shop),
 8              				 X#shop.quantity < 250,
 9              				 Y <- mnesia:table(cost),
10             				 X#shop.item =:= Y#cost.name,
11             				 Y#cost.price < 2
12             ])).
1add_shop_item(Name, Quantity, Cost) ->
2    Row = #shop{item=Name, quantity=Quantity, cost=Cost},
3    F = fun() ->
4            mnesia:write(Row)
5        end,
6    mnesia:transaction(F).
1> test_mnesia:start().
2ok
3> test_mnesia:reset_tables().
4{atomic, ok}
5> test_mnesia:demo(select_shop).
6> test_mnesia:add_shop_item(orange, 236, 2.8).
7> test_mnesia:demo(select_shop).
1remove_shop_item(Item) ->
2    Oid = {shop, Item},
3    F = fun() ->
4            mnesia:delete(Oid)
5        end,
6    mnesia:transaction(F).
1test_mnesia:remove_shop_item(pear).
2test_mnesia:demo(select_shop).
3mnesia:stop().
1do(Q) ->
2    F = fun() -> qlc:e(Q) end,
3    {atomic, Val} = mnesia:transaction(F),
4    Val.

test_mnesia.erl:

 1-record(design, {id, plan}).
 2
 3add_plans() ->
 4    D1 = #design{id = {joe,1},
 5                 plan = {circle,10}},
 6    D2 = #design{id = fred,
 7                 plan = {rectangle,10,5}},
 8    D3 = #design{id = {jane,{house,23}},
 9                 plan = {house,
10                         [{floor,1,
11                           [{doors,3},
12                            {windows,12},
13                            {rooms,5}]},
14                          {floor,2,
15                           [{doors,2},
16                            {rooms,4},
17                            {windows,15}]}]}},
18    F = fun() ->
19            mnesia:write(D1),
20            mnesia:write(D2),
21            mnesia:write(D3)
22        end,
23    mnesia:transaction(F).
24
25get_plan(PlanId) ->
26    F = fun() -> mnesia:read({design, PlanId}) end,
27    mnesia:transaction(F).
1test_mnesia:start().
2test_mnesia:add_plans().
3test_mnesia:get_plan(fred).
4test_mnesia:get_plan({jane,{house,23}}).

练习

(1) 假设你要制作一个网站,让用户可以提供优秀Erlang程序的消息。制作一个包含三个表(users、tips和abuse)的Mnesia数据库来保存网站需要的所有数据。users表应当保存用户账户数据(姓名、邮箱地址和密码等)。tips表应当保存关于实用网站的消息(比如网站URL、描述和检查日期等)。abuse表应当保存某些数据来尽量防止网站滥用(比如网站访客的IP地址和网站访问次数等数据)。 配置这个数据库,让它运行在一台机器上并各有一个内存和磁盘副本。编写一些函数来读取、写入和列出这些表。

(2) 继续上一个练习,但要把数据库配置成在两台机器上各有内存和磁盘副本。尝试在一台机器上生成更新并让它崩溃,然后检查是否能接着访问第二台机器上的数据库。

(3) 编写一个查询来拒绝某个消息,条件是用户在一天内提交了超过10条消息,或者最近一天从三个以上的IP地址登录。 测量查询数据库所需的时间。

第21章 性能分析、调试与跟踪

11> cprof:start(). %% 启动性能分析器
24501
32> shout:start(). %% 运行应用程序
4<0.35.0>
53> cprof:pause(). %% 暂停性能分析器
64844 
74> cprof:analyse(shout). %% 分析函数调用
 11> cover:start(). %% 启动覆盖分析器
 2{ok,<0.34.0>}
 32>cover:compile(shout). %% 编译shout.erl来进行覆盖分析
 4{ok,shout}
 53>shout:start(). %% 运行程序
 6<0.41.0>
 7Playing:<<"title: track018"...
 84> %% 让程序运行一段时间
 94> cover:analyse_to_file(shout). %% 分析结果
10{ok, "shout,COVER.out"} %% 结果文件
1cd /home/joe/2007/vsg-1.6
2rm *.beam
3erlc +debug_info *.erl
4erl
51> xref:d('.').
1foo(1,2) ->
2    a;
3foo(2,3,a) ->
4    b.
11> c(bad).
2./bad.erl:3: head mismatch
1foo(A,B) ->
2    bar(A, dothis(X), B),
3    baz(Y,X).
11> c(bad).
2./bad.erl:2: variable 'X' is unbound
3./bad.erl:3: variable 'Y' is unbound
1unterminated string starting with "..."
1foo() ->
2    case bar() of
3        1 ->
4            X = 1,
5            Y = 2;
6        2 ->
7            X = 3
8    end,
9    b(X).
11> c(bad).
2./bad.erl:5: Warning: variable 'Y' is unused
3{ok, bad}
1foo() ->
2    case bar() of
3        1 ->
4            X = 1,
5            Y = 2;
6        2 ->
7            X = 3
8    end,
9    b(X,Y).
11> c(bad).
2./bad.erl:9: variable 'Y' unsafe in 'case'
3{ok, bad}
1foo(X, L) ->
2    lists:map(fun(X) -> 2*X end, L).
11> c(bad).
2./bad.erl:1: Warning: variable 'X' is unused
3./bad.erl:2: Warning: variable 'X' shadowed in 'fun'
4{ok, bad}
1foo(Z, L) ->
2    lists:map(fun(X) -> 2*X end, L).
1deliberate_error(A) ->
2    bad_function(A, 12),
3    lists:reverse(A).
4
5bad_function(A, _) ->
6    {ok, Bin} = file:open({abc,123}, A),
7    binary_to_list(Bin).
11> lib_misc:deliberate_error("file.erl").
2** exception error: no match of right hand side value {error,badarg}
3in function lib_misc:bad_function/2 (lib_misc.erl, line 804)
4in call from lib_misc:deliberate_error/1 (lib_misc.erl, line 800)
1loop(...) ->
2    receive
3        Any ->
4            io:format("*** warning unexpected message:~p~n", [Any])
5            loop(...)
6	end.
1-define(NYI(X),(begin
2                    io:format("*** NYI ~p ~p ~p~n",[?MODULE, >LINE, X]),
3               		exit(nyi)
4                end)).
5
6glurk(X, Y) ->
7    ?NYI({glurk, X, Y}).
1> lib_misc:glurk(1,2).
2*** NYI lib_misc 83 {glurk,1,2}
3** exited: nyi *
1dump(File, Term) ->
2    Out = File ++ ".tmp",
3    io:format("** dumping t0 ~s~n", [Out]),
4    {ok, S} = file:open(Out, [write]),
5    io:format(S, "~p.~n", [Term]),
6    file:close(S).

elog5.config

1%% 文本错误日志
2[ {kernel,
3  [{error_logger,
4   {file, "/Users/joe/error_logs/debug.log"}}]}].
1erl -config elog5.config
 11> %% 重新编译lib_misc,这样就能调试它了
 21> c(lib_misc, [debug_info]).
 3{ok, lib_misc}
 42> im(). %% 这里会弹出一个窗口,现在可以忽略它
 5<0.42.0>
 63> ii(lib_misc).
 7{module,lib_misc}
 84> iaa([init]).
 9true.
105> lib_misc:
11...

tracer_test.erl:

 1trace_module(Mod, StartFun) ->
 2    %% 分裂一个进程来执行跟踪
 3    spawn(fun() -> trace_module1(Mod, StartFun) end).
 4
 5trace_module1(Mod, StartFun) ->
 6    %% 下一方的意思是:跟踪Mod里的所有函数调用和返回值
 7    erlang:trace_pattern({Mod, '_','_'},
 8                         [{'_',[],[{return_trace}]}],
 9                         [local]),
10    %% 分裂一个函数来执行跟踪
11    S = self(),
12    Pid = spawn(fun() -> do_trace(S, StartFun) end),
13    %% 设置跟踪,告诉系统开始
14    %% 跟踪进程Pid
15    erlang:trace(Pid, true, [call,procs]),
16    %% 现在让Pid启动
17    Pid ! {self(), start},
18    trace_loop().
19
20%% do_trace会在Parent的指示下执行StartFun()
21do_trace(Parent, StartFun) ->
22    receive
23        {Parent, start} ->
24            StartFun()
25    end.
26
27%% trace_loop负责显示函数调用和返回值
28trace_loop() ->
29    receive
30        {trace,_,call,X} ->
31            io:format("Call: ~p~n",[X]),
32            trace_loop();
33        {trace,_,return_from, Call, Ret} ->
34            io:format("Return From: ~p => ~p~n", [Call, Ret]),
35            trace_loop();
36        Other ->
37            %% 其他信息,打印
38            io:format("Other = ~p~n", [Other]),
39            trace_loop()
40    end.
1test2() ->
2    trace_module(tracer_test, fun() -> fib(4) end).
3
4fib(0) -> 1;
5fib(1) -> 1;
6fib(N) -> fib(N-1) + fib(N-2).
11> c(tracer_test).
22> tracer_test:test2().
1test1() ->
2    dbg:tracer(),
3    dbg:tpl(tracer_test, fib, '_',
4            dbg:fun2ms(fun(_) -> return_trace() end)),
5    dbg:p(all,[c]),
6    tracer_test:fib(4).

练习

(1) 创建一个新目录,然后复制标准库模块dict.erl到这个目录里。给dict.erl添加一个错误,使它在某一行代码被执行时会崩溃。然后编译这个模块。

(2) 现在我们有了一个问题模块dict,但多半还不知道它有问题,所以需要引发错误。编写一个简单的测试模块来用多种方式调用dict,看看能否让dict崩溃。

(3) 使用覆盖分析器来检查dict里的每一行代码各被执行了多少次。给你的测试模块添加更多的测试案例,看看是否覆盖了dict里的所有代码。这么做的目的是确保dict里的每一行代码都被执行。一旦知道哪些代码行未被执行,就能轻松进行反向推导,找出测试案例里的哪些代码行能导致某一行原代码被执行。 坚持做这件事,直到程序崩溃为止。崩溃迟早会发生,因为当每一行代码都被覆盖时,就意味着错误已被触发。

(4) 现在我们有了一个错误。假装你不知道错误出在哪里,然后使用这一章里介绍的方法来找出这个错误。 当你真的不知道错误在哪里时,这个练习会更有效。找个朋友来破坏你的某些模块,然后对这些模块运行覆盖测试来引发错误。一旦引发了错误,就使用调试技术来找出问题所在。

第22章 OTP介绍

有点像接口的概念。

引入OTP

server.erl:

 1-module(server1).
 2-export([start/2, rpc/2]).
 3
 4%% 开启服务器进程并注册
 5start(Name, Mod) ->
 6    register(Name, spawn(fun() -> loop(Name, Mod, Mod:init()) end)).
 7%% 给服务器发信
 8rpc(Name, Request) ->
 9    Name ! {self(), Request},
10    receive
11        {Name, Response} -> Response
12    end.
13%% 服务器核心程序
14%% 这个State参数要注意
15loop(Name, Mod, State) ->
16    receive
17        {From, Request} ->
18            {Response, State1} = Mod:handle(Request, State),
19            From ! {Name,Response},
20            loop(Name, Mod, State1)
21    end.

name_server.erl:

 1-module(name_server).
 2-export([init/0, add/2, find/1, handle/2]).
 3-import(server1, [rpc/2])
 4
 5%% 客户端方法
 6add(Name, Place) -> rpc(name_server, {add, Name, Place}).
 7find(Name) -> rpc(name_server, {find, Name}).
 8
 9%% 回调方法
10init() -> dict:new().
11handle({add, Name, Place}, Dict) -> {ok, dict:stire(Name,Place,Dict)};
12handle({find, Name}, Dict) -> {dict:find(Name, Dict), Dict}.
11> server1:start(name_server, name_server).
2ture
32> name_server:add(joe, "at home").
4ok
53> name_server:find(joe).
6{ok, "at home"}

server2.erl:

 1-module(server2).
 2-export([start/2, rpc/2]).
 3
 4start(Name, Mod) ->
 5    register(Name, spawn(fun() -> loop(Name, Mod, Mod:init()) end)).
 6
 7rpc(Name, Request) ->
 8    Name ! {self(), Request},
 9    receive
10        {Name, crash} -> exit(rpc);
11        {Name, ok, Response} -> Response
12    end.
13
14loop(Name, Mod, OldState) ->
15    receive
16        {From, Request} ->
17            try Mod:handle(Request, OldState) of
18                {Response, NewState} ->
19                    From ! {Name, ok, Response},
20                    loop(Name, Mod, NewState)
21            catch
22                _:Why ->
23                    log_the_error(Name, Request, Why),
24                    %% 发送一个消息让客户端崩溃
25                    From ! {Name, crash},
26                    %% 以初始状态继续循环
27                    loop(Name, Mod, OldState)
28            end
29    end.
30
31log_the_error(Name, Request, Why) ->
32    io:format("Server ~p request ~p ~n"
33              "caused exception ~p~n",
34              [Name, Request, Why]).

server3.erl:

 1-module(server3).
 2-export([start/2, rpc/2, swap_code/2]).
 3
 4start(Name, Mod) ->
 5    register(Name, spawn(fun() -> loop(Name, Mod, Mod:init()) end)).
 6
 7%% 通过传入新的Mod改变运行的代码
 8swap_code(Name, Mod) -> rpc(Name, {swap_code, Mod}).
 9
10rpc(Name, Request) ->
11    Name ! {self(), Request},
12    receive
13        {Name, Response} -> Response
14    end.
15
16loop(Name, Mod, OldState) ->
17    receive
18        %% 监听到改变代码的消息
19        {From, {swap_code, NewCallBackMod}} ->
20            From ! {Name, ack},
21            loop(Name, NewcallBackMod, OldState);
22        %% 正常处理
23        {From, Request} ->
24            {Response, NewState} = Mod:handle(Request, OldState),
25            From ! {Name, Response},
26            loop(Name, Mod, NewState)
27    end.

name_server1.erl:

 1-module(name_server1).
 2-export([init/0, add/2, find/1, handle/2]).
 3-import(server3, [rpc/2]).
 4
 5%% 客户端方法
 6add(Name, Place) -> rpc(name_server, {add, Name, Place}).
 7find(Name) -> rpc(name_server, {find, Name}).
 8
 9%% 回调方法
10init() -> dict:new().
11
12handle({add, Name, Place}, Dict) -> {ok, dict:stire(Name,Place,Dict)};
13handle({find, Name}, Dict) -> {dict:find(Name, Dict), Dict}.

new_name_server.erl:

 1-module(new_name_server).
 2-export([init/0, add/2, all_names/0, delete/1, find/1, handle/2]).
 3-import(server3, [rpc/2]).
 4
 5%% 接口
 6all_names() -> rpc(name_server, allNames).
 7add(Name, Place) -> rpc(name_server, {add, Name, Place}).
 8delete(Name) -> rpc(name_server, {delete, Name}).
 9find(Name) -> rpc(name_server, {find, Name}).
10
11%% 回调方法
12init() -> dict:new().
13
14handle({add, Name, Place}, Dict) -> {ok, dict:store(Name, Place, Dict)};
15handle(allNames, Dict) -> {dict:fetch_keys(Dict), Dict};
16handle({delete, Name}, Dict) -> {ok, dict:erase(Name, Dict)};
17handle({find, Name}, Dict) -> {dict:find(Name, Dict), Dict}.
 11> server3:start(name_server, name_server1).
 2true
 32> name_server1:add(joe, "at home").
 4ok
 53> name_server1:add(helen, "at work").
 6ok
 74> c(new_name_server).
 85> server3:swap_code(name_server, new_name_server).
 9ack
106> new_name_server:all_names().

server4.erl:

 1-module(server4).
 2-export([start/2, rpc/2, swap_code/2]).
 3
 4start(Name, Mod) ->
 5    register(Name, spawn(fun() -> loop(Name, MOd, MOd:init()) end)).
 6
 7swap_code(Name, Mod) -> rpc(Name, {swap_code, Mod}).
 8
 9rpc(Name, Request) ->
10    Name ! {self(), Request},
11    receive
12        {Name, crash} -> exit(rpc);
13        {Name, ok, Response} -> Response
14    end.
15
16loop(Name, Mod, OldState) ->
17    receive
18        {From, {swap_code, NewCallbackMod}} ->
19            From ! {Name, ok, ack},
20            loop(Name, NewCallbackMod, OldState);
21        {From, Request} ->
22            try Mod:handle(Request, OldState) of
23                {Response, NewState} ->
24                    From ! {Name, ok, Response},
25                    loop(Name, Mod, NewState)
26            catch
27                _: Why ->
28                    log_the_error(Name, Request, Why),
29                    From ! {Name, crash},
30                    loop(Name, Mod, OldState)
31            end
32    end.
33
34log_the_error(Name, Request, Why) ->
35    io:format("Server ~p request ~p ~n"
36              "caused exception ~p~n",
37              [Name, Request, Why]).

server5.erl:

 1-module(server5).
 2-export([start/0, rpc/2]).
 3
 4start() -> spawn(fun() -> wait() end).
 5
 6wait() ->
 7    receive
 8        {become, F} -> F()
 9    end.
10
11rpc(Pid, Q) ->
12    Pid ! {self(), Q},
13    receive
14        {PId, Reply} -> Reply
15    end.

my_fac_server.erl:

 1-module(my_fac_server).
 2-export([loop/0]).
 3
 4loop() ->
 5    receive
 6        {From, {fac, N}} ->
 7            From ! {self(), fac(N)}
 8                loop();
 9        {become, Something} ->
10            Something()
11     end.
12
13fac(0) -> 1;
14fac(N) -> N * fac(N-1).
11> Pid = server5:start().
22> c(my_fac_server).
33> Pid ! {become, fun my_fac_server:loop/0}.
44> server5:rpc(Pid, {fac,30}).

gen_server回调结构

应熟记。

  • gen_server:start_link(Name, Mod, InitArgs, Opts)

    创建一个名为Name的通用服务器,回调模块是Mod,Opts则控制通用服务器的行为。在这里可以指定消息记录、函数调试和其他行为。通用服务器通过调用Mod:init(InitArgs)启动。

    在通常的操作里,只会返回{ok, State}。要了解其他参数的含义,请参考gen_server的手册页。 如果返回{ok, State},就说明我们成功启动了服务器,它的初始状态是State。

  • gen_server:call(Name, Request)

    Request(gen_server:call/2的第二个参数) 作为handle_call/3的第一个参数重新出现。From是发送请求的客户端进程的PID,State则是客户端的当前状态。 我们通常会返回{reply, Reply, NewState}。在这种情况下,Reply会返回客户端,成为gen_server:call的返回值。NewState则是服务器接下来的状态。 其他的返回值({noreply, ..}和{stop, ..})相对不太常用。no reply会让服务器继续工作,但客户端会等待一个回复,所以服务器必须把回复的任务委派给其他进程。用适当的参数调用stop会停止服务器。

  • gen_server:cast(Name, Msg)

    对应的回调方法是handle_cast。这个处理函数通常只返回{noreply, NewState}或{stop, …}。前者改变服务器的状态,后者停止服务器。

  • handle_info(Info, State)

    用来处理发给服务器的自发性消息。自发性消息是一切未经显式调用gen_server:call或gen_server:cast而到达服务器的消息。

    举个例子,如果服务器连接到另一个进程并捕捉退出信号,就可能会突然收到一个预料之外的{‘EXIT’, Pid,What}消息。除此之外,系统里任何知道通用服务器PID的进程都可以向它发送消息。这样的消息在服务器里表现为info值。

    它的返回值和handle_cast相同。

  • terminate(Reason, NewState)

    服务器会因为许多原因而终止。某个以handle_开头的函数也许会返回一个{stop, Reason,NewState},服务器也可能崩溃并生成{‘EXIT’, reason}。在所有这些情况下,无论它们是怎样发生的,都会调用terminate(Reason, NewState)

  • code_change(_OldVsn, State, _Extra)

    可以在服务器运行时动态更改它的状态。这个回调函数会在系统执行软件升级时由版本处理子系统调用。

gen_server案例

my_bank.erl:

 1-module(my_bank).
 2
 3-behaviour(gen_server).
 4-export([start/0]).
 5%% gen_server回调函数
 6-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
 7-compile(export_all).
 8-define(SERVER, ?MODULE).
 9
10start() -> gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).
11stop() ->gen_server:call(?MODULE, stop).
12
13new_account(Who) -> gen_server:call(?MODULE, {new, Who}).
14deposit(Who, Amount) -> gen_server:call(?MODULE, {add, Who, Amount}).
15withdraw(Who, Amount) -> gen_server:call(?MODULE, {remove, Who, Amount}).
16
17
18init([]) -> {ok, ets:new(?MODULE, [])}.
19
20handle_call({new, Who}, _From, Tab) ->
21    Reply = case ets:lookup(Tab, Who) of
22                [] -> ets:insert(Tab, {Who, 0}),
23                    {welcome, Who};
24                [_] -> {Who, you_already_are_a_customer}
25            end,
26    {reply, Reply, Tab};
27handle_call({add,Who,X}, _From, Tab) ->
28    Reply = case etts:lookup(Tab, Who) of
29                [] -> not_a_customer;
30                [{Who,Balance}] ->
31                    NewBalance = Balance + X,
32                    ets:insert(Tab, {Who, NewBalance}),
33                    {thanks, Who, your_balance_is, NewBalance}
34            end,
35    {reply, Reply, Tab};
36handle_call({remove,Who, X}, _From, Tab) ->
37    Reply = case ets:lookup(Tab, Who) of
38                [] -> not_a_customer;
39                [{Who, Balance}] when X =< Balance ->
40                    NewBalance = Balance - X,
41                    ets:insert(Tab , {Who, NewBalance}),
42                    {thanks, Who, your_balance_is, NewBalance};
43                [{Who, Balance}] ->
44                    {sorry,Who,you_only_have,Balance, in_the_bank}
45            end,
46    {reply, Reply, Tab};
47
48handle_call(stop, _From, Tab) ->
49    {stop, normal, stopped, Tab}.
50handle_cast(_Msg, State) -> {noreply, State}.
51handle_info(_Info, State) -> {noreply, State}.
52terminate(_Reason, _State) -> ok.
53code_change(_OldVsn, State, _Extra) -> {ok, State}.
 11> my_bank:start().
 2{ok, <0.33.0>}
 32> my_bank:deposit("joe", 10).
 4not_a_customer
 53> my_bank:new_account("joe").
 6{welcome,"joe"}
 74> my_bank:deposit("joe", 10).
 8{thanks,"joe",your_balance_is,10}
 95> my_bank:deposit("joe", 30).
10{thanks,"joe",your_balance_is,40}
116> my_bank:withdraw("joe", 15).
12{thanks,"joe",your_balance_is,25}
137> my_bank:withdraw("joe", 45).
14{sorry,"joe",you_only_have,25,in_the_bank}
  1gen_server_template.full
  2%%%-------------------------------------------------------------------
  3%%% @作者XXX<[email protected]>
  4%%% @版权所有 (C) 2013, XXX
  5%%% @doc
  6%%%
  7%%% @end
  8%%% 创建于:2013年5月26日 作者XXX <[email protected]>
  9%%%-------------------------------------------------------------------
 10-module().
 11-behaviour(gen_server).
 12%% API
 13-export([start_link/0]).
 14%% gen_server回调函数
 15-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
 16terminate/2, code_change/3]).
 17-define(SERVER, ?MODULE).
 18-record(state, {}).
 19%%%===================================================================
 20%%% API
 21%%%===================================================================
 22%%--------------------------------------------------------------------
 23%% @doc
 24%% 启动服务器
 25%%
 26%% @spec start_link() -> {ok, Pid} | ignore | {error, Error}
 27%% @end
 28%%--------------------------------------------------------------------
 29start_link() ->
 30gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).
 31%%%===================================================================
 32%%% gen_server回调函数
 33%%%===================================================================
 34%%--------------------------------------------------------------------
 35%% @private
 36%% @doc
 37%% 初始化服务器
 38%%
 39%% @spec init(Args) -> {ok, State} |
 40%%
 41{ok, State, Timeout} |
 42%%
 43ignore |
 44%%
 45{stop, Reason}
 46%% @end
 47%%--------------------------------------------------------------------
 48init([]) ->
 49{ok, #state{}}.
 50%%--------------------------------------------------------------------
 51%% @private
 52%% @doc
 53%% 处理调用消息
 54%%
 55%% @spec handle_call(Request, From, State) ->
 56%%
 57{reply, Reply, State} |
 58%%
 59{reply, Reply, State, Timeout} |
 60%%
 61{noreply, State} |
 62%%
 63{noreply, State, Timeout} |
 64%%
 65{stop, Reason, Reply, State} |
 66%%
 67{stop, Reason, State}
 68%% @end
 69%%--------------------------------------------------------------------
 70handle_call(_Request, _From, State) ->
 71Reply = ok,
 72{reply, Reply, State}.
 73%%--------------------------------------------------------------------
 74%% @private
 75%% @doc
 76%% 处理播发消息
 77%%
 78%% @spec handle_cast(Msg, State) -> {noreply, State} |
 79%%
 80{noreply, State, Timeout} |
 81%%
 82{stop, Reason, State}
 83%% @end
 84%%--------------------------------------------------------------------
 85handle_cast(_Msg, State) ->
 86{noreply, State}.
 87%%--------------------------------------------------------------------
 88%% @private
 89%% @doc
 90%% 处理所有非调用/播发的消息
 91%%
 92%% @spec handle_info(Info, State) -> {noreply, State} |
 93%%
 94{noreply, State, Timeout} |
 95%%
 96{stop, Reason, State}
 97%% @end
 98%%--------------------------------------------------------------------
 99handle_info(_Info, State) ->
100{noreply, State}.
101%%--------------------------------------------------------------------
102%% @private
103%% @doc
104%% 这个函数是在某个gen_server即将终止时调用的。它应当是Module:init/1的逆操作,并进行必要的清理。
105%% 当它返回时,gen_server终止并生成原因Reason。它的返回值会被忽略
106%%
107%% @spec terminate(Reason, State) -> void()
108%% @end
109%%--------------------------------------------------------------------
110terminate(_Reason, _State) ->
111ok.
112%%--------------------------------------------------------------------
113%% @private
114%% @doc
115%% 在代码更改时转换进程状态
116%%
117%% @spec code_change(OldVsn, State, Extra) -> {ok, NewState}
118%% @end
119%%--------------------------------------------------------------------
120code_change(_OldVsn, State, _Extra) ->
121{ok, State}.
122%%%===================================================================
123%%% 内部函数
124%%%===================================================================

练手小项目

要求:

建立两个node,从一个node上向另一个node请求一个字符串。做两个gen server分别运行在两个节点上,互相能发消息。将两个node上的chat的pid拿到。在chat内部直接发cast消息。不用call。要让两个chat直接通信。然后发信的时候携带Pid让对方可以回信。

问题:

  • 节点之间没有互连,setcookie不是互联而是能够互联。使用net_adm:ping/1连接节点。

  • 需要让gen_server本身的进程使用cast方法,如何让进程本身发消息而不使用rpc或者其他进程给他消息?

    包装一下就行,让另一个进程发送消息触发handle,在handle里面使用cast。

  • 如何获取Pid?注意是网络的Pid不是本地的Pid。

初步实现:

 1%%%-------------------------------------------------------------------
 2%%% @author bnaod1
 3%%% @copyright (C) 2024, 
 4%%% @doc
 5%%%
 6%%% @end
 7%%% Created : 2024 12月26. 14:47
 8%%%-------------------------------------------------------------------
 9-module(chat).
10-author("bnaod1").
11
12-behaviour(gen_server).
13%% API
14-export([start/1]).
15-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
16
17-compile(export_all).
18%%-define(SERVER, server).
19
20start(Sname) -> gen_server:start_link({global, Sname}, ?MODULE, [], []).
21stop(Sname) -> gen_server:call({global, Sname}, stop).
22
23ask_for(Sname, Msg) -{}> gen_server:call({global, Sname}, {quest, Msg}).
24
25init([]) -> {ok, already_to_run}.
26
27handle_call({quest, Msg}, From, Tab) ->
28  io:format("receive msg from ~p: ~n~p~n", [From, Msg]),
29  Reply = "got_it",
30  {reply, Reply, Tab};
31handle_call(stop, _From, Tab) ->
32  {stop, normal, stopped, Tab}.
33
34handle_cast(_Msg, State) -> {noreply, State}.
35handle_info(_Info, State) -> {noreply, State}.
36terminate(_Reason, _State) -> ok.
37code_change(_OldVsn, State, _Extra) -> {ok, State}.

改进:

 1%%%-------------------------------------------------------------------
 2%%% @author bnaod1
 3%%% @copyright (C) 2024, 
 4%%% @doc
 5%%%
 6%%% @end
 7%%% Created : 2024 12月26. 14:47
 8%%%-------------------------------------------------------------------
 9-module(chat).
10-author("赵枭奇").
11
12-behaviour(gen_server).
13%% API
14-export([start/1]).
15-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
16
17-compile(export_all).
18%%-define(SERVER, server).
19
20-record(state, {conn_pid}).
21
22%% 创建genserver
23start(SName) -> gen_server:start_link({global, SName}, ?MODULE, [], []).
24
25%% 交流前先握手获取Pid
26handshake(FromName, ToName) ->
27  io:format("prepare handshake~n"),
28  gen_server:call({global, FromName}, {quest, ToName}).
29
30%% 停止genserver
31stop(SName) -> gen_server:cast({global, SName}, stop).
32
33%% 调试
34is_alive(SName) -> gen_server:call({global, SName}, is_alive).
35
36%% 正式交流
37ask(SName, Msg) -> gen_server:cast({global, SName}, {ask, Msg}).
38
39%% 初始化
40init([]) -> {ok, #state{conn_pid = null}}.
41
42handle_call({quest, SName}, _From, State) ->
43  gen_server:cast({global, SName}, {handshake, self()}),
44  {reply, starting_handshake, State};
45handle_call(is_alive, From, #state{conn_pid = Pid} = State) ->
46  Reply = {alive, connected_process_is_, Pid},
47  {reply, Reply, State}.
48
49handle_cast({handshake, Pid}, State) ->
50  io:format("reply handshake~n"),
51  gen_server:cast(Pid, {ack, self()}), 
52  {noreply, #state{conn_pid = Pid}};
53handle_cast({ack, Pid}, State) ->
54  io:format("handshake complete~n"),
55  {noreply, #state{conn_pid = Pid}};
56handle_cast({ask, Msg}, #state{conn_pid = Pid} = State)->
57  io:format("send msg from ~p to ~p~n", [self(), Pid]),
58  gen_server:cast(Pid, {communicate, Msg}),
59  {noreply, State};
60handle_cast({communicate, Msg}, #state{conn_pid = Pid} = State) ->
61  io:format("get msg from ~p: ~p~n", [Pid, Msg]),
62  {noreply, State};
63handle_cast(stop, State) ->
64  {stop, "ask for stop", State}.
65
66
67handle_info(_Info, State) -> {noreply, State}.
68terminate(_Reason, _State) -> ok.
69code_change(_OldVsn, State, _Extra) -> {ok, State}.
70

练习

在下面这些练习里,我们将用job_centre模块制作一个服务器,它用gen_server实现一种任务管理服务。任务中心(job center)持有一个必须完成的任务队列,这些任务会被编号,任何人都能向队列添加任务。工人可以从队列请求任务,并告诉任务中心已经执行了某项任务。任务是由fun表示的,要执行任务F,工人必须执行F()函数。

(1) 实现任务中心的基本功能,它的接口如下。

  • job_centre:start_link() -> true

    启动任务中心服务器。

  • job_centre:add_job(F) -> JobNumber

    添加任务F到任务队列,然后返回一个整数任务编号。

  • job_centre:work_wanted() -> {JobNumber,F} | no

    请求任务。如果工人想要一个任务,就调用job_centre:work_wanted()。如果队列里有任务,就会返回一个{JobNumber, F}元组。工人执行F()来完成任务。如果队列里没有任务,则会返回no。请确保同一项任务每次只分配给一个工人,并确保系统是公平的,意思是任务按照请求的顺序进行分配。

  • job_centre:job_done(JobNumber)

    发出任务完成的信号。如果工人完成了某一项任务,就必须调用job_centre:job_done (JobNumber)。

(2) 添加一个名为job_centre:statistics()的统计函数,让它报告队列内、进行中和已完成任务的状态。

(3) 添加监视工人进程的代码。如果某个工人进程挂了,请确保它所执行的任务被返回到等待完成的任务池里。

(4) 检查是否有懒惰的工人,也就是接受工作但不按时完成的进程。把任务请求函数修改为返回{JobNumber, JobTime, F},其中JobTime是工人必须完成任务的秒数。如果工人在JobTime- 1时还未完成任务,服务器就应当向其发送一个hurry_up(快点儿)消息,而在JobTime + 1时应该用调用exit(Pid, youre_fired)(你被解雇了)来杀掉这个工人进程。

(5) 可选练习:实现一个工会服务器来监督工人的权利,防止他们没收到警告就被解雇。提示:使用进程跟踪基本函数来实现它。

第23章 用OTP构建系统

这一章也是非常重要。

通用事件处理程序

event_handler.erl:

 1-module(event_handler).
 2-export([make/1, add_handler/2, event/2]).
 3
 4%% 制作一个名为Name的新事件处理器
 5%% 处理函数是no_op,代表部队事件做任何处理
 6make(Name) ->
 7    register(Name, spawn(fun() -> my_handler(fun no_op/1) end)).
 8
 9add_handler(Name, Fun) -> Name ! {add, Fun}.
10
11%%生成一个事件
12event(Name, X) -> Name ! {event, X}.
13
14my_handler(Fun) ->
15    receive
16        {add, Fun1} ->
17            my_handler(Fun1);
18        {event, Any} ->
19            (catch Fun(Any)),
20            my_handler(Fun)
21    end.
22
23no_op(_) -> void.

motor_controller.erl: 定义错误处理函数

1-module(motor_controller).
2-export([add_event_handler/0]).
3
4add_event_handler() ->
5    event_handler:add_handler(errors, fun controller/1).
6controller(too_hot) ->
7    io:format("Turn off the motor~n");
8controller(X) ->
9    io:format("~w ignored event: ~p~n", [?MODULE, X]).

测试:

 11> event_handler:make(errors).
 2true
 32> event_handler:event(errors, hi).
 4{event,hi}
 53> c(motor_controller).
 6{ok, motor_controller}
 74> motor_controller:add_event_handler().
 8{add,#Fun...}
 95> event_handler:event(errors, cool).
10motor_controller ignored event: cool
11{event, cool}
126> event_handler:event(errors, too_hot).
13Turn off the motor
14{event, too_hot}

错误记录器

OTP系统自带一个可定制的错误记录器。

错误记录器会生成多种报告类型。

  • 监控器报告 这些报告会在OTP监控器启动或停止被监控进程时生成(参见23.5节)。
  • 进度报告 这些报告会在OTP监控器启动或停止时生成。
  • 崩溃报告 如果某个被OTP行为启动的进程因为normal或shutdown以外的原因终止,这些报告就会 生成。

这三种报告会自动生成,程序员无须做任何事。 另外,还可以显式调用error_logger模块里的方法来生成三种类型的日志报告。这让我们能够记录错误、警告和信息消息。这三个名词没什么语义含义,只是一些标签,程序员用它们来提示错误日志条目的性质。

API

  • -spec error_logger:error_msg(String) -> ok

    向错误记录器发送一个错误消息。

  • -spec error_logger:error_msg(Format, Data) -> ok

    向错误记录器发送一个错误消息。它的参数和io:format(Format, Data)相同。

  • -spec error_logger:error_report(Report) -> ok

    向错误记录器发送一个标准错误报告。

    -type Report = [{Tag, Data} | term() | string() ].

    -type Tag = term(). -type Data = term().

配置

  1. 标准错误记录器

它会创建一个适合进行程序开发的环境,只提供一种简单的错误记录形式。(不带启动参数的erl命令就等于erl -boot start_clean。):

1erl -boot start_clean

它会创建一个适合运行生产系统的环境。系统架构支持库(System Architecture SupportLibraries,简称SASL)将负责错误记录和过载保护等工作。

1erl -boot start_sasl

日志文件的配置最好通过配置文件实现,因为没人能记住记录器的全部参数。

  1. 无配置SASL
1erl -boot start_sasl
  1. 配置错误记录器

下面的配置文件启动系统,就只会得到错误报告,不会有进度和其他报告。所有这些错误报告只会出现在shell里。

elog1.config:

1%% 无tty
2[{sasl, [
3         {sasl_error_logger, false}
4        ]}].
1erl -boot start_sasl -config elog1

下面的配置在shell里列出错误报告,所有的进度报告则会保存在一个文件里。

elog2.config:

1%% 单文本文件,最小化tty
2
3[{sasl, [
4         %% 所有报告都写入这个文件
5         {sasl_error_logger, {file, "/Users/joe/error_logs/THELOG"}}
6        ]}].

下面的配置既能提供shell输出,又能把写入shell的所有信息复制到一个滚动日志文件里。

elog3.config:

 1[{sasl, [
 2         {sasl_error_logger, false},
 3         %% 定义滚动日志的参数
 4         %% 日志文件目录
 5         {error_logger_mf_dir, "/Users/joe/error_logs"},
 6         %% 每个文件的字节数 10MB
 7         {error_logger_mf_maxbytes, 10485760}, 
 8         %% 日志文件的最大数量
 9         {error_logger_mf_maxfiles, 10}
10        ]}].

在生产环境里,我们真正感兴趣的只有错误,而非进度或信息报告,所以只让错误记录器报告错误。

elog4.config:

 1[{sasl, [
 2         %% 最小化shell错误记录
 3         {sasl_error_logger, false},
 4         %% 只报告错误
 5         {errlog_type, error},
 6         %% 定义滚动日志的参数
 7         %% 日志文件目录
 8         {error_logger_mf_dir,"/User/joe/error_lgos"},
 9         %% 每个日志文件的字节数
10         {error_logger_mf_maxbytes,10485760},
11         %% 日志文件的最大数量
12         {error_logger_mf_maxfiles, 10}
13        ]}].

分析错误

阅读错误日志是rb模块的责任

 1erl -boot start_sasl -config elog3
 2%% 首先必须用正确的配置文件启动Erlang,这样才能定位错误日志
 3rb:help().
 4
 5%% 启动报告浏览器
 6rb:start().
 7
 8%% 告诉它要读取多少日志条目
 9rb:start([{max,20}]).
10
11%% 列出日志里的条目
12rb:list().
13
14%% 检查第8条
15rb:show(8).

要分离出某个错误,可以使用rb:grep(RegExp)这样的命令,它会找出所有匹配正则表达式RegExp的报告。

rb模块里有一些函数能选择特定类型的错误或把它们提取到文件里。因此,分析错误日志的过程可以实现完全自动化。

警报管理

这个警报处理器是OTPgen_event行为的回调模块

my_alarm_handler.erl

 1-module(my_alarm_handler).
 2-behaviour(gen_event).
 3
 4-export([init/1, code_change/3, handle_event/2, handle_call/2, handle_info/2, terminate/2]).
 5
 6%% init必须返回{ok, State}
 7init(Args) ->
 8    io:format("*** my_alarm_handler init:~p~n", [Args]),
 9    {ok, 0}.
10
11handle_event({set_alarm, tooHot}, N) ->
12    error_logger:error_msg("*** Tell the Engineer to turn on the fan~n"),
13    {ok, N};
14handle_event({clear_alarm, tooHot}, N) ->
15    error_logger:error_msg("*** Danger over. Turn off the fan~n"),
16    {ok, N};
17handle_event(Event, N) ->
18    io:format("*** unmatched event:~p~n", [Event]),
19    {ok, N}.
20
21handle_call(_Request, N) -> Reply = N, {ok, Reply, N}.
22handle_info(_Info, N) -> {ok, N}.
23
24terminate(_Reason, _N) -> ok.
25code_change(_OldVsn, State, _Extra) -> {ok, State}.

这段代码非常像之前在22.3节里看到的 gen_server 回调代码。其中值得注意的方法是handle_event(Event, State) ,它应当返回 {ok, NewState} 。 Event 是一个 {EventType,Event-Arg}形式的元组,其中EventType是set_event或clear_event,而EventArg是一个用户提供的参数。

 1erl -boot start_sasl -config elog3
 2> alarm_handler:set_alarm(tooHot).
 3ok
 4=INFO REPORT=====
 5alarm:handler: {set,tooHot}
 6> gen_event:swap_handler(alarm_handler, {alarm_handler, swap},
 7										{my_alarm_handler, xyz}).
 8> alarm_handler:set_alarm(tooHot).
 9ok
10=ERROR ERPORT ====
11*** Tell the Engineet to turn on the fan
12> alarm_handler:clear_alarm(tooHot).
13ok
14=ERROR ERPORT ====
15*** Danger over. Turn off the fan

应用程序服务器

两个gen_server服务器,不抄了。

监控树

监控树是一种由进程组成的树形结构。树的上级进程(监控器)监视着下级进程(工作器), 如果下级进程挂了就会重启它们。监控树有两种:

  • 一对一监控树 在一对一监控里,如果某个工作器崩溃了,就会被监控器重启。
  • 一对多监控树 在一对多监控里,如果任何一个工作器崩溃了,所有工作进程都会被终止(通过调用相 应回调模块里的terminate/2函数)然后重启。

监控器是用OTP supervisor行为创建的。这个行为用一个回调模块作为参数,里面指定了监 控策略以及如何启动监控树里的各个工作进程。监控树通过以下形式的函数指定:

1init(...) ->
2            {ok, {
3                  {RestartStrategy, MaxRestarts, Time},
4                  [Worker1, Worker2, ...]
5                 }}.
  • RestartStrategy是原子one_for_one或one_for_all
  • MaxRestarts和Time则指定“重启频率”。如果一个监控器在Time秒内执行了超过MaxRestarts次重启,那么这个监控器就会终止所有工作进程然后退出。这是为了防止出现一种情形,即某个进程崩溃、被重启,然后又因为相同原因崩溃而形成的无限循环。
  • Worker1和Worker2这些是描述如何启动各个工作进程的元组

示例:

sellaprime_supervisor.erl:

 1-module(sellaprime_supervisor).
 2-behaviour(supervisor).
 3-export([start/0, start_in_shell_for_testing/0, start_link/1, init/1]).
 4
 5start() ->
 6    spawn(fun() ->
 7         	supervisor:start_link({local,?MODULE}, ?MODULE, _Arg = [])
 8         end).
 9start_in_shell_for_testing() ->
10    {ok, Pid} = supervisor:start_link({local,?MODULE}, ?MODULE, _Arg = []),
11    unlink(Pid).
12start_link(Args) ->
13    supervisor:start_link({local,?MODULE}, ?MODULE, Args).
14
15init([]) ->
16    gen_event:swap_handler(alarm_handler,
17                           {alarm_handler, swap},
18                           {my_alarm_handler, xyz}),
19    {ok, {{one_for_one, 3, 10},
20          [{tag1,
21           	{area_server, start_link, []},
22            permanet,
23           	10000,
24            worker,
25            [area_server]},
26           {tag2,
27           	{prime_server, start_link, []},
28            permanet,
29           	10000,
30            worker,
31            [prime_server]}
32          ]}}.
  • init/1返回的数据结构定义了一种监控策略。

  • Worker中的参数的意义

    1{Tag, {Mod, Func, ArgList},
    2     Restart,
    3     Shutdown,
    4     Type,
    5     [Mod1]}
    

    Tag:这是一个原子类型的标签,将来可以用它指代工作进程(如果有必要的话)。

    {Mod, Func, ArgList}:定义了监控器用于启动工作器的函数,将被用作apply(Mod, Fun, ArgList)的参数。

    Restart = permanent | transient | temporary:permanent(永久)进程总是会被重启transient(过渡)进程只有在以非正常退出值终止时才会被重启。temporary(临时)进程不会被重启。

    Shutdown:这是关闭时间,也就是工作器终止过程允许耗费的最长时间。如果超过这个时间,工作进程就会被杀掉。

    Type = worker | supervisor:这是被监控进程的类型。可以用监控进程代替工作进程来构建一个由监控器组成的树。

    [Mod1]:如果子进程是监控器或者gen_server行为的回调模块,就在这里指定回调模块名。

启动系统

后面才是真正的启动。

1erl -boot start_sasl -config elog3
21> sellaprime_supervisor:start_in_shell_for_testing().
32> area_server:area({square,10}).
43> area_server:area({rectangle,10,20}).
54> area_server:area({square,25}).
65> prime_server:new_prime(20).
1rb:start([{max,20}]).
2rb:list().
3rb:show(5).

应用程序

最后的工作

编写一个扩展名为.app的文件,它包含关于这个应用程序的信息。

这是应用程序资源文件,用于’base’应用程序。

sellaprime.app:

1{application, sellaprime,
2 [{description, "The Prime Number Shop"},
3  {vsn, "1.0"},
4  {modules, [sellaprime_app, sellaprime_supervisor, area_server, prime_server, lib_lin, lib_primes, my_alarm_handler]},
5  {registered, [area_server, prime_server, sellaprime_super]},
6  {applications, [kernel, stdlib]},
7  {mod, {sellaprime_app, []}},
8  {start_phases, []}
9 ]}.

现在必须编写一个回调模块,它的名称与前面文件里的mod文件名相同

sellaprime_app.erl

1-module(sellaprime_app).
2-behaviour(application).
3-export([start/2, stop/1]).
4start(_Type, StartArgs) ->
5    sellaprime_supervisor:start_link(StartArgs).
6stop(_State) ->
7    ok.

它必须导出函数start/2和stop/1。做完这一切之后,就可以在shell里启动和停止应用程序了。

启动!:

1erl -boot start_sasl -config elog3
21> application:loaded_applications().
32> application:load(sellaprime).
43> application:loaded_applications().
54> application:start(sellaprime).
65> application:stop(sellaprime).
76> application:unload(sellaprime).

现在它就是一个功能完备的OTP应用程序了。我们在第2行里载入了应用程序,这么做会载入全部代码,但不会启动应用程序。第4行启动了应用程序,第5行则停止了它。请注意,启动和停止应用程序时我们能看到打印输出,因为它调用了面积服务器和质数服务器里相应的回调函数。在第6行卸载了应用程序,这样应用程序里的所有模块代码都被移除了。用OTP构建复杂的系统时,会把它们打包成应用程序。这样我们就能统一启动、停止和管理它们。

调用路径

文件 内容
area_server.erl 面积服务器(gen_server回调模块)
prime_server.erl 质数服务器(gen_server回调模块)
sellaprime_supervisor.erl 监控器回调模块
sellaprime_app.erl 应用程序回调模块
my_alam_handler.erl 用于gen_event的事件回调模块
sellaprime.app 应用程序规范
elog4.config 错误记录器配置文件

(1) 用下列命令启动系统:

1erl -boot -start_sasl -config elog4.config
21> application:start(sellaprime).

sellaprime.app文件必须位于Erlang的启动根目录或它的子目录里。 应用程序控制器随后在sellaprime.app里寻找一个{mod, …}声明。它包含应用程序控制器的名称,在这个案例里是模块sellaprime_app。

(2) 回调方法sellaprime_app:start/2被调用。

(3) sellaprime_app:start/2 调 用 sellaprime_supervisor:start_link/2 , 启 动sellaprime监控器。

(4) 监控器回调函数sellaprime_supervisor:init/1被调用,它会安装一个错误处理器,然后返回一个监控规范。这个监控规范说明了如何启动面积服务器和质数服务器。

(5) sellaprime监控器启动面积服务器和质数服务器,两者都是gen_server的回调模块。停止这一切很容易,只需要调用application:stop(sellaprime)或init:stop()。

应用程序监视器

应用程序监视器是一个用来查看应用程序的GUI。appmon:start()命令会启动应用程序查看器。

练习

(1) 制作一个名为prime_tester_server的gen_server,让它测试给定的数字是否是质数。你可以使用lib_primes.erl里的is_prime/2函数来处理(或者自己实现一个更好的质数测试函数)。把它添加sellaprime_supervisor.erl的监控树里。

(2) 制作由10个质数测试服务器组成的进程池。制作一个队列服务器来把请求加入队列,直到其中一个质数测试服务器处于空闲状态为止。当质数测试服务器空闲时,向它发送一个请求来测试某个数字是否是质数。

(3) 修改质数测试服务器的代码,让它们各自维护一个请求队列,然后移除队列服务器。编写一个负载均衡器来记录各个质数测试服务器中正在进行的任务和待完成请求。测试新质数的请求现在应该发送到负载均衡器。安排负载均衡器把请求发送给负载最小的服务器。

(4) 实现一种监控层级体系,使任何质数测试服务器崩溃后都能被重启。如果负载均衡器崩溃了,就让所有质数测试服务器都崩溃,然后全体重启。

(5) 使全体重启所需的数据在两台机器上同步复制。

(6) 实现一种重启策略,使整台机器崩溃后也能全体重启。

第24章 编程术语

counter.erl:

1-module(counter).
2-export([bump/2, read/1]).
3
4bump(N, {counter, K}) -> {counter, N + K}.
5read({counter, N}) -> N.
 11> c(counter).
 2{ok, counter}
 3
 42> C = {counter, 2}.
 5{counter, 2}
 6
 73> C:read().
 82
 9
104> C1 = C:bump(3).
11{counter, 5}
12
135> C1:read().
145

对元组C和C1的调用代码来说,模块名counter和状态变量都是隐藏的。

adapter_db1.erl:

 1-module(adapter_db1).
 2-export([new/1, store/3, lookup/2]).
 3
 4new(dict) ->
 5    {?MODULE, dict, dict:new()};
 6new(lists) ->
 7    {>MODULE, list, []}.
 8
 9store(Key, Val, {_, dict, D}) ->
10    D1 = dict:store(Key, Val, D),
11    {?MODULE, dict, D1};
12store(Key, Val, {_, list, L}) ->
13    L1 = lists:keystore(Key, 1, L, {Key, Val}),
14    {?MODULE, list, L1}.
15
16lookup(Key, {_, dict, D}) ->
17    dict:find(Key, D);
18lookup(Key, {_, list, L}) ->
19    case lists:keysearch(Key, 1, L) of
20        {value, {Key, Val}} -> {ok, Val};
21        false -> error
22    end.

adapter_db1_test.erl:

 1-module(adapter_db1_test).
 2-export([test/0]).
 3-import(adapter_db1, [new/1, store/2, lookup/1]).
 4
 5test() ->
 6    M0 = new(dict),
 7    M1 = M0:store(key1, val1),
 8    M2 = M1:store(key2, val2),
 9    {ok, val1} = M2:lookup(key1),
10    {ok, val2} = M2:lookup(key2),
11    error = M2:lookup(nokey),
12    
13    N0 = new(lists),
14    N1 = N0:store(Key1, val1),
15    N2 = N1:store(key2, val2),
16    {ok, val1} = N2:lookup(key1),
17    {ok, val2} = N2:lookup(key2),
18    error = N2:lookup(nokey),
19    ok.
1dict:fetch(Key, Dict) = Val | EXIT
2dict:search(Key, Dict) = {found, Val} | not_found.
3dict:is_key(Key, Dict) = Boolean

练习

(1) 扩展adapter_db1里的适配器,使调用adapter_db1:new(persistent)能创建一个持久性数据存储的元组模块。

(2) 编写一种键值存储,让它把较小的值放入内存,把较大的值放入磁盘。制作一个适配器模块来实现它,并让这个模块与本章前面的适配器具有相同的接口。

(3) 编写一种键值存储,让它把各个键值对分为易失性存储和非易失性存储。调用put(Key,memory, Val)会把一个Key, Val对放入内存,put(Key, disk, Val)则会把数据存入磁盘。用一对进程来做这件事,一个用于易失性存储,另一个用于非易失性存储。重用本章前面的代码。

第25章 第三方程序

rebar

rebar涉及的东西只是23章的东西的一部分自动化。

创建应用

1rebar create-app appid=bertie
1-module(bertie).
2-export([start/0]).
3
4start() -> io:format("Hello my name is Bertie~n").
1rebar compile

整合外部程序与我们的代码

bertie/bertie.erl:

 1-module(bertie).
 2-export([start/0]).
 3
 4start() ->
 5    Handle = bitcask:open("bertie_database", [read_write]),
 6    N = fetch(Handle),
 7    store(Handle, N+1),
 8    io:format("Bertie has benn run ~p times~n", [N]),
 9    bitcask:close(Handle),
10    init:stop().
11
12store(Handle, N) ->
13    bitcask:put(Handle, <<"bertie_executions">>, term_to_binary(N)).
14fetch(Handle) ->
15    case bitcask:get(Handle, <<"bertie_executions">>) of
16        not_found -> 1;
17        {ok, Bin} -> binary_to_term(Bin)
18    end.

bertie/rebar.config:

1{deps, [
2	{bitcask, ".*", {git, "git://github.com/basho/bitcask.git", "master"}}
3]}.

bertie/Makefile:

1all:
2	test -d deps || rebar get-deps
3	rebar compile
4	@erl -noshell -pa './deps/bitcask/ebin' -pa './ebin' -s bertie start

rebar get-deps命令从GitHub获取bitcask并把它保存在名为deps的子目录里。bitcask自身需要一个名为meck的程序用于测试,这就是所谓的递归依赖。rebar会递归获取bitcask所需的各个依赖项,并把它们保存在deps子目录里。

makefile给命令行添加了一个-pa ‘deps/bitcask/ebin’标识,这样当程序启动时,bertie就能自动载入bitcask的代码。

生成依赖项本地副本

~joe/nobackup/erlang_imports/rebar.config:

1{deps, [
2	{cowboy, ".*", {git, "git://.."}},
3	{ranch, ".*", {git, "git://..."}},
4	{bitcask, ".*", {git, "git://..."}}
5]}.
1rebar get-deps
1Home = os:getenv("HOME").
2Dir = Home ++ "/nobackup/erlang_imports/deps",
3    {ok, L} = file:list_dir(Dir).
4lists:foreach(fun(I) ->
5               Path = Dir ++ "/" ++ I ++ "/ebin",
6               code:add_path(Path)
7           end, L).

练习

(1) 注册一个GitHub账号,然后按照本章开头的步骤来创建你自己的项目。

(2) 第二个cowboy示例可能是不安全的。用户可以通过CGI调用接口请求执行任意的Erlang模块。重新设计这个接口,让它只允许调用一组事先定义的模块。

(3) 对cowboy示例做一次安全审计,因为它的代码里有许多安全问题。例如被请求文件的值未经检查,这样用户就能访问Web服务器目录结构以外的文件。找到并修复这些安全问题。

(4) 任何主机都能连接到cowboy服务器。修改它的代码,让它只允许来自已知IP地址的主机连接。把这些主机保存到某种持久性数据库里,比如Mnesia或bitcask。记录某个特定主机进行了多少次连接。制作一个主机黑名单,登记那些在给定时间段内连接过于频繁的主机。

(5) 修改Web服务器,让它允许对通过CGI接口调用的模块进行动态重编译。在我们的示例里,echo.erl模块必须先编译才能调用。当某个模块通过CGI接口被调用时,读取其beam文件的时间戳并与对应.erl的时间戳进行比较,如有必要就重新编译和载入Erlang代码。

(6) rebar是把Erlang程序作为“独立”二进制文件分发的优秀范例。请把rebar的可执行文件复制到一个空白目录并重命名为rebar.zip(rebar其实是一个zip文件),然后解压缩并检查里面的内容。用cowboy示例代码制作你自己的自执行二进制文件。

第26章 多核CPU编程

多核高效运行

使用大量进程

避免副作用

通过在ets:new里使用某个选项,就能创建出一个public类型的表。如果你还记得的话,它的效果如下: 创建一个公共表,任何知道此表标识符的进程都能读取和写入这个表。这么做可能很危险,只有在以下条件得到满足时才是安全的:

  • 每次只能有一个进程写入表,其他进程可以读取表;
  • 写入ETS表的进程是正确的,不会把错误数据写入表。

系统一般不能满足这些条件,而是要靠程序逻辑。

注意1:ETS表的每一种操作都是原子式的,但一系列ETS操作无法作为一个原子单元执行。虽然不会损坏ETS表里的数据,但如果多个进程试图同时更新一个共享表而又没有协调好,就可能出现逻辑上不一致的表。 注意2:ETS表类型protected的安全性要高得多。只有一个进程(即所有者)能写入表,但可以有多个进程读取这个表。这一点由系统保证。但是请记住,即使只有一个进程能写入ETS表,如果它损坏了表里的数据,也会影响到所有读取这个表的进程。 如果你使用的ETS表类型是private,那么你的程序就是安全的。上述结论也适用于DETS。可以创建能被多个不同进程写入的共享式DETS表,但不鼓励这种做法。

ETS和DETS原本并不是为了独立使用而创建的,而是为了实现Mnesia。原本的意图是如果应用程序想要模拟进程间共享内存,就应该使用Mnesia的事务机制。

避免顺序瓶颈

当我们使程序并行化,确保有大量进程并且没有共享内存操作后,接下来的问题就是思考顺序瓶颈。有些事情本来就是顺序的,如果问题本身具有“顺序性”,我们是无法改变这一点的。在很多时候,解决顺序瓶颈的唯一方式就是改变相关的算法,没有其他捷径可走。必须把非分布式算法修改成分布式算法。

要避免单个代理的瓶颈,可以设置两个订票代理。在销售开始时,第一个订票代理会得到所有偶数编号的票,第二个订票代理会得到所有奇数编号的票。通过这种方式,就能确保代理们不会两次销售同一张票。

这种把单个订票代理替换成n个分布式代理(n可以随时间而变),并且各个代理可以加入和离开售票网络以及随时崩溃的做法是当前热门的分布式计算研究领域。这个研究领域被称为分布式散列表(distributed hash tables)。

并行的map函数及测试

 1pmap(F, L) ->
 2    S = self(),
 3    %% make_ref() 返回一个唯一的引用
 4    Ref = erlang:make_ref(),
 5    Pids = map(fun(I) ->
 6               	spawn(fun() -> do_f(S, Ref, F, I) end)
 7               end, L),
 8    %% 收集结果
 9    gather(Pids, Ref).
10
11do_f(Parent, Ref, F, I) ->
12    Parent ! {self(), Ref, (catch F(I))}.
13gather([Pid|T], Ref) ->
14    receive
15        {Pid, Ref, Ret} -> [Ret|gather(T,Ref)]
16    end;
17gather([], _) ->
18    [].

gather函数里的选择性receive会确保返回值里的参数顺序符合原始列表的顺序

具有副作用的代码不能简单地用pmap取代map调用来实现并行。

 1pmap(F, L) ->
 2    S = self(),
 3    %% make_ref() 返回一个唯一的引用
 4    Ref = erlang:make_ref(),
 5    foreach(fun(I) ->
 6               	spawn(fun() -> do_f1(S, Ref, F, I) end)
 7               end, L),
 8    %% 收集结果
 9    gather(length(L), Ref, []).
10
11do_f1(Parent, Ref, F, I) ->
12    Parent ! {Ref, (catch F(I))}.
13gather1(0, _, L) -> L;
14gather1(N, Ref, L) ->
15    receive
16        {ef, Ret} -> gather1(N-1,Ref, [Ret|L])]
17    end;
1#!/bin/sh
2echo "" > results
3for i in 1 2 3 4 5 6 7...\
4		 17 18 ... 32
5do
6	echo $i
7	erl -boot start_clean -noshell -smp +S $i\
8		-s ptests tests $i >> results
9done

ptests.erl:

 1-module(ptests).
 2-export([tests/1, fib/1]).
 3-import(lists, [map/2]).
 4-import(lib_misc, [pmap/2]).
 5
 6tests([N]) ->
 7    Nsched = list_to_integer(atom_to_lists(N)),
 8    run_tests(1, Nsched).
 9
10%% 一次运行test 1 2 3
11run_tests(N, Nsched) ->
12    case test(N) of
13        stop ->
14            init:stop();
15        Val ->
16            io:format("~p.~n", [{Nsched, Val}]),
17            run_tests(N+1, Nsched)
18                
19test(1) ->
20    seed(),
21    S = lists:seq(1,100),
22    L = map(fun(_) -> mkList(1000) end, S),
23    {Time1, S1} = timer:tc(lists, map, [fun lists:sort/1, L]),
24    {Time2, S2} = timer:tc(lib_misc, pmap, [fun lists:sort/1, L]),
25    {sort, Time1, Time2, euqal(S1, S2)};
26test(2) ->
27    L = lists:duplicate(100, 27),
28    {Time1, S1} = timer:tc(lists, map, [fun ptests:fib/1, L]),
29    {Time2, S2} = timer:tc(lib_misc, pmap, [fun lists:sort/1, L]),
30    {fib, Time1, Time2, euqal(S1, S2)};
31test(3) ->
32    stop.
33
34%% 测试map和pmap的计算结果时候相等
35equal(S,S) -> true;
36equal(S1, S2) -> {differ, S1, S2}.
37
38fib(0) -> 1;
39fib(1) -> 1;
40fib(N) -> fib(N-1) + fib(N-2).
41
42%% 重置随机数生成器,这样每次测试都能获得相同的随机数序列
43seed() -> random:seed(44,55,66).
44
45mkList(K) -> mkList(K, []).
46
47mkList(0, L) -> L;
48mkList(N, L) -> mkList(N-1, [random:uniform(1000000) | L]).

mapreduce

mapreduce的基本概念:这些映射(map)进程会生成由{Key,Value}对组成的消息流,并发送给一个化简(reduce)进程进行合并,后者会把具有相同键的对组合在一起。

1-spec mapreduce(F1, F2, Acc0, L) -> Acc
2    F1 = funn(Pid, X) -> void
3    F2 = fun(Key, [Value], Acc0) -> Acc
4    L = [X]
5    Acc = X = term()
  • F1(Pid, X)是映射函数。 F1的任务是发送一个{Key, Value}消息流到Pid进程,然后终止。mapreduce会为列表L里的每一个X值分裂出一个全新进程。
  • F2(Key, [Value], Acc0) -> Acc是化简函数。 当所有映射进程都终止时,化简函数就应该已经合并某个键的所有值了。mapreduce随后对收集的各个{Key, [Value]}元组调用F2(Key, [Value], Acc)。Acc是一个初始值为Acc0 的归集器, F2 会返回一个新的归集器。(另一种描述方式是 F2 对收集的 {Key, [Value]}元组执行了折叠操作。) Acc0是归集器的初始值,在调用F2时使用。
  • L是一个由X值组成的列表。 F1(Pid, X)会被用于L里的每一个X值。Pid是化简进程的标识符,它是由mapreduce创建的。

mapreduce是在phofs(parallel higher-order functions的简写,即并行高阶函数)模块里定 义的:phofs.erl

 1-module(phofs).
 2-export([mapreduce/4]).
 3-import(lists, [foreach/2]).
 4
 5mapreduce(F1, F2, Acc0, L) ->
 6    S = self(),
 7    Pid= spawn(fun() -> reduce(S, F1, F2, Acc0, L) end),
 8    receive
 9        {Pid, Result} ->
10            Result
11    end.
12
13reduce(Parent, F1, F2, Acc0, L) ->
14    process_flag(trap_exit, true),
15    ReducePid = self(),
16    foreach(fun(X) ->
17           		spawn_link(fun() -> do_job(ReducePid, F1, X) end)
18            end, L),
19    N = length(L),
20    Dict0 = dict:new(),
21    Dict1 = collect_replies(N, Dict0),
22    Acc = dict:fold(F2, Acc0, Dict1),
23    Parent ! {self(), Acc}.
24
25collect_replies(0, Dict) ->
26    Dict;
27collect_replies(N, Dict) ->
28    receive
29        {Key, Val} ->
30            case dict:is_key(Key, Dict) of
31                true ->
32                    Dict1 = dict:append(Key, Val, Dict),
33                    collect_replies(N, Dict1);
34                false ->
35                    Dict1 = dict:store(Key, [Val], Dict),
36                    collect_replies(N, Dict1)
37             end;
38        {'EXIT', _, _Why} ->
39            collect_replies(N-1, Dict)
40     end.
41
42do_job(ReducePid, F, X) ->
43    F(ReducePid, X).

练习

(1) 我们在17.1.1节里编写了一个获取网页的程序。修改这个程序,用HEAD命令取代GET命令。可以通过发送HTTP HEAD命令来测量网站的响应时间。服务器响应HEAD命令时只会返回网页的头部,不会返回主体。编写一个名为web_profiler:ping(URL, Timeout)的函数来测量URL这个网站地址的响应速度。它应当返回{time, T}或者timeout。

(2) 制作一个包含大量网站的列表 L ,记录 lists:map(fun(I) -> web_profiler:ping(URL, Timeout) end, L) 所 花 费 的 时 间 。 它 也 许 会 运 行 很 久 , 最 坏 情 况 下 是 Timeout xlength(L)。

(3) 用pmap代替map重复刚才的计时。现在所有的HEAD请求都应当是并行的。因此,最坏情况下的响应时间就是Timeout。

(4) 把结果保存在一个数据库里,然后制作一个Web接口来查询这个数据库。可以从第24章里开发的数据库和Web服务器代码入手。

(5) 如果你用一个超长的元素列表调用pmap,就可能会创建出过多的并行进程。编写一个名为pmap(F, L, Max)的函数来并行计算列表[F(I) || I <- L],但限制它同时最多只能运行Max个并行进程。

(6) 编写一个新版的pmap,让它能工作在分布式Erlang上,把任务分派给多个Erlang节点

(7) 编写一个新版的pmap,让它能工作在分布式Erlang上,把任务分派给多个Erlang节点,并且实现各个节点之间的任务负载均衡。

第27章 福尔摩斯的最后一案