30 分钟学 Erlang (一)

Shawn_xiaoyu

https://www.jianshu.com/p/b45eb9314d1e

本文写给谁看的?

那些已经有过至少一门编程语言基础,并且需要快速了解Erlang,掌握其基本要点,并马上投入工作中的人。
文章挺长,所以分成了几篇。但只要掌握了本文中提到的这些知识点,你就可以上手工作了。剩下的就是在实践中继续学习。
Erlang 读作 ai lan, er lan, er lang 都行,但你别单个字母读 E-R-L-A-N-G,那样我就不跟你玩了。

什么时候用 Erlang?

Erlang 的设计目标非常明确,就是专门为大型的电信系统设计。
所以它的应用场景和设计需求就是电信领域里需要解决的问题。
主要是三个: 高并发高容错软实时。电信系统负载非常大,需要同时服务大量用户的能力;同时不允许出错,电话频繁掉线会很快把客户赶到竞争对手那边;再者,即便某个通话再繁忙也不能影响其他通话的正常进行,到技术层面就是,不能因为某个任务很重,就把其他的任务的资源都占用了,while loop 占用 100% CPU是绝对不允许的。

Erlang 是实用主义的语言,目的是 "get things done",它所有的特性都是为这个目标服务。Erlang 早已经脱离电信行业,飞奔到互联网行业了,因为这些年互联网行业所面临的问题,跟几十年前的电信系统越来越像。如今,Erlang 正在进入物联网行业,它将为世界物联网的发展做出自己的贡献。

开始吧

学习 Erlang 等小众语言的过程中,没有太多中文资料,所以这篇文章里,对于名词、概念类的,还是用英文原词不做翻译。以免造成以后学习的障碍。

安装和使用

安装

  • For Homebrew on OS X: brew install erlang
  • For MacPorts on OS X: port install erlang
  • For Ubuntu and Debian: apt-get install erlang
  • For Fedora: yum install erlang
  • For FreeBSD: pkg install erlang

启动 Erlang Shell

安装完成后,在终端里敲 'erl' 进入 Erlang 的 REPL,erlang shell:

➜  ~ erl
Erlang/OTP 20 [erts-9.3] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]Eshell V9.3  (abort with ^G)
1> io:format("hello world!~n").
hello world!
ok
2> 1 + 1.
2
3> q().
ok
➜  ~

上面 Erlang shell 里:

  • 第一行 io:format("hello world!~n"). 向标准输出写了一行 "hello world!", 并以 "~n" 换行结尾。最后显示的那个 ok 是io:format() 函数的返回值。
  • 第二行 1 + 1. 做了个加法运算,返回值是 2
  • 第三行 q(). 是退出 erlang shell, 是 init:stop(). 的快捷方式. 连续按两次Ctrl - C, 或者 Ctrl - C 之后选 q, 是一样的效果。

上面的两个例子展示了几个要点:

  • Erlang 的每个语句都要用 . 结尾。
  • Erlang 是函数式语言,所有的函数、表达式都必须有一个返回值。输出 "hello world" 会返回一个 atom 类型的 ok
  • Ctrl - C 然后 q or a, 或者 q(). 会退出 shell,如果你运行了代码的话,你的应用程序会连带着一起关掉。所以线上系统千万不要 Ctrl - C

注释

Erlang 里用 % 来做行注释,相当于C语言里的 //, 或者Python里的 #。 没有块注释。

% I am a comment
test_fuc() ->"test".

基本类型

摘取自 learn-you-some-erlang,并为你们这些有经验的程序员删减和加工

Numbers

1> 2 + 15.
17
2> 49 * 100.
4900
3> 1892 - 1472.
420
4> 5 / 2.   %% 最常用的浮点数除法
2.5
5> 5 div 2.  %% div 是整除
2
6> 5 rem 2.  %% rem 是取余运算
1
...
%% 数字前面可以用 ‘#’ 来标注其 ‘Base’
%% 语法:Base#Value
%% 默认的 Base 是 10
...
10> 2#101010.  %% 2 进制的 101010
42
11> 8#0677.  %% 8 进制的 0677
447
12> 16#AE.   %% 16 进制的 AE
174

变量

Erlang 是函数式语言(虽然也支持副作用)。这意味着 Erlang 里的变量 ‘ Immutable’ (不可变的).
Immutable variables 在设计上简单,减少了并发过程中处理状态改变带来的复杂性。理解这一点很重要。

Erlang 是动态类型的语言,但它也是强类型的语言。动态类型意味着你声明变量时不需要指定类型,而强类型是说,erlang 不会偷偷做类型转换:

1> 6 + "1".
** exception error: bad argument in an arithmetic expression
in operator  +/2
called as 6 + "1"

Erlang 里变量的命名有约定,必须首字母大写。因为首字母小写的,会被认为是 atom (原子) 类型。
这一点在 elixir 里有改进

正常的变量命名比如 Hello, Test. 而像 hello, test 这种的不是变量名,他们是 atom 类型,跟数字、字符串一样,是值类型:

1> Hello = "hello?".
"hello?"
2> Test = "testing words".
"testing words"
3> hello.
hello
4> V1 = hello.  %% bind atom hello to V1
hello
5> V1.
hello

Erlang 里没有赋值语句。= 在 Erlang 里是 pattern matching (匹配、模式匹配),如果 = 左侧跟右侧的值不相等,就叫没匹配上,这时那个 erlang 进程会直接异常崩溃(不要害怕,erlang 里面崩溃挺正常的)。如果 = 左侧的变量还没有值,这次匹配过后,右侧的值就会 bind (绑定) 到那个变量上。

1> One.     %% 变量没绑定,不能使用。所以这里出错了。
* 1: variable 'One' is unbound
2> One = 1.   %% 匹配变量 One 与 1. 由于One 之前没有绑定过值,这里将 Number 1 绑定给 One
1
3> Un = Uno = One = 1.
%% 1) 匹配 Un, Uno, One 和 1. One 的值是 1, 所以最右侧的 One = 1 匹配成功,匹配操作返回值是 1.
%% 2) 然后继续与左边的 Uno 匹配。 Uno 之前没有绑定过值,所以将 1 绑定给 Uno,匹配操作返回值也是 1.
%% 3) 同理 Un 也被绑定为 1. 返回值也是 1.
1
4> Two = One + One. %% Two 这时候被绑定为 2.
2
5> Two = 2.    %% 尝试匹配 2 = 2. 成功并返回 2.
2
6> Two = Two + 1.  %% 尝试匹配 2 = 3. 失败了,所以当前的 erlang shell 进程崩溃了,然后又自动给你启动了一个新的 erlang shell。
** exception error: no match of right hand side value 3
7> two = 2.  %% 尝试匹配一个 atom 和一个数字: two = 2. 匹配, 失败崩溃了。
** exception error: no match of right hand side value 2
8> _ = 14+3.  %% 下划线 _ 是个特殊保留字,表示 "ignore",可以匹配任何值。
17
9> _.
* 1: variable '_' is unbound
10> _Ignore = 2.  %% 以下划线开头的变量跟普通的变量作用没有什么区别,只不过在代码中,以下滑线开头的变量告诉编译器,"如果这个变量后面我没用到的话,也不要警告我!"
2
11> _Ignore.
2
12> _Ignore = 3.
** exception error: no match of right hand side value 3

Atoms

上面已经提到过了,Erlang 里面有 atom 类型,atom 类型使用的内存很小,所以常用来做函数的参数和返回值。参加 pattern matching 的时候,运算也非常快速。
在其他没有 atom 的语言里,你可能用过 constant 之类的东西,一个常量需要对应一个数字值或者其他类型的值。比如:

const int red = 1;
const int green = 2;
const int blue = 3;

但多了这个映射,其实用起来不大方便,后面对应的值 1, 2,3 一般只是用来比较,具体是什么值都关系不大。所以有了 atom 就很方便了,我们从字面上就能看出,这个值是干嘛的:

1> red.
red

atom 类型支持的写法:

1> atom.
atom
2> atoms_rule.
atoms_rule
3> atoms_rule@erlang.
atoms_rule@erlang
4> 'Atoms can be cheated!'.  %% 包含空格等特殊字符的 atom 需要用单引号括起来
'Atoms can be cheated!'
5> atom = 'atom'.
atom

需要注意的是:在一个 erlang vm 里,可创建的 atom 的数量是有限制的(默认是 1,048,576 ),因为erlang 虚拟机创建 atom 表也是需要内存的。一旦创建了某个 atom,它就一直存在那里了,不会被垃圾回收。不要在代码里动态的做 string -> atom 的类型转换,这样最终会使你的 erlang atom 爆表。比如在你的接口逻辑处理的部分做 to atom 的转换的话,别人只需要用不一样的参数不停地调用你的接口,就可以攻击你。

Boolean 以及比较

atom 类型的 true 和 false 两个值,被用作布尔处理。

1> true and false.     %% 逻辑 并
false
2> false or true.     %% 逻辑 或
true
3> true xor false.     %% 逻辑 异或
true
4> not false.     %% 逻辑 非
true
5> not (true and true).
false

还有两个与 and 和 or 类似的操作:andalso 和 orelse。区别是 and 和 or 不论左边的运算结果是真还是假,都会执行右边的操作。而 andalso 和 orelse是短路的,意味着右边的运算不一定会执行。

来看一下比较:

6> 5 =:= 5.    %% =:= 是"严格相等"运算符,== "是大概相等"
true
7> 1 =:= 0.
false
8> 1 =/= 0.   %%  =/= 是"严格不等"运算符,/= "是相差很多"
true
9> 5 =:= 5.0.
false
10> 5 == 5.0.
true
11> 5 /= 5.0.
false

一般如果懒得纠结太多,用 =:= 和 =/= 就可以了。

12> 1 < 2.
true
13> 1 < 1.
false
14> 1 >= 1.      %% 大于等于
true
15> 1 =< 1.      %% 注意这个 "小于等于" 的写法,= 在前面。因为 => 还有其他的用处。。
true
17> 0 == false.  %% 数字和 atom 类型是不相等的
false
18> 1 < false.
true

虽然不同的类型之间可以比较,也有个对应的顺序,但一般情况用不到的:
number < atom < reference < fun < port < pid < tuple < list < bit string

Tuples

Tuple 类型是多个不同类型的值组合成的类型。有点类似于 C 语言里的 struct
语法是:{Element1, Element2, ..., ElementN}

1> X = 10, Y = 4.
4
2> Point = {X,Y}.  %% Point 是个 Tuple 类型,包含了两个整形的变量 X 和 Y
{10,4}

实践中,我们经常 在 tuple 的第一个值放一个 atom 类型,来标注这个 tuple 的含义。这种叫做 tagged tuple:

1> Data1 = {point, 1, 2}.
{point,1,2}
2> Data2 = {rectangle, 20, 30}.
{rectangle,20,30}

后面的代码如果要处理 Data1 和 Data2 的话,只需要检查 tuple 的第一项,就知道这个 tuple 是个点坐标,还是个矩形:

3> case Data1 of
3>   {point, X, Y} -> "this is a point";
3>   {rectangle, Length, Width} -> "this is a rectangle"
3> end.
"this is a point"

上面用 case 做 pattern matching ,这个后面还要讲。

List

List 就是我们经常说的链表,数据结构里学的那个。但 List 类型在 Erlang 里使用极其频繁,因为用起来很方便。

List 可以包含各种类型的值:

1> [1, 2, 3, {numbers,[4,5,6]}, 5.34, atom].
[1,2,3,{numbers,[4,5,6]},5.34,atom]

上面这个 list 包含了数字类型 1,2,3,一个 tuple,一个浮点数,一个 atom 类型。

来看看这个:

2> [97, 98, 99].
"abc"

卧槽这什么意思?!因为 Erlang 的 String 类型其实就是 List!所以 erlang shell 自动给你显示出来了。
就是说如果你这么写 "abc", 跟 [97, 98, 99] 是等效的。
链表存储空间还是比纯字符串数组大的,拼接等操作也费时,所以一般如果你想用 '真 · 字符串' 的时候,用 Erlang 的 Binary 类型,这样写:<<"abc">>。这样内存消耗就小很多了。Binary 这是后话了,这篇文章里不介绍。

我知道一开始你可能不大明白 tuple 跟 list 的区别,这样吧:

  • 当你知道你的数据结构有多少项的时候,用 Tuple
  • 当你需要动态长度的数据结构时,用 List

List 处理:

5> [1,2,3] ++ [4,5].       %% ++ 运算符是往左边的那个 List 尾部追加右边的 List。
%% 这样挺耗时的。链表嘛你知道的,往链表尾部追加,需要先遍历这个链表,找到链表的尾部。
%% 所以 "abc" ++ "de" 这种的操作的复杂度,取决于前面 "abc" 的长度。
[1,2,3,4,5]
6> [1,2,3,4,5] -- [1,2,3].  %% -- 是移除操作符。
[4,5]
7> [2,4,2] -- [2,4].
[2]
8> [2,4,2] -- [2,4,2].
[]
9> [] -- [1, 3].   %% 如果左边的 List 里不包含需要移除的值,也没事儿。不要拿这种东西来做面试题,这样会没朋友的。
[]
11> hd([1,2,3,4]).
1
12> tl([1,2,3,4]).
[2,3,4]

上面 hd/1 是取 Head 函数。tl/1 是取 Tail. 这俩都是 BIF (Built-In-Function),就是 Erlang 内置函数.
第一行里你也看到了,List 的追加操作会有性能损耗 (lists:append/2 跟 ++ 是一回事儿),所以我们需要一个从头部插入 List 的操作:

13> List = [2,3,4].
[2,3,4]
14> NewList = [1|List].   %% 注意这个 | 的左边应该放元素,右边应该放 List。
[1,2,3,4]
15> [1, 2 | [0]].   %% 左边元素有好几个的话,erlang 会帮你一个一个的插到头部。先插 2,后插1.
[1,2,0]
16> [1, 2 | 0].     %%  右边放的不是 List,这种叫 'improper list'。
%% 虽然你可以生成这种列表,但不要这么做,代码里出现这种一般就是个 bug。忘了这种用法吧。
[1,2|0]20> [1 | []].       %% List 可以分解为 [ 第一个元素 | 剩下的 List ]。仔细看一下这几行体会一下。
[1]
21> [2 | [1 | []]].
[2,1]
22> [3 | [2 | [1 | []] ] ].
[3,2,1]

List Comprehensions

实践中我们经常会从一个 List 中,取出我们需要的那些元素,然后做处理,最后再将处理过的元素重新构造成一个新的元素。
你马上就想到了 map,reduce。在 Erlang 里,我们可以用 List Comprehensions 语法,很方便的做一些简单的处理。

1> [2*N || N <- [1,2,3,4]].   %% 取出  [1,2,3,4] 中的每个元素,然后乘2,返回值再组成一个新的 List
[2,4,6,8]
2> [X || X <- [1,2,3,4,5,6,7,8,9,10], X rem 2 =:= 0].   %% 取出右边列表里所有偶数。
[2,4,6,8,10]

Anonymous functions

让我们定义一个函数:

Add = fun (A, B) -> A + B end.

上面的代码里,我们用 fun() 定义了一个 匿名函数, 接收两个参数,并将两个参数的和作为返回值。
最后将这个函数 bind 到 Add 变量:

1> Add = fun (A, B) -> A + B end.
#Fun<erl_eval.12.118419387>
2> Add(1, 2).
3

Modules

本章代码在:https://github.com/terry-xiaoyu/learn-erlang-in-30-mins/tree/master/modules

Erlang Shell 是一个快速尝试新想法的地方,但我们真正的代码是要写到文件里,然后参与编译的。

Erlang 里代码是用 Module 组织的。一个 Module 包含了一组功能相近的函数。
用一个函数的时候,要这么调用:Module:Function(arg1, arg2)
或者你先 import 某个 Module 里的函数,然后用省略Module名的方式调用:Function(arg1, arg2)

Module 可也提供代码管理的作用,加载一个 Module 到 Erlang VM就加载了那个 Module 里的所有代码,然后你想热更新代码的话,直接更新这个 Module 就行了。

来看 Erlang 自带的几个 Module:

1> erlang:element(2, {a,b,c}).
b
2> element(2, {a,b,c}).
b
3> lists:seq(1,4).
[1,2,3,4]
4> seq(1,4).
** exception error: undefined shell command seq/2

上面的例子里,你能直接用 erlang Module 里的 element/2 函数,是因为 erlang 里的常用函数会被 潜在的 import 过来。其他的 Module 比如 lists 不会.

erlang module 里的函数叫做 BIF.

使用 Module 写 functions:
建立一个名为 useless.erl 的文件。
在文件的第一行, 用 -module(useless) 来声明你的 module name。注意跟 java 类似,module 名要跟文件名一样。
然后你在你的 module 里写你的函数:

-module(useless).
-export([add/2, add/3]). %% export 是导出语法,指定导出 add/2, add/3 函数。没导出的函数在 Module 外是无法访问的。add(A, B) ->A + B.
add(A, B, C) ->A + B + C.

然后你用 erlc 编译

mkdir -p ./ebin
erlc -o ebin useless.erl

编译后的 beam 文件会在 ebin 目录下,然后你启动 erlang shell:

$ erl -pa ./ebin
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false]Eshell V8.3  (abort with ^G)
1> useless:add(1, 2).
3
2> useless:add(1, 2, 1).
4

erl -pa 参数的意思是 Path Add, 添加目录到 erlang 的 beam 文件查找目录列表里。
就是说,你运行 useless:add(1, 2). 的时候,erlang 发现 module 'useless' 没加载,就在那些查找目录里找 useless.beam,然后加载进来。

Erlang 里面函数是用 函数名/参数个数来表示的,如果两个函数的函数名与参数个数都一样,他们就是一个函数的两个分支,必须写在一起,分支之间用分号分割。
上面的 add(A, B) 可以叫做 add/2, 而 add(A, B, C) 函数叫做 add/3. 注意这个 add/3和 add/2 因为参数个数不一样,所以被认为两个不同的函数,即使他们的函数名是一样的。
所以,第一个函数用 . 结尾。如果是一个函数的多个 clause, 是要用 ; 分割的:

-module(clauses).
-export([add/2]).%% goes into this clause when both A and B are numbers
add(A, B) when is_number(A), is_number(B) ->A + B;
%% goes this clause when both A and B are lists
add(A, B) when is_list(A), is_list(B) ->A ++ B.
%% crashes when no above clauses matched.

上面代码里,定义了一个函数:add/2. 这个函数有两个 clause 分支,一个是计算数字相加的,一个是计算字符串相加的。
代码里 when 是一个 Guard 关键字。Pattern Matching 和 Guard 后面讲解。
运行 add/2 时会从上往下挨个匹配:

$ erl -pa ebin/
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false]Eshell V8.3  (abort with ^G)
1> clauses:add("ABC", "DEF").  %% 第一个 clause 没匹配上。走的是第二个 clause。
"ABCDEF"
2> clauses:add(1, 2).  %% 走第一个 clause
3
3> clauses:add(1, 2.4).
3.4
4> clauses:add(1, "no").  %% 两个 clause 都没匹配上,崩溃了。
** exception error: no function clause matching clauses:add(1,"no") (clauses.erl, line 4)

常用知识点

Pattern Matching

Erlang 里到处都用匹配的。

1. case clauses
下面的代码里,我们定义了一个 greet/2 函数

-module(case_matching).
-export([greet/2]).greet(Gender, Name) ->case Gender ofmale ->io:format("Hello, Mr. ~s!~n", [Name]);female ->io:format("Hello, Mrs. ~s!~n", [Name]);_ ->io:format("Hello, ~s!~n", [Name])end.

case 的各个分支是自上往下依次匹配的,如果 Gender 是 atom 'male', 则走第一个,如果是 'female' 走第二个,如果上面两个都没匹配上,则走第三个。

$ erl -pa ebin/
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false]Eshell V8.3  (abort with ^G)
1> case_matching:greet(male, "Shawn").
Hello, Mr. Shawn!
ok

2. function clauses
我们把上面的例子改一下,让代码更规整一点:

-module(function_matching).
-export([greet/2]).greet(male, Name) ->io:format("Hello, Mr. ~s!~n", [Name]);
greet(female, Name) ->io:format("Hello, Mrs. ~s!~n", [Name]);
greet(_, Name) ->io:format("Hello, ~s!~n", [Name]).

这个 function 有三个 clause,与 case 一样,自上往下依次匹配。

$ erl -pa ebin/
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false]Eshell V8.3  (abort with ^G)
1> function_matching:greet(female, "Scarlett").
Hello, Mrs. Scarlett!
ok
2>

在匹配中获取值

3> {X, 1, 5} = {2, 1, 5}.     %% 如果匹配成功的话,将对应的值 bind 到 X 上。
{2,1,5}
4> X.
2
5> [H | T] = [1, 2, 3].       %% 现在我们使用匹配来解析 List,将第一个元素绑定到 H, 将其余绑定到 T。
[1,2,3]
6> H.
1
7> T.
[2,3]8> [_ | T2] = T.      %% 我可以一直这么做下去
[2,3]
9> T2.
[3]
10> [_ | T3] = T2.    %% 再来
[3]
11> T3.
[]
12> f().              %% Erlang 里面变量是 immutable 的,所以我们现在解绑一下所有变量,清理之前用过的变量名。
ok
13> Add = fun({A, B}) -> A + B end.  %% 我们重新定义了 Add 函数,现在它只接收一个 tuple 参数
%% 然后在参数列表里我们做了 pattern matching 以获取 tuple 中的两个值,解析到 A,B.
#Fun<erl_eval.6.118419387>
14> Add({1, 2}).
3

好了,就问你厉不厉害?

Guards

前面有用过 when, 提到过 guards. 现在我们来认真讨论它:
learn-you-some-erlang 的作者那边 16岁才能"开车" (笑). 那我们写个函数判断一下,某个人能不能开车?

old_enough(0) -> false;
old_enough(1) -> false;
old_enough(2) -> false;
...
old_enough(14) -> false;
old_enough(15) -> false;
old_enough(_) -> true.

上面这个又点太繁琐了,所以我们得另想办法:

old_enough(X) when X >= 16 -> true;
old_enough(_) -> false.

然后作者又说了,超过 104 岁的人,禁止开车:

right_age(X) when X >= 16, X =< 104 ->   %% 注意这里用了逗号,表示 andtrue;
right_age(_) ->false.

when 语句里,,表示 and; 表示 or, 如果你想用短路运算符的话,用 andalso 和orelse, 这么写:

right_age(X) when X >= 16 andalso X =< 104 -> true;

Records

前面讲过 tagged tuple,但它用起来还不够方便,因为没有个名字,也不好访问其中的变量。
我们来定义一个好用点的 tagged tuple,Erlang 里就是record

-module(records).
-export([get_user_name/1,get_user_phone/1]).-record(user, {name,phone
}).get_user_name(#user{name=Name}) ->Name.get_user_phone(#user{phone=Phone}) ->Phone.
$ erl
Erlang/OTP 19 [erts-8.3] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false]Eshell V8.3  (abort with ^G)
1> c(records).   %% 这是编译 erlang 代码的另外一种办法。c/1 编译并加载 module。
{ok,records}
2> rr(records).  %% 将 records module 中的所有 record 都加载到 erl shell 里。
[user]
4> Shawn = #user{name = <<"Shawn">>, phone = <<"18253232321">>}.
#user{name = <<"Shawn">>,phone = <<"18253232321">>}
5> records:get_user_phone(Shawn).
<<"18253232321">>
6> records:get_user_name(Shawn).
<<"Shawn">>%% record 其实就是个 tagged tuple, 第一个元素是 record 名字。
7> records:get_user_name({user, <<"Shawn">>, <<"18253232321">>}).
<<"Shawn">>9> Shawn#user.name.
<<"Shawn">>
10> #user.name.
2

你看到 #user{} 其实只是一个第一个元素为 user 的 tagged tuple {user, name, phone}, 而 #user.name 是这个 tuple 里 name 字段的位置号 2。
注意: Erlang 里面的位置、Index 等都是约定从 1 开始的。

Shawn#user.name 的意思是取 Shawn 里的第 2 个元素。

递归

Erlang 是函数式语言,变量 immutable 的,所以没有 while loop。因为不能让你定义一个递增的 counter 变量。
所以我们用递归来解决大多数问题。
先来一个计算 List 长度的函数:

len([]) -> 0;    %% 空列表的长度是 0
len([_|T]) -> 1 + len(T)   %% 列表的长度,是 1 + 剩余列表的长度。

简单吧?但是你知道的,这样子如果要计算的 List 长度太长的话,调用栈就特别长,会吃尽内存。计算过程是这样的:

len([1,2,3,4]) = len([1 | [2,3,4])= 1 + len([2 | [3,4]])= 1 + 1 + len([3 | [4]])= 1 + 1 + 1 + len([4 | []])= 1 + 1 + 1 + 1 + len([])= 1 + 1 + 1 + 1 + 0= 1 + 1 + 1 + 1= 1 + 1 + 2= 1 + 3 = 4

所以我们必须用 Tail Recursion (尾递归) 来改写一下:

len(L) -> len(L,0).   %% 这其实只是给 len/2 的第二个参数设置了一个默认值 0.len([], Acc) -> Acc;  %% 所有的元素都读完了
len([_|T], Acc) -> len(T,Acc+1).  %% 读一个元素,Acc 增1,然后计算剩下的 List 的长度。

尾递归就是,最后一个语句是调用自身的那种递归。Erlang 遇到这总递归的时候,不会再保留调用栈。这样的递归相当于一个 while loop。

我们用 Acc 来记录每次计算的结果,读取一个元素 Acc 就增 1,一直到读取完所有的元素。

第一个例子里,第二个 clause 的最后一个调用是 1 + len(T) ,这不是尾递归。因为系统还要保留着调用栈,等其算出 len(T) 之后,再回来跟 1 做加法运算。只有 len(T,Acc+1). 这种才是。

尾递归与递归的区别:
有个比喻可以帮你理解他们的差异。
假设玩一个游戏,你需要去收集散落了一路,并通向远方的硬币。

于是你一个一个的捡,一边捡一边往前走,但是你必须往地上撒些纸条做记号,因为不做记号你就忘了回来的路。于是你一路走,一路捡,一路撒纸条。等你捡到最后一个硬币时,你开始沿着记号回来了,一路走,一路捡纸条(保护环境)。等回到出发点时,你把硬币装你包里,把纸条扔进垃圾桶。
这就是非尾递归,纸条就是你的调用栈,是内存记录。

下次再玩这个游戏时,你学聪明了,你直接背着包过去了,一路走,一路捡,一路往包里塞。等到了终点时,最后一个硬币进包了,任务完成了,你不回来了!
这就是尾递归,省去了调用栈的消耗。

30 分钟学 Erlang (二)

https://www.jianshu.com/p/5b7e73576dcb

并发

创建进程

使用 erlang:spawn/1,2,3,4 用来创建一个 erlang 进程。Erlang 进程不是操作系统的进程,而是类似其他语言里“协程”的概念,它由 Erlang 虚拟机调度。本文以后说“进程”,就是指 Erlang 进程。

进程之间是互相独立的,一个进程要想与另外一个进程通信,就必须通过消息传递。消息会被发送到对方进程的信箱存储起来,对方进程可以在合适的时间,按照自定的顺序读取信箱里的消息。

Erlang 里进程非常轻量,启动速度很快,并且可以同时运行千千万万个,默认的进程个数上限是 262144 ,但可以在启动时使用 erl +P 修改这个配置。

1> HelloParallel = fun() -> io:format("hello parallel!~n") end.
#Fun<erl_eval.20.99386804>
2> spawn(HelloParallel).  %% spawn/1 BIF 接受一个函数做为参数。
hello parallel!
<0.63.0>
3> PID = pid(0,63,0).   %% 使用 pid 来生成一个 PID
4> is_pid(PID).  %% 检查是否是 PID 类型
true
5> is_process_alive(PID). %%  检查 Process 是否还活着。显示 false 是因为它已经运行完成终止了。
false

spawn 函数返回一个新进程的 pid,我们可以使用这个 pid 与其交互。

erlang shell 也是有 pid 的。前面说到一个运行时错误会使得当前的shell 进程崩溃,并重新启动一个新的进程,我们验证一下:

1> self().   %% self/1 返回当前进程的 pid
<0.60.0>
2> 1 = 2.
** exception error: no match of right hand side value 2
3> self().
<0.63.0>

消息发送和接收

使用消息发送运算符 ! 发送消息。

4> self() ! "hello".    %% 向自己所在的进程发送一个 List 类型的 "hello". `!` 操作的返回值是消息内容, "hello".
"hello"
5> flush().  %% flush() 将当前 process 的信箱里的所有消息清空并打印。
Shell got "hello"
ok

receive ... end 语句使用 pattern matching 来从自己进程的信箱里读取消息,可以使用 after 语句来设置等待超时时间:

1> self() ! "msg1".
"msg1"
2> self() ! "msg2".
"msg2"
3> self() ! "msg3".
"msg3"
4> receive Msg -> Msg after 3000 -> no_more end. %% 读取任意消息并返回这条消息,如果信箱里没有消息了,等待 3 秒后结束并返回 no_more.
"msg1"
5> receive Msg -> Msg after 3000 -> no_more end.  %% 后面这两条为什么返回 no_more ? 不应该是 "msg2", "msg3" 吗?
no_more
6> receive Msg -> Msg after 3000 -> no_more end.
no_more

上面的第 4 行 receive 语句里,erlang shell 进程查看邮箱,查到第一个消息是 "msg1", Msg 被绑定为 "msg1"。再次运行 receive 语句的时候,由于 Msg 的值已经为 "msg1",与信箱里的 "msg2", "msg3" 都不匹配,所以后面两条 receive 语句都没有从信箱里读取新消息,"msg2" 和 "msg3" 仍然存储在信箱里:

16> flush().
Shell got "msg2"
Shell got "msg3"
ok

注意虽然后面两个 receive 语句都没有从信箱里读取消息,但在 receive 语句的执行过程中,它仍然是从头到尾遍历了整个邮箱,并尝试拿邮箱里的各个消息跟代码里的 Msg 进行匹配,这是消耗资源的,等后面消息堆积越多越麻烦。这个叫 Selective Message Reception. 消息的读取顺序是接收方决定的。

所以一般情况下我们在读取信箱消息时,读到我们不感兴趣的消息也取出来,打个 error log 然后扔掉它,不要让它一直在信箱里耗费资源。

在 Erlang shell 已经伸展不开拳脚了。让我们来写个复杂点的程序:
我们的程序实现一个 消息缓存,具体需求是:

  • 我们需要一个消息栈,用于存储用户发来的消息。
  • 考虑到用户发来的消息可能有很多,我们需要好几个这样的消息栈来分担负载。
  • 我们还想能够给消息栈命名,以便区分。
-module(msg_cache).%% APIs
-export([start_one/1]).%% for spawns
-export([loop/1]).%% 定义进程的 state。
%% 我们一般说,一个服务、或 “对象” 会维护自己内部的 '状态'
%% 状态可能是一个字符串缓存,可能是某个资源的引用,这个跟业务相关。
%% 状态存在于内存中,跟外界隔离,通过 API 接口与外界交互。
%% 面向对象语言里用 类和对象来存储状态,Erlang 里我们用 process。
%% 所以我们又说 Erlang 是 “面向Process 编程的”
-record(state, {name,      %% 消息栈的名字length = 0,  %% 消息栈长度buff = []   %% 消息栈的存储列表}).loop(State = #state{name = Name, length = Len, buff = Buff}) ->receive{get_name, From}->From ! {ok, Name},loop(State);{get_length, From}->From ! {ok, Len},loop(State);{set_name, NewName, From} ->From ! ok,loop(State#state{name = NewName});{push, Msg, From} ->From ! ok,loop(State#state{buff = [Msg | Buff], length = Len + 1});{pop, [], From} ->From ! {error, empty},loop(State);{pop, [TopMsg | Msgs], From} ->From ! {ok, TopMsg},loop(State#state{buff = Msgs, length = Len - 1});_Unsupported ->erlang:error(io_libs:format("unsupported msg: ", [_Unsupported]) )end.start_one(BuffName) ->%% 启动一个消息栈,并返回其 PIDPid = spawn(msg_cache, loop, [#state{name=BuffName}]),io:format("Buff ~s created! Pid = ~p~n", [BuffName, Pid]),Pid

其实除了 loop/1 长一点,其他的都挺容易理解的。
注意 loop/1 里的每个分支的最后一个语句都是尾递归,意味着只要不出错,loop/1 就一直循环下去,所以进程就不会停止。

思考:如果把上面代码里 receive 语句的最后一个 _Unsupported -> 分支删掉的话,会发生什么?

receive 语句里,接受消息时,都要求消息发送方将自己的 Pid 带过来,放到 From 变量里,以便我们回复消息给对方。

我们来试试:

1> PID = msg_cache:start_one("cache2").
Buff cache2 created! Pid = <0.62.0>
<0.62.0>
2> PID ! {get_length, self()}.
{get_length,<0.60.0>}
3> flush().
Shell got {ok,0}
ok4> PID ! {pop, self()}.
{pop,<0.60.0>}
5> flush().
Shell got {error,empty}
ok6> PID ! {push, "msg1", self()}.
{push,"msg1",<0.60.0>}
7> PID ! {push, "msg2", self()}.
{push,"msg2",<0.60.0>}
8> PID ! {push, "msg3", self()}.
{push,"msg3",<0.60.0>}
9> PID ! {get_length, self()}.
{get_length,<0.60.0>}
10> flush().
Shell got ok
Shell got ok
Shell got ok
Shell got {ok,3}
ok11> PID ! {pop, self()}.
{pop,<0.60.0>}
12> flush().
Shell got {ok,"msg3"}
ok13> PID ! {get_length, self()}.
{get_length,<0.60.0>}
14> flush().
Shell got {ok,2}
ok

继续往下阅读之前,仔细看一下这个例子,确保你完全理解了这段代码。

挺厉害的吧?但我们还有两个问题没有解决:

  • 没有一个易用易维护的 API。 PID ! {get_length, self()}. 这种调用方式实在有些反人类。
  • 没有管理进程。我们调用一次 msg_cache:start_one/1 就启动了一个msg_cache, 但是现在我不知道当前已经启动了几个 msg_cache.

我们来解决这第一个问题,重新整理一下代码:

-module(msg_cache).%% APIs
-export([start_one/1,get_name/1,get_length/1,pop/1,set_name/2,push/2]).%% for spawns
-export([loop/1]).-define(API_TIMEOUT, 3000).-record(state, {name,length = 0,buff = []}).start_one(BuffName) ->Pid = spawn(msg_cache, loop, [#state{name=BuffName}]),io:format("Buff ~s created! Pid = ~p~n", [BuffName, Pid]),Pid.%% 加了这几个 API
get_name(CacheID) ->call(CacheID, {get_name, self()}).
get_length(CacheID) ->call(CacheID, {get_length, self()}).
set_name(CacheID, NewName) ->call(CacheID, {set_name, NewName, self()}).
pop(CacheID) ->call(CacheID, {pop, self()}).
push(CacheID, Msg) ->call(CacheID, {push, Msg, self()}).%% 由于发送和接受消息的处理方面,各个 API 都差不多,就提取出来专门写个 call 函数,提高代码复用。
call(Pid, Request) ->Pid ! Request,receiveResponse -> Responseafter ?API_TIMEOUT ->{error, api_timeout}end.%% loop 这一部分我们没改动任何代码
loop(State = #state{name = Name, length = Len, buff = Buff}) ->receive{get_name, From}->From ! {ok, Name},loop(State);{get_length, From}->From ! {ok, Len},loop(State);{set_name, NewName, From} ->From ! ok,loop(State#state{name = NewName});{push, Msg, From} ->From ! ok,loop(State#state{buff = [Msg | Buff], length = Len + 1});{pop, From} ->case Buff of[] ->From ! {error, empty},loop(State);[TopMsg | Msgs] ->From ! {ok, TopMsg},loop(State#state{buff = Msgs, length = Len - 1})end;_Unsupported ->erlang:error(io_libs:format("unsupported msg: ", [_Unsupported]) )end.

再试一下:

1> PID = msg_cache:start_one("cache_worker_1").
Buff cache_worker_1 created! Pid = <0.62.0>
<0.62.0>
2> msg_cache:get_name(PID).
{ok,"cache_worker_1"}
3> msg_cache:get_length(PID).
{ok,0}
4> msg_cache:pop(PID).
{error,empty}
5> msg_cache:push(PID, "msg1").
ok
6> msg_cache:push(PID, "msg2").
ok
7> msg_cache:get_length(PID).
{ok,2}
8> msg_cache:pop(PID).
{ok,"msg2"}
9> msg_cache:pop(PID).
{ok,"msg1"}
10> msg_cache:pop(PID).
{error,empty}
11> msg_cache:get_length(PID).
{ok,0}

还阔以吧?

留个作业

上面那个 "管理进程" 我们没有实现。你来实现它。

我想这么调用:

%% 启动两个 worker:
1> msg_cache:start_cache_workers(["c_worker_1", "c_worker_2"]).
[<0.62.0>, <0.65.0>]%% 列出所有 workers, 返回值是个 worker 列表, 元素展示了每个 worker 的 name, pid, 和 length 。
2> CachePidList = msg_cache:list_cache_workers().
[{"c_worker_1", <0.62.0>, 0}, {"c_worker_2", <0.65.0>, 0}]%% 负载均衡, 会往随机的一个 cache worker 里 push.
%% 注意我这里调用 msg_cache:push 的时候,没有提供某个 cache worker 的 PID
3> ok = msg_cache:push("msg1").
ok
4> ok = msg_cache:push("msg2").
ok
5> CachePidList = msg_cache:list_cache_workers().
[{"c_worker_1", <0.62.0>, 1}, {"c_worker_2", <0.65.0>, 1}]%% 至于 pop 不用管顺序了,有消息就随便 pop 出一个来。
4> msg_cache:pop().
{ok, "msg1"}

提示:

  • erlang:register/2 可以给一个 PID 注册一个名字,以后使用这个 PID 就可以使用这个名字代替。比如
register(msg_cache_manger, Pid).msg_cache:list_cache_workers() ->msg_cache_manger ! get_all_workers.

课后必读文章

Erlang 中的错误处理机制, Link、Monitor:
Errors and Processes

ETS

ETS (Erlang Term Storage) 是设计来存放大量的 Erlang 数据的。跟 ETS 打交道不用消息格式转换,可直接存放 Erlang 数据格式 (erlang 各种数据格式的统称叫做 erlang terms)。
ETS 非常快,访问时间是常数级的,自动帮你解决了多进程访问的各种竞态条件问题,让我们在 Erlang 中做并发编程一身轻松。ETS 是非常优秀的缓存系统,是我们开发中不可或缺的利器之一。这比起用某种流行语言来说,舒服太多[1]。
ETS 只将数据存储在内存里,如果想保存到磁盘,或者要在多个 Erlang Node 之间共享数据,OTP 基于 ETS 和 DETS 实现了 mnesia.
NODE: mnesia 只适合用来做缓存,在多个 Node 之间共享少量数据,非常快速。但是并不适合当做数据库存储大量的数据,因为 mnesia 在启动时会加载所有数据到内存里,导致启动缓慢、新节点加入缓慢。并且 mnesia 是强一致性的数据库,其本身并不处理由于集群脑裂导致的不一致性,这可能不太符合你的预期。

ETS 支持几种数据类型:

  • set: set 是普通的 key - value 存储类型,一个 ETS table 里,两个数据的 key 不能相同。重复插入 key 相同的两条数据,后面的那条会覆盖前面的那条。
  • ordered_set: 有序的 set 表。
  • bag: bag 允许多个 key 相同的数据的存在,但 key, value 都完全相同的数据只能留一个。
  • duplicate_bag: 允许多个 key, value 完全相同的数据的存在。

我们来试试 set 类型的 table,这也是最常用的类型。我们创建一个命名表,叫 users, 然后插入两条数据:

1> ets:new(users, [set, named_table]).
users
2> ets:info(users).   %% 注意默认的权限是 protected
[{read_concurrency,false},{write_concurrency,false},{compressed,false},{memory,304},{owner,<0.57.0>},{heir,none},{name,users},{size,0},{node,nonode@nohost},{named_table,true},{type,set},{keypos,1},{protection,protected}]
3> ets:insert(users, {1, <<"Shawn">>, 27}).
true
4> ets:insert(users, {2, <<"Scarlett">>, 25}).
true
5> ets:lookup(users, 1).
[{1,<<"Shawn">>,27}]
6> ets:lookup(users, 2).
[{2,<<"Scarlett">>,25}]
7> ets:info(users).
[{read_concurrency,false},{write_concurrency,false},{compressed,false},{memory,332},{owner,<0.57.0>},{heir,none},{name,users},{size,2},{node,nonode@nohost},{named_table,true},{type,set},{keypos,1},{protection,protected}]
8>

注意上边的示例里:

  • 创建 ETS table 时给了两个 Options 参数:[set, named_table]。set 是指定创建 set 类型的表,named_table 是创建命名表,命名为 users,后面可以用这个表名来引用。
  • 插入数据 {1, <<"Shawn">>, 27} 和 {2, <<"Scarlett">>, 25} 时,两个 tuple 的第一项就是默认的 key,tuple 里其他项都是 values。如果你想用其他的项作为 key,可以在 ets:new 的时候,指定 {keypos, Pos} 参数,设置 key 在 tuple 中的位置。

ETS 表的其他类型你可以自己试验一下。

需要注意的是:

  • ETS 表里的任何数据都不参加 GC
  • ETS 表有自己的 owner 进程,默认情况下,创建表的那个进程就是 ETS table 的 owner。如果 owner 进程挂了,ETS 表也就被释放了。我们上边的例子里,erlang shell 进程就是 user table 的 owner。
  • ETS 表也是有访问权限的,默认是 protected:
    • public:任何人可以读写这张表。
    • protected: owner 可以读写,但其他进程只能读。
    • private:只有 owner 可以读写。别的进程无法访问。

由于 ETS 表非常高效,一般情况下我们都直接使用 public,然后设置 {read_concurrency, true} 或 {write_concurrency,true} 选项来提高并发读或写的效率,在写一个管理模块来直接访问 ets 表,让什么封装什么设计模式都去 shi。

OTP

OTP 已经失去了字面意思,基本上指的就是 Erlang 生态环境的官方部分。Erlang 世界的组成是这样的:

  • Erlang 以及 Elixir 等语言。
  • 工具和函数库,包括 erlang runtime,kernel,stdlib(像 lists 这种的官方库), sasl, 还有像 ETS,dbg 之类的很多。
  • 系统设计原则, 包括本章要讲的一众 Behaviors。是一堆应用于并发世界的设计模式,他们包含了解决通用问题的通用代码。
  • 开源社区生态环境,包括各种开源软件和社区。

OTP 指的是前三个,Elixir 的话还不大算。

Erlang 的逻辑是,架构的设计应该由有经验的人负责,由专家做好基础代码框架,解决好最困难的问题。而使用者只需要写自己的逻辑代码。这就是 OTP behaviors,他们已经在通信、互联网领域,经历了几十年的战火考验。

本文要讲的有三个:

  • gen_server
  • application
  • supervisor

本章只讲解 gen_server。 application 和 supervisor 放到后面 Hello World 工程里讲解。

gen_server 要解决的问题,就是我们上面那个 msg_cache 面临的问题:怎样做一个服务来响应用户的请求。

我们之前写的代码很短,可以工作,但是很多东西都没有考虑。比如请求者如果同时收到来自服务端的两个 Response 的话,不知道是对应哪个请求的:

%% 服务端:{get_name, From}->From ! {ok, Name},loop(State);{get_length, From}->From ! {ok, Len},loop(State);%% 客户端:ServerPID ! {get_length, self()},   %% 客户端连续调用了两次ServerPID ! {get_length, self()},  receive{ok, Len} ->  %% 你知道这次匹配到的消息,是上面哪次调用的回复吗?success;_ ->failedend.

上面代码里连续调用了两次 {get_length}, 但是由于发送消息是异步的,消息通过网络回来,你并不能确定第一次收到的回复就是第一次调用产生的。

这个问题可以加一个随机生成的 RequestID 的字段来解决,客户端发送请求消息的时候带 RequestID 过去,服务端返回的时候再传回来。客户端通过匹配 RequestID,就能知道当前的回复是对应的哪个请求。

但这种需求其实是通用的,你现在写 msg_cache 用得到,改天写其他代码也一样用得到。另外我们也没有过多考虑异常的情况:如果程序崩溃了怎么办?发送消息怎么知道对方是不是还活着?

诸如此类的问题应该由专家来解决,所以我们有了 gen_server.
gen_server 的模板是这样的:

-module(gen_server_demo).
-behaviour(gen_server).%% API functions
-export([start_link/0]).%% gen_server callbacks
-export([init/1,handle_call/3,handle_cast/2,handle_info/2,terminate/2,code_change/3]).-record(state, {}).
%%%% %%%% %%%% %%%% %%%%
%%%% 这是给客户端调用的接口部分
%%%% %%%% %%%% %%%% %%%%
%% 启动一个服务,后台会启动一个 erlang process, 并进入 loop 函数, 回想一下我们实现 msg_cache 时写的那个 loop/1.
%% 但是这个 loop 函数属于通用部分的代码,是由 OTP 官方实现的,所以代码不在这里,在 OTP 代码的 lib/stdlib/src/gen_server.erl 里。
start_link() ->%% gen_server:start_link 启动 process, 然后将 process 注册在当前%% node 上,注册名字就是当前 Module 名:gen_server_demogen_server:start_link({local, ?MODULE}, ?MODULE, [], []).%%%% %%%% %%%% %%%% %%%%
%%%% 这是 gen_server 发生某事件时的回调函数部分
%%%% %%%% %%%% %%%% %%%%%% gen_server:start_link 被调用,服务启动时,回调 init/1
init([]) ->{ok, #state{}}.%% gen_server:call 被调用。gen_server:call 是“同步”调用,调用方可以设置一个超时时间。
%% 返回值里的 Reply 是返回给调用者的内容。
handle_call(_Request, _From, State) ->Reply = ok,{reply, Reply, State}.%% gen_server:cast 被调用。gen_server:cast 是“异步”调用。
%% 调用者一般是想发一个消息给我们的 gen_server,然后继续做自己的事情,他不想收到来自 gen_server 的回复。
handle_cast(_Msg, State) ->{noreply, State}.%% gen_server 进程收到一个普通 erlang 消息:一个不是通过 gen_server:call 和 gen_server:cast 发来的消息。
handle_info(_Info, State) ->{noreply, State}.%% 上面的三个函数 handle_call, handle_cast, handle_info
%%   都可以返回一个 {stop, Reason, State},这样的话 gen_server 会退出。
%%   但退出之前,可能会回调 terminate(_Reason, _State)。%% gen_server 将要退出时,回调 terminate/2.
%% 注意
%% 1) 要想 terminate 在 gen_server 退出前被回调,gen_server 必须捕获退出信号:
%%    需要在 init 回调里,加这么一行:process_flag(trap_exit, true).
%% 2) 有几个特定的 Reason 被认为是正常退出:normal, shutdown, or {shutdown,Term},
%%    其他的 Reason,sasl 是会报错打日志的。
terminate(_Reason, _State) ->ok.code_change(_OldVsn, State, _Extra) ->{ok, State}.

gen_server 真正的进程代码在 OTP 库里,运行 start_link(),gen_server 就在后台跑起来了。你需要实现的只是这个模板里的各个回调函数,将你的业务逻辑放到这些回调里。

仔细看一下上面的 gen_server 模板和注释,确保你能完全理解。

我不想重新实现之前的 msg_cache,一点都不酷。我们重新写个其他的,让你对 Erlang 程序的基本设计理念有更深的印象。

我们要实现一个多用户聊天的程序:

  • 用户能够查询在线的其他用户。
  • 用户之间能够聊天。
  • 要容易扩展,因为后面我们的 Client 会通过TCP、WebSocket 等连接上来,不会是 Erlang 写的 Client。
  • 要容易伸缩,因为我们业务发展很快,用户量会越来越大,我们希望程序能很容易的部署在多台服务器上。

先来设计我们程序的架构:

chat_server-2.png

  • 每个 client 连接上来,都会启动一个新的 Process,叫做 ChatServer.
  • ChatServer 负责维护这个 Client 的 TCP 连接。
  • Route 是一个Module,它提供了数据库的管理,数据库里维护了从 User 到其 ChatServer 的 PID 的映射关系。

注意我们的设计思想:

  • 为每一个连接上来的请求启动一个 Erlang 进程 "ChatServer",不要担心进程个数,百万也没问题。
  • 两个用户之间的消息传递,体现在服务端就是两个 "ChatServer" 之间的 Erlang 消息传递。
  • Route 部分只是一个 Module,不是进程。每一个 ChatServer 调用 Route 里的代码的时候,执行过程其实是在每个 ChatServer 进程内部的。这样我们就避免了集中向一个进程发送消息带来的瓶颈。我们把这种瓶颈的处理留给了 ETS 来解决。
  • 如何伸缩?ChatServer 在不在同一个服务器上没什么关系。ChatServerPID !{send, Msg} 会将消息发送到ChatServerPID,即使 ChatServerPID 在远端的服务器上。分布式部署的时候,这行代码根本不用改,你要做的仅仅是添加一个新的 Erlang Node。分布式 Erlang 后面还要讲。
  • 如何扩展?ETS 使用 Route Module 管理,为的就是当以后换用其他的缓存数据库的时候简单一些。我们设想后面为了做分布式集群,要用 mnesia 替代 ETS,只需要写一个新的 Route Module,内部改用 mnesia 存储,然后替换线上已经加载的老的 Route Module。线上系统都不用停止,客户端的连接一个都不会断!

你现在能否体会到 Erlang 的实用主义呢?完全没废话,就是解决问题!

Client 部分我们现在不做,让前端的同学帮我们实现。但假设我们的前端程序员还没到岗,所以我们可以先放着 WebSocket 部分后面再做。但有两个过程必须现在实现:

  • 当 Client 登录时,我们需要使用 Route 注册 user 所在的 ChatServer 的 PID。
  • 当 Client 发消息时,我们需要使用 Route 查找对方的 ChatServer 的 PID。

首先我们来定义我们的消息协议。我们的消息体内包含几部分,发送者ID,接收者ID,以及消息内容:

-record(msg, {from_userid,to_userid,payload
}).

接下来让我们来实现 Route 模块,实现数据库创建,注册,查找与注销功能:

-module(route).
-export([ensure_db/0,lookup_server/1,register_server/2,unregister_server/1]).ensure_db() ->case ets:info(servers) ofundefined ->%% 为了演示方便,我们启动一个临时进程来创建 ETS 表,%% 如果直接在 erlang shell 里创建ETS的话,出错时 shell 的崩溃连带着我们的ETS也丢了。%% 当然线上系统不会这么做。spawn(fun() -> ets:new(servers, [named_table, public]), receive after infinity->ok end end);_ -> okend.lookup_server(UserID) ->case ets:lookup(servers, UserID) of[{UserID, ServerID}] -> {ok, ServerID};_ -> {error, no_server}end.register_server(UserID, ServerID) ->ets:insert(servers, {UserID, ServerID}).unregister_server(UserID) ->ets:delete(servers, UserID).

接下来实现我们的 ChatServer:

-module(chat_server).
-behaviour(gen_server).
%% state 保存用户的 userid,以及 client 端连上来的 socket.
-record(state, {userid,socket
}).%% 后面当一个新连接连接到服务器的时候,我们会调用 start_link 启动一个新的 ChatServer 为之服务。
%% 注意这里使用的是 gen_server:start_link/3,没有注册进程名,我们直接使用 PID. 因为我们要启动很多个 ChatServer。
start_link(UserID, Socket) ->{ok, ServerID}  = gen_server:start_link(?MODULE, [UserID, Socket], []),ServerID.%% 在 init 回调里注册用户的 ChatServer。
%% 注意我们捕获了 exit signal, 以便程序退出的时候回调 terminate/2.
%% 我们在 terminate/2 里取消注册。
init([UserID, Socket]) ->process_flag(trap_exit, true),route:register_server(UserID, self()),{ok, #state{userid=UserID, socket=Socket}}.%% 如果我们的 ChatServer 收到一条来自 Socket 的消息,它会收到一条类似 {tcp, Sock, Data} 的普通消息。
%% 我们需要在 handle_info 里处理,转发给对方的 ChatServer。
handle_info({tcp, #msg{to_userid = ToUserID, payload = Payload} = Msg}, State) ->io:format("Chat Server(User: ~p) - received msg from tcp client, Msg: ~p~n",[State#state.userid, Msg]),case route:lookup_server(ToUserID) of{error, Reason} ->io:format("Chat Server(User: ~p) - cannot forward to Chat Server(User: ~p): ~p~n",[State#state.userid, ToUserID, Reason]);{ok, TargetServerID} ->io:format("Chat Server(User: ~p) - forward msg to Chat Server(User: ~p), Payload: ~p~n",[State#state.userid, ToUserID, Payload]),ok = gen_server:call(TargetServerID, {send, Msg})end,{noreply, State};%% 我们的 ChatServer 收到一条来自对端 ChatServer 的转发请求
handle_call({send, #msg{payload = Payload}}, _From, State) ->io:format("Chat Server(User: ~p) - deliver msg to tcp client, Payload: ~p~n",[State#state.userid, Payload]),send_to_client_via_tcp(State#state.socket, Payload),{reply, ok, State};%% Socket 部分我们没有实现,暂时就简单打印一下
send_to_client_via_tcp(_Socket, Payload) ->%gen_tcp:send(_Socket, Payload),io:format("Sent To Client: ~p~n",[Payload]).

完工了!我们测试一下:

1> c(chat_server).
{ok,chat_server}
2> c(route).
{ok,route}%% 现在模拟系统启动时,初始化 DB 的过程。
%% 后续这个会在启动代码里写。
3> route:ensure_db().
<0.73.0>%% 现在我们模拟一个新的用户登录上来,启动新的 ChatServer 的过程。
%% 后续这个过程当然是由 WebSocket 模块调用。
4> ServerIDUser1 = chat_server:start_link(<<"user1">>, fake_socket).
<0.75.0>
5> ServerIDUser2 = chat_server:start_link(<<"user2">>, fake_socket).
<0.77.0>%% 我们来做一个 #msg{} 消息体。
%% 后续我们应该在收到 socket 上来的消息解析成功之后,打包一个 #msg{} 消息体。
6> rr("chat_protocol.hrl").
[msg]
7> Msg = #msg{from_userid= <<"user1">>, to_userid = <<"user2">>, payload = <<"hello?">>}.
#msg{from_userid = <<"user1">>,to_userid = <<"user2">>,payload = <<"hello?">>}%% 模拟从 socket 收到消息的过程。
8> ServerIDUser1 ! {tcp, Msg}.
Chat Server(User: <<"user1">>) - received msg from tcp client, Msg: {msg,<<"user1">>,<<"user2">>,<<"hello?">>}
{tcp,#msg{from_userid = <<"user1">>,to_userid = <<"user2">>,payload = <<"hello?">>}}
Chat Server(User: <<"user1">>) - forward msg to Chat Server(User: <<"user2">>), Payload: <<"hello?">>
Chat Server(User: <<"user2">>) - deliver msg to tcp client, Payload: <<"hello?">>
Sent To Client: <<"hello?">>
9>

我们看到服务端的路由已经走通了,接下来只要写一个 web socket 模块,listen 在某个端口,当有连接请求时,像上面第 4、第 5 行一样调用 chat_server:start_link/2 就行了。当然 send_to_client_via_tcp 也要改为真正往 socket 发送消息。

完整代码:
https://github.com/terry-xiaoyu/learn-erlang-in-30-mins/tree/master/chat

一个完整的线上演示:
(即将上线)

30 分钟学 Erlang (三)

https://www.jianshu.com/p/bbaf695ec167

分布式 Erlang

Erlang 自带分布式功能,并且 Erlang 语言的消息发送也完全适应分布式环境。
我们称一个 Erlang VM 是一个 Erlang Node。所以每次用 erl 命令启动一个 erlang shell,就是启动了一个 Node.

我们有两种办法连接两个 Node。第一种是显式的调用 net_kernel:connect_node/1,第二种是在使用 RPC 调用一个远程的方法的时候,自动加入集群。

来试一下,先启动第一个 node,命名为 'node1', 绑定在 127.0.0.1 上。并设置 erlang distribution cookie:

$ erl -name node1@127.0.0.1 -setcookie 'dist-cookie'
(node1@127.0.0.1)1>

cookie 是用来保护分布式系统安全的,只有设置了相同 cookie 的 node 才能建立分布式连接。

我们在另外一个终端里,再启动一个新的 node2:

$ erl -name node2@127.0.0.1 -setcookie 'dist-cookie'
(node2@127.0.0.1)1> nodes().
[]
(node2@127.0.0.1)2> net_kernel:connect_node('node1@127.0.0.1').
true
(node2@127.0.0.1)3> nodes().
['node1@127.0.0.1']
(node2@127.0.0.1)4>

erlang:nodes/0 用来显示与当前建立了分布式连接的那些 nodes。

再启动一个新的 node:

$ erl -name node3@127.0.0.1 -setcookie 'dist-cookie'
(node3@127.0.0.1)1> net_adm:ping('node1@127.0.0.1').
pong
(node3@127.0.0.1)2> nodes().
['node1@127.0.0.1','node2@127.0.0.1']
(node3@127.0.0.1)3>

这次我们仅仅 ping 了一下 node1, 就已经建立了 node1, node2, node3 所有 3 台 node 组成的集群。

前面我们有提到过,发送消息语句完全适应分布式环境,我们来试试:
在 node2 里查看一下当前 erlang shell 的 PID:

(node2@127.0.0.1)4> self().
<0.63.0>

在 node3 里,我们查看一下这个 <0.63.0> 对应到本地的 PID 系统是怎么表示的:

(node3@127.0.0.1)7> ShellNode2 = rpc:call('node2@127.0.0.1', erlang, list_to_pid, ["<0.63.0>"]).
<7525.63.0>%% 然后我们给它发个消息:
(node3@127.0.0.1)8> ShellNode2 ! "hi, I'm node3".
"hi, I'm node3"

在 node2 里,我们就会收到这条消息:

(node2@127.0.0.1)5> flush().
Shell got "hi, I'm node3"
ok

看到了吧,只要我们知道一个 PID,不论他是在本地 node 还是在远端,我们都能用 ! 发送消息,语义完全一样。
所以前面的聊天程序里,我们只需要把 PID 存到 mnesia,让它在各个 node 之间共享,就可以实现从单节点到分布式的无缝迁移。

分布式 Erlang 怎么工作的?

启动 erlang 的时候,系统会确保一个 epmd (erlang port mapping daemon) 已经起来了。

$ lsof -i -n -P | grep TCP | grep epmd
epmd      22871 liuxinyu    3u  IPv4 0x1b13d7ce066b8f6d      0t0  TCP *:4369 (LISTEN)
epmd      22871 liuxinyu    4u  IPv6 0x1b13d7ce04d5741d      0t0  TCP *:4369 (LISTEN)
epmd      22871 liuxinyu    5u  IPv4 0x1b13d7ce0830f865      0t0  TCP 127.0.0.1:4369->127.0.0.1:59719 (ESTABLISHED)
epmd      22871 liuxinyu    6u  IPv4 0x1b13d7ce055ded7d      0t0  TCP 127.0.0.1:4369->127.0.0.1:52371 (ESTABLISHED)
epmd      22871 liuxinyu    7u  IPv4 0x1b13d7ce10169295      0t0  TCP 127.0.0.1:4369->127.0.0.1:52381 (ESTABLISHED)
epmd      22871 liuxinyu    9u  IPv4 0x1b13d7ce12755d7d      0t0  TCP 127.0.0.1:4369->127.0.0.1:53066 (ESTABLISHED)

epmd 监听在系统的 4369 端口,并记录了本地所有 erlang node 开放的分布式端口。

来看一下 node1 使用的端口情况:

$ lsof -i -n -P | grep TCP | grep beam
beam.smp  47263 liuxinyu   25u  IPv4 0x1b13d7ce10713b8d      0t0  TCP *:52370 (LISTEN)
beam.smp  47263 liuxinyu   26u  IPv4 0x1b13d7ce10713295      0t0  TCP 127.0.0.1:52371->127.0.0.1:4369 (ESTABLISHED)
beam.smp  47263 liuxinyu   27u  IPv4 0x1b13d7ce12754295      0t0  TCP 127.0.0.1:52370->127.0.0.1:52405 (ESTABLISHED)
beam.smp  47263 liuxinyu   28u  IPv4 0x1b13d7ce12844295      0t0  TCP 127.0.0.1:52370->127.0.0.1:53312 (ESTABLISHED)

epmd 工作的原理是:

  • node1 监听在 52370 端口。
  • 当 node2 尝试连接 node1@127.0.0.1 的时候,node2 首先去 127.0.0.1 机器上的 empd 请求一下,获得 node1 监听的端口号:52370。
  • 然后 node2 使用一个临时端口号 52405 作为 client 端,与 node1 的 52370 建立了 TCP 连接。

Hello World

我们 Hello World 程序的教学目的是,熟悉如何创建一个可以上线的项目。
让我们用 erlang.mk 创建一个真正的 hello world 工程。很多项目是用 rebar 的,到时候自己学吧。

OTP 工程的基本框架

屏幕快照 2018-03-21 下午4.12.36.png

  • 一个项目可以包含很多个 Application, 每个 application 包含了本应用的所有代码,可以随时加载和关闭。
  • 一个 Application 一般会包含一个顶层 Supervisor 进程,这个顶层 Supervisor 下面管理了许多 sub Supervisor 和 worker 进程。
  • Supervisor 是用来监控 worker 的, 我们的业务逻辑都在 worker 里面,supervisor 里可以定制重启策略,如果返现某个 worker 挂掉了,我们可以按照既定的策略重启它。
  • 这个框架叫做 Supervision Tree.

Supervisor 可用的重启策略:

  • one_for_all:如果一个子进程挂了,重启所有的子进程
  • one_for_one:如果一个子进程挂了,只重启那一个子进程
  • rest_for_one:如果一个子进程挂了,只重启那个子进程,以及排在那个子进程后面的所有子进程 (一个 supervisor 会按顺序启动很多子进程,排在一个子进程后面的叫 rest)。
  • simple_one_for_one:当你想要动态的启动一个进程的多个实例时,用这个策略。比如来一个 socket 连接我们就启动一个 handler 进程,就适用于这种。

我们在后面的实例中理解这些概念。

创建工程

官方示例

我们首先创建一个 hello_world 目录,然后在里面建立工程的基本框架:

$ mkdir hello_world && cd hello_world
$ curl -O https://erlang.mk/erlang.mk$ make -f erlang.mk bootstrap SP=2
$ make -f erlang.mk bootstrap-rel
$ l
total 480
drwxr-xr-x  7 liuxinyu  staff   238B  3 21 15:21 .
drwxr-xr-x  9 liuxinyu  staff   306B  3 21 15:04 ..
-rw-r--r--  1 liuxinyu  staff   167B  3 21 15:21 Makefile
-rw-r--r--  1 liuxinyu  staff   229K  3 21 15:14 erlang.mk
drwxr-xr-x  4 liuxinyu  staff   136B  3 21 15:14 rel
-rw-r--r--  1 liuxinyu  staff   164B  3 21 15:14 relx.config
drwxr-xr-x  4 liuxinyu  staff   136B  3 21 15:21 src
$ l rel/
total 16
drwxr-xr-x  4 liuxinyu  staff   136B  3 21 15:14 .
drwxr-xr-x  7 liuxinyu  staff   238B  3 21 15:21 ..
-rw-r--r--  1 liuxinyu  staff     5B  3 21 15:14 sys.config
-rw-r--r--  1 liuxinyu  staff    58B  3 21 15:14 vm.args

然后我们创建一个 hello_world.erl, 模板是 gen_server :

$ make new t=gen_server n=hello_world SP=2
$ l src
total 24
drwxr-xr-x  5 liuxinyu  staff   170B  3 21 19:01 .
drwxr-xr-x  8 liuxinyu  staff   272B  3 21 18:59 ..
-rw-r--r--  1 liuxinyu  staff   673B  3 21 19:01 hello_world.erl
-rw-r--r--  1 liuxinyu  staff   170B  3 21 18:59 hello_world_app.erl
-rw-r--r--  1 liuxinyu  staff   233B  3 21 18:59 hello_world_sup.erl

以上我们生成的文件里,文件命名有一些约定。与工程名同名的文件 hello_world.erl 里是我们的 worker,gen_server 的模板文件,是工程的入口文件。_app 后缀的是 application behavior, _sup结尾的是 supervisor behavior.

hello_world_app.erl 里面,start/2 函数启动的时候,启动了整个应用的顶层 supervisor,hello_world_sup:

start(_Type, _Args) ->hello_world_sup:start_link().

hello_world_sup.erl 里面,调用 supervisor:start_link/3 之后,supervisor 会回调 init/1。我们需要在 init/1 中做一些初始化参数的设置:

init([]) ->%% 重启策略是 one_for_one%% 重启频率是5 秒内最多重启1次,如果超过这个频率就不再重启SupFlags = #{strategy => one_for_one, intensity => 1, period => 5},%% 只启动一个子进程,类型是 workerProcs = [#{id => hello_world,   %%  给子进程设置一个名字,supervisor 用这个名字标识这个进程。start => {hello_world, start_link, []}, %% 启动时调用的 Module:Function(Args)restart => permanent,  %% 永远需要重启shutdown => brutal_kill, %% 关闭时不需要等待,直接强行杀死进程type => worker,modules => [cg3]}],  %% 使用的 Modules{ok, {SupFlags, Procs}}.

在 hello_world.erl 里的 init/1 里添加一个 timer

init([]) ->timer:send_interval(10000, {interval, 3}), %% 每隔 10 秒发一个 {interval, 3} 给自己进程{ok, #state{}}.

最后 make run 看看效果。可以看到每次崩溃都会被 supervisor 重启:

$ make run
(hello_world@127.0.0.1)1> hello_world(<0.228.0>): doing something bad now...
=ERROR REPORT==== 21-Mar-2018::19:44:35 ===
** Generic server <0.228.0> terminating
** Last message in was {interval,3}...hello_world(<0.247.0>): doing something bad now...
=ERROR REPORT==== 21-Mar-2018::19:44:58 ===
** Generic server <0.247.0> terminating
** Last message in was {interval,3}

然后添加一个 timer 的回调函数,回调函数里故意写了一行让程序崩溃的代码

handle_info({interval, Num}, State) ->io:format("~p(~p): doing something bad now...~n", [?MODULE, self()]),1 = Num,{noreply, State};

完整代码:
https://github.com/terry-xiaoyu/learn-erlang-in-30-mins/tree/master/hello_world

然后呢?

以上你已经学会了基本的 Erlang 常用技能,可以投入工作了。
当你使用 Erlang 有了一段时间,想要系统学习和掌握它的时候,看下面的资料:

  • Learn You Some Erlang for Great Good:Fred 老师擅长讲故事,灵魂画风。可能是多数Erlang从业者的第一位老师。
  • Erlang and OTP In Action: 快速教会你在生产环境中,怎么使用 Erlang。
  • Designing for Scalability with Erlang/OTP: 作者以其丰富的从业经验,告诉你如何使用 Erlang 设计可伸缩的系统。
  • Erldocs 平常你需要查文档的。

30 分钟学 Erlang相关推荐

  1. 30分钟学玩转RabbitMQ

    转载自 http://www.cnblogs.com/learnhow/p/8362289.html 最近在学习RabbitMQ,在网上找了不少资料发现都特高端.动辄集群部署,分布式架构什么的,对于一 ...

  2. 每天30分钟学python-这个工具,30分钟可以把科研人一天的工作都给干完!

    原标题:这个工具,30分钟可以把科研人一天的工作都给干完! 一入科研深似海,每逢返校倍忧桑. 被 paper 和发际线上移支配的恐惧要回来了--一个假发片还够用吗? 1 文献看到眼花,科研热点总是无缘 ...

  3. 【30分钟学完】canvas动画|游戏基础(1):理论先行

    前言 本文虽说是基础教程,但这是相对动画/游戏领域来说,在前端领域算是中级教程了,不适合前端小白或萌新.阅读前请确保自己对前端三大件(JavaScript+CSS+HTML)的基础已经十分熟悉,而且有 ...

  4. 【30分钟学完】canvas动画|游戏基础(7):动量守恒与多物体碰撞

    前言 一路沿着本系列教程学习的朋友可能会发现,前面教程中都尽量避免提及质量的概念,很多运动概念也时刻提醒大家这不是真实的物体运动.因为真实的物体运动其实跟质量都是密不可分的,而且质量的引入自然必须提及 ...

  5. 【30分钟学完】canvas动画|游戏基础(4):边界与碰撞

    前言 本系列前几篇中常出现物体跑到画布外的情况,本篇就是为了解决这个问题. 阅读本篇前请先打好前面的基础. 本人能力有限,欢迎牛人共同讨论,批评指正. 越界检测 假定物体是个圆形,如图其圆心坐标即是物 ...

  6. 【30分钟学完】canvas动画|游戏基础(5):重力加速度与模拟摩擦力

    前言 解决运动和碰撞问题后,我们为了让运动环境更加自然,需要加入一些环境因子,比如常见的重力加速度和模拟摩擦力. 阅读本篇前请先打好前面的基础. 本人能力有限,欢迎牛人共同讨论,批评指正. 重力加速度 ...

  7. Pandownload 下线了,我花了 30 分钟自己搭建了一个网盘

    点击上方视学算法,选择设为星标 加个"星标★",每日好文必达! 文末包邮送3本技术书! 来源:码匠笔记 阅读文本大概需要 5 分钟 点击「阅读原文」查看pk哥原创精品视频. Pan ...

  8. 30分钟搞定ES6常用基础知识

    ES6基础智商划重点 在实际开发中,ES6已经非常普及了.掌握ES6的知识变成了一种必须.尽管我们在使用时仍然需要经过babel编译. ES6彻底改变了前端的编码风格,可以说对于前端的影响非常巨大.值 ...

  9. 停车30分钟内不收费,程序员远程操控挪车,实现自动免费停车...

    还记得前几天关于野生钢铁侠如何创造 自动驾驶自行车 的故事吗? 掌握核心科技的程序员们,又开始开脑洞啦!最近又有一个北京的开发者开了 这个脑洞的背景: 北京道路停车的自动收费,停车不足30分钟不计费 ...

最新文章

  1. C#窗体控件-组合框控件ComboBox
  2. 更新Android Studio 3.1.1碰到的问题
  3. 56秒看完131年英格兰顶级联赛冠军排行:利物浦时隔30年再夺冠
  4. Python3 协程 + 正则 批量爬取斗鱼美女图片
  5. eks volumn s3_云顶棋弈研习社 游玩S3赛季 PBE注册、下载与汉化教程
  6. 手机号正则_一起刷题学习正则表达式
  7. php 解压有密码的zip文件_文件解压引发的getshell
  8. Word表格高度不能调小
  9. SQL Server 日期和时间函数
  10. JQuery Mobile - 固定住页面和页脚
  11. java 微博 屏蔽_最新JAVA调用新浪微博API之发微博(转)
  12. (论文加代码)基于deap数据集的脑电情绪识别(二分类改为八分类)
  13. 荣耀畅玩5a android5.0,华为荣耀畅玩5A有几个版本?华为荣耀5A各版本区别对比介绍...
  14. 【iOS】指纹(面容)支付基本逻辑和适配
  15. STM32+光敏模块
  16. 沈剑架构师训练营,最新金九银十JAVA面试合集
  17. 交叉编译folly库
  18. PMI-ACP敏捷项目管理辅导:敏捷开发之 4句敏捷宣言
  19. 用行列式展开计算n阶行列式【c++/递归】
  20. 乐清高考2021成绩查询,2021年乐清高考状元名单公布,乐清文理科状元是谁多少分...

热门文章

  1. 苹果id可以同时用两个手机吗_科技V报余承东:鸿蒙OS随时可用于手机;疑似魅族16s Pro通过3C认证20190809...
  2. 洛谷——P1420 最长连号
  3. PCL之平面分割模型
  4. 安卓3d游戏开发引擎_鲁大师安卓3D引擎更新,跑分测试精准度再升级
  5. python中函数startswith的用法_Python中的startswith和endswith函数使用实例
  6. 在python中查看关键字、需要执行,如何在一个文本文件,二进制执行搜索来搜索一个Python关键字?...
  7. python保留7天备份文件
  8. json,pickle,shelve序列化和反序列化
  9. 【Nodejs六】关于mongodb那些事
  10. Xamarin 技术全解析