erlang 杂项

1.匿名func曾经很慢
不过这都成为历史,现在func的花费在local func和apply之间。

2.list的comprehensions 慢
由func实现,由于之前func很慢,so....,不过现在改成了递归实现,所以。。。
不过尾递归+reverse还是会快一点的。

3.尾递归比普通递归要快
普通递归函数除了不断拷贝堆栈垃圾之外还要不断gc,so...
而尾递归不同,由头至尾重复使用。。
不过在erlang的后续版本,对此进行了优化,防止了stack的不断拷贝,以至于两者使用相同的mem,
那尾递归是否快于普通递归?用big list做了测试
x86中 快了30%
unix中 普通递归稍微快了小小

但是有一点可以很清楚的就是,如果不用在尾递归最后调用reverse,那可以保证,尾递归肯定比普通
递归要快。


4.'++'运算符
它有一个不好的名声,但是也要基于coder怎么来用,比如
naive_reverse([H|T]) ->
    naive_reverse(T)++[H];
naive_reverse([]) ->
    [].
这样做是非常低效的,导致了列表的不断不断的复制,导致了二次复杂性。

然而
naive_but_ok_reverse([H|T], Acc) ->
    naive_but_ok_reverse(T, [H]++Acc);
naive_but_ok_reverse([], Acc) ->
    Acc.
并不坏,acc作为++的操作符,只复制一次,但是最好的写法应该是
vanilla_reverse([H|T], Acc) ->
    vanilla_reverse(T, [H|Acc]);
vanilla_reverse([], Acc) ->
    Acc.


5.string模块非常慢
string模块使用的不当的话,会非常慢。另外最好使用re模块来代替regxp模块


6.dets的repair非常慢
根据record不同不而不同,虽然改进了,但是还是慢

7.beam是基于堆栈的字节流虚拟机运行
因此,比较慢,erts有1024个reg。每个指令字会转成对应的c 代码来执行,所以调度很快。
堆栈用于存放临时变量&&func call的时候参数

8.'_'变量
如果不适用的变量,可以使用'_'来提升你程序的运行速度。不过现在的版本,已经不再这样。


9.尽量少使用timer模块
使用erlang的timer函数来代替,因为timer模块使用了一个进程来管理,导致当并发大的时候,总会
出现一些异常的情况

10.list_to_atom
erlang当中atom并不回收的,而atom的默认最大值是1048576。若是不注意,将会导致atom的损耗尽,
特别是基于拒绝服务攻击的时候,这个时候可以用list_to_existing_atom来调整
另外,使用的时候最好别这样使用
apply(list_to_atom("some_prefix"++Var), foo, Args) 
这是非常低效率的


11.lenth
length是bif,所以它的效率毋庸置疑的,不过有些时候为了代码外观可以这样做
foo(L) when length(L) >= 3 ->
...

改成
foo([_,_,_|_]=L) ->
...


12. setelement函数
每次修改都会copy一份,所以如果要修改一份大的tuple的时候,最好就将tuple转成一个list
然后去操作list,再转成相应的tuple即可。


13.size函数
size函数可以返回binary&tuple的大小,不过最好使用对应的bif ,tuple_size & byte_szie,
因为这样有助于解析器帮你发现bug,以及编译器的优化。

14.split_binary函数
最好直接匹配实现,少用此函数,太过昂贵。
<<Bin1:Num/binary,Bin2/binary>> = Bin,
而不是这样:
{Bin1,Bin2} = split_binary(Bin, Num)

15.'--'操作符
比如
HugeList1 -- HugeList2
非常缓慢,最好的代替方法:
HugeSet1 = ordsets:from_list(HugeList1),
HugeSet2 = ordsets:from_list(HugeList2),
ordsets:subtract(HugeSet1, HugeSet2)

不过基于ord的话可能会导致原来的顺序打乱,so...
Set = gb_sets:from_list(HugeList2),
[E || E <- HugeList1, not gb_sets:is_element(E, Set)]

当然,你使用'--'操作符删除列表的一个元素,并不会有什么性能问题
HugeList1 -- [Element]


16.大话binary的construction 和match
在r12b以后的版本,构造和匹配binary都做的优化,比之前的版本快了很多
比如
my_list_to_binary(List) ->
    my_list_to_binary(List, <<>>).

my_list_to_binary([H|T], Acc) ->
    my_list_to_binary(T, <<Acc/binary,H>>);
my_list_to_binary([], Acc) ->
    Acc
要是以前的版本,acc将在每次迭代的时候复制一份,但是在r12b版本,acc将只复制一份,而且
会另外一份额外的mem,因此在next迭代的时候H就会写到对应的额外mem,如果mem不够用的时候,
binary将会从小分配一份大的mem。
因此现在的binary的匹配也是很快的:
my_binary_to_list(<<H,T/binary>>) ->
    [H|my_binary_to_list(T)];
my_binary_to_list(<<>>) -> [].

17.binary
binary和bitstring都是使用相同的方式实现的,erts内部可以统称为binarys。
有4种binary,其中两种是容器,另外两种是binary data 的部分引用(块引用)。

前者两种可以称为:引用计数binary(refc-binary) && 堆binary(heap binary)

refc-binary有两部分组成
  a.procbin--存放在进程堆
  b.存储在外部的binary object
后者采用了引用计数,具体如何实现,想必都很清楚了,每个procbin使用binary object时,都会使
计数+1,反之则-1,到refc==0的时候,就会被gc。

Heap binaries 是小的二进制文件,最多64个字节,直接存储在该进程的堆。proc死前回收时或者sent msg的时候,
他们将会被copy,因此不需特殊的gc。


有两种refc object,可以用来引用部分refc binary 和 heap binary的内容。
分别为match contexts && sub binaries
sub binaries 由split_binary/2 函数 && match pattern而产生。
它只是一个标记,而不会进行数据的复制,所以不会昂贵。

match contexts  类似前者,但是是经过优化的,它直接通过指针指向binary data。对于每块binaries的match
将会导致match context对应位置的递增。

在r11b中,match contexts只用在binary match中
在r12b中,编译器为了避免产生多余代码,而创建一个sub binaries,若是需要创建match contexts时,
则会删除sub binaries ,保留后者。



18.构造binaries
r12b中:
<<Binary/binary, ...>>
<<Binary/bitstring, ...>>

这样erts将会对此进行优化,是runtime 进行优化而不是编译器。
但是有些情况下,优化不会发生的:
how it works?
接下来解析binaries如何运行的?
Bin0 = <<0>>, 对变量Bin0一个heap binary

Bin1 = <<Bin0/binary,1,2,3>>,是一个append操作,由于Bin0没有涉及到这个append操作,
则会创建一个refc binary 而且Bin0的内容将会拷贝到对应mem上。而ProcBin部分也会被设置为对应大小。

Bin2 = <<Bin1/binary,4,5,6>>,此时Bin1有一个append的操作,追加操作将会保存在额外空间里面。
Bin3 = <<Bin2/binary,7,8,9>>,同上面一直,会保存在额外空间里面。

然而
Bin4 = <<Bin1/binary,17>>,这里会产生有趣的事情,编译器会充pre的append操作获取到Bin1,并且
创建一个新的refc binary。
可以参考源码erl_bits.c

强制复制的情况:
由于procbin和binary obejct是分开的,对于一对一的时候,append操作会导致mem的realloc以及procbin本身
的update,但是多对一的时候,此操作将会变得困难。
这会导致强制复制的产生。

因此,比如
Bin = <<Bin0,...>>
如果Bin0是来自最新的append,否则将会产生新的binary object。

另外,对于一些特殊情况,也会导致binary的copy
比如当做消息发送给一个proc或者port,将会导致binary的shunrk,
任何的其他append操作都会产生copy,产生新的binary。
Bin1 = <<Bin0,...>>,
PortOrPid ! Bin1,
Bin = <<Bin1,...>>

或者你将binary 插入到ets or 使用erlang:port_command/2 发送到一个port
或者使用了match
Bin1 = <<Bin0,...>>,
<<X,Y,Z,T/binary>> = Bin1,
Bin = <<Bin1,...>> %%为什么会复制了,因为match contexts包含了直接指向data的指针

以上的情况都会导致Bin1的copy。




19.再谈binary match
上面谈过
my_binary_to_list(<<H,T/binary>>) ->
    [H|my_binary_to_list(T)];
my_binary_to_list(<<>>) -> [].

分析下,究竟发生了什么?

当函数第一次调用时,产生一个match contexts,并且指向了第一个byte。
H将会匹配第一个byte,导致match..的update,更新point指向到第二个byte。
并且match...只创建一次,到迭代完毕,而在r11b中,则完全不同,首先会创建一个sub...
然后接着创建一个sub..的match..,而且每一次的迭代都会创建一对新的,so......


再看
my_complicated_binary_to_list(Bin) ->
    my_complicated_binary_to_list(Bin, 0).

my_complicated_binary_to_list(Bin, Skip) ->
    case Bin of
    <<_:Skip/binary,Byte,_/binary>> ->
        [Byte|my_complicated_binary_to_list(Bin, Skip+1)];
    <<_:Skip/binary>> ->
        []
    end.

此函数可以避免重复创建sub....,但是将会导致无论在r11b或者r12b中,都会创建N+1次的match...
不过不排除编译器会优化。

每次递归完毕之后,match..将会丢弃,但是如果尚未递归完就停止了呢,那会是什么情况?

after_zero(<<0,T/binary>>) ->
    T;
after_zero(<<_,T/binary>>) ->
    after_zero(T);
after_zero(<<>>) ->
    <<>>.

使用bin_opt_info选项来进行优化。
erlc +bin_opt_info Mod.erl
export ERL_COMPILER_OPTIONS=bin_opt_info




20 大话list
使用++的话只能从前端后端加入数据。
现在看看append的实现
append([H|T], Tail) ->
    [H|append(T, Tail)];
append([], Tail) ->
    Tail.
将会导致一次的复制,很明显复制了前者。
这证明了什么?告诉我们当我们递归or创建一个list的时候,确保你加入新的元素
是在前端,这样你是在构造一个新list,否则你是在种种list复制
比如
bad_fib(N) ->
    bad_fib(N, 0, 1, []).
bad_fib(0, _Current, _Next, Fibs) ->
    Fibs;
bad_fib(N, Current, Next, Fibs) -> 
    bad_fib(N - 1, Next, Current + Next, Fibs ++ [Current]).
将会导致种种的复制
正确的做法是:
tail_recursive_fib(N) ->
    tail_recursive_fib(N, 0, 1, []).
tail_recursive_fib(0, _Current, _Next, Fibs) ->
    lists:reverse(Fibs);
tail_recursive_fib(N, Current, Next, Fibs) -> 
    tail_recursive_fib(N - 1, Next, Current + Next, [Current|Fibs]).

列表解析:
列表解析一个字慢。它内部解析为内部func实现,比如
[Expr(E) || E <- List]
解析为:
'lc^0'([E|Tail], Expr) ->
    [Expr(E)|'lc^0'(Tail, Expr)];
'lc^0'([], _Expr) -> [].

看到了木有,创建了一个list

但是并不是一定的,比如:
[io:put_chars(E) || E <- List],
ok.
或者
case Var of
    ... ->
        [io:put_chars(E) || E <- List];
    ... ->
end,
some_function(...),
...

编译器会发现不用创建一个list,so....
解析为
'lc^0'([E|Tail], Expr) ->
    Expr(E),
    'lc^0'(Tail, Expr);
'lc^0'([], _Expr) -> [].


lists:flatten/1 将会重新构造一个新的list,毋庸置疑是昂贵的。
甚至比运算符'++'更昂贵。
所以尽量避免使用
下面几种情况可以避免使用它:
a.发送list到port,因为port会自动解析
b.使用bif函数的时候,比如list_to_binary/iolist_to_binary
c.使用append来代替。

正确:
port_command(Port, DeepList)
错误:
port_command(Port, lists:flatten(DeepList))

正确:
TerminatedStr = [String, 0],
port_command(Port, TerminatedStr)

错误:
TerminatedStr = String ++ [0],
port_command(Port, TerminatedStr)

正确:
lists:append([[1], [2], [3]]).
错误:
lists:flatten([[1], [2], [3]]).

再谈尾递归和普通递归
尾递归在固定的空间里面构造,而普通递归则使用与list长度成比例的stack。
正确:
sum(L) -> sum(L, 0).
sum([H|T], Sum) -> sum(T, Sum + H);
sum([], Sum)    -> Sum.

错误:
recursive_sum([H|T]) -> H+recursive_sum(T);
recursive_sum([])    -> 0.



21 大话fun
函数匹配,主要是针对编译器的优化,让编译器自动重排
错误写法:
atom_map1(one) -> 1;
atom_map1(two) -> 2;
atom_map1(three) -> 3;
atom_map1(Int) when is_integer(Int) -> Int;
atom_map1(four) -> 4;
atom_map1(five) -> 5;
atom_map1(six) -> 6.


正确:
atom_map2(one) -> 1;
atom_map2(two) -> 2;
atom_map2(three) -> 3;
atom_map2(four) -> 4;
atom_map2(five) -> 5;
atom_map2(six) -> 6;
atom_map2(Int) when is_integer(Int) -> Int.

or

atom_map3(Int) when is_integer(Int) -> Int;
atom_map3(one) -> 1;
atom_map3(two) -> 2;
atom_map3(three) -> 3;
atom_map3(four) -> 4;
atom_map3(five) -> 5;
atom_map3(six) -> 6.


错误:
map_pairs1(_Map, [], Ys) ->
    Ys;
map_pairs1(_Map, Xs, [] ) ->
    Xs;
map_pairs1(Map, [X|Xs], [Y|Ys]) ->
    [Map(X, Y)|map_pairs1(Map, Xs, Ys)].

正确:
map_pairs2(_Map, [], Ys) ->
    Ys;
map_pairs2(_Map, [_|_]=Xs, [] ) ->
    Xs;
map_pairs2(Map, [X|Xs], [Y|Ys]) ->
    [Map(X, Y)|map_pairs2(Map, Xs, Ys)].

缘由很简单,由于第二参数任意匹配参数,所以编译器无法自动重排。



函数调用的效率
a.本地函数外部函数效率最快(local function)
b.匿名函数Fun()以及apply(Fun,[])调用,大概是前两者的3倍时间。
c.执行mod:name或者apply(mod,fun,[])大概是local function的6倍

执行local函数,不用hash lookup,直接是func pointer
而apply/3则需要查找hash table
其实
Module:Name(A)和apply(Module,Name,[A])都是一样的
最终编译器会重写。

apply(Module, Function, Arguments)会稍微慢点,因为参数在编译器并不可知


递归函数的内存使用
使用递归函数的时候,最好是使用尾递归函数,因为在固定的mem中执行。
list_length(List) ->
    list_length(List, 0).
list_length([], AccLen) -> 
    AccLen; 
list_length([_|Tail], AccLen) ->
    list_length(Tail, AccLen + 1). 


22 进程
erlang的进程是轻量级的,正常情况下是309字大小
1> Fun = fun() -> receive after infinity -> ok end end.
#Fun<...>
2> {_,Bytes} = process_info(spawn(Fun), memory).
{memory,1232}
3> Bytes div erlang:system_info(wordsize).
309

进程里面的循环必须是尾调用
正确:
loop() -> 
      receive
         {sys, Msg} ->
            handle_sys_msg(Msg),
            loop();
         {From, Msg} ->
            Reply = handle_msg(Msg),
            From ! Reply,
            loop()
    end.

错误:
loop() -> 
  receive
     {sys, Msg} ->
         handle_sys_msg(Msg),
         loop();
     {From, Msg} ->
          Reply = handle_msg(Msg),
          From ! Reply,
          loop()
  end,
  io:format("Message is processed~n", []).

进程默认heap size 是233字
可以通过erl +h来调整或者
spawn_opt/4函数min_heap_size 选项
对于一些临时task,我们可以spawn一个进程来处理。

进程之间的消息的data都是进行复制的,除了同一节点下refc binaries
如果发送到别的节点,则首先会encode,然后通过tcp/ip,然后对于的node
会进行decode,然后派发到对应的进程。

erlang下的常量--字符量,会保存在constant pool里面,
每个module都有它自己的constant pool
days_in_month(M) ->
    element(M, {31,28,31,30,31,30,31,31,30,31,30,31}).

但是如果将constant发送给其他的proc,或者保存到ets里面,那将会产生复制。
若是一个module给unload的话,那refer了对应constant的proc将会产生复制。



23 高级话题
erlang中不同类型所占的mem大小
Small integer    1字  
32bit 使用了28bit  -134217729 < i < 134217728 (28 bits)
64bit 使用了60bit -576460752303423489 < i < 576460752303423488 (60 bits)

Big integer   3..N words

Atom 1字 atom保存在atom table中,并且不gc

Float 
32-bit 4 字
64-bit 3 字

Binary 3..6 + data (共享的data)

List  1字 + element*size *word

String 1 字+ 2 字每个字符

Tuple 2字+ element*2word

Pid  1字 identity + 5字 node + 指向其他元素需要消耗内存

port 和pid一样

Reference 5字 identity + 7字 node + 指向其他元素所需要消耗内存

Fun      9..13 字 + 环境 + fun table

Ets table  初始化是768字节,自动递增

Erlang process 默认327 包含了233 heap 


一些限制:
默认最大proc 的数量:32768
可以通过erl +P 设置,最大可以是268435456 

atom的长度 255个字符
atom默认最大限制 1048576
erl +t 可以设置

ets 默认1400

ERL_MAX_ETS_TABLES 可以设置

* 确保没有任何编译警告 

* Erlang中String采用list实现,32位系统中,其1个字符用8个字节的空间(4个保存value, 4个保存指针)。因此string速度较慢,空间占用较大 

* 在Server中,总是尽力书写尾递归(tail-recursive)的函数 

* 使用'++'时,left list会被拷贝,然后添加到right list的头部,因此最好把length较短的list放在左侧 

* 避免使用regexp,如果需要正则表达式,请使用re 

* timer模块的大部分函数实现,依赖于一个process,如果过多使用timer,会导致这个process负载过大,影响效率。 
  推荐使用erlang:send_after/3及erlang:start_timer/3 

* 避免使用list_to_atom/1,因为erlang中atom数量最大为1048576, 且不进行GC控制。因此如果持续性的调用list_to_atom/1 
  可能很容易达到系统上限,从而导致emulator terminate。请使用list_to_existing_atom/1。 

* list内部实现为一个列表,因此length(List), 需要遍历整个list比较耗时 

* 对于不同的数据类型,使用不同的size函数:tuple_size/1, byte_size/1, bit_size/1 

* 使用binary match来进行binary的分割,而不使用split_binary/2 

* 如果两个list都拥有很多数据,那么请不要使用'--',而是将数据转化到ordsets,然后调用ordsets:substract/2. 

* 对于binary相关操作可以进行binary优化(bin_opt_info编译选项)代码框架: 

*   f(<<Pattern1,...,Rest/bits>>,...) ->  
       ... % Rest is not used here  
       f(Rest,...);  
    f(<<Pattern2,...,Rest/bits>>,...) ->  
      ... % Rest is not used here  
      f(Rest,...);  
    ...  
    f(<<>>, ...) ->  
      ReturnValue. 

* 调用lists:flatten/1可以将list扁平化,这个操作代价很大,比'++'还要昂贵。下面这些时候我们可以避免: 
    将数据发送给port时 
    调用list_bo_binary/1和iolist_to_binary前 

* 小的函数可以让您方便的找出错误的函数和代码 

* 不要在同一行出现相同的符号 
20    some_fun() -> 
21       L = [{key1, v1}, {key2, [some_record#v21, v22]}], 
22      ... 
编译时,会提示line 21 '[' 语法错误, 因为21行有多个 '[' ,所以这个bug不能准确定位,你需要花时间去排查代码。 
好的做法是: 
20 some_fun() -> 
21      L = [{key1, v1}, 
22            {key2, [some_record#v21, v22]} 
23            ], 
      ... 
这样,编译其会提示你 line 22 '[' 语法错误,你很开就知道是那个地方错了。 

* 使用 CTRL + 或 init:stop(), 可以退出Erlang, 使用CTRL + G 及 CTRL + C 弹出菜单选项,可以选择是否退出Erlang。 
其中CTRL + G可以用来连接其他的shell, CTRL + C可以查看其他一些系统信息 
Ctrl + C abort 是野蛮的退出方式 

* use "open_port({fd,0,2}, [out])" make erlang program write standard error to unix system 

* If you don't run experiments before you start designing a new system, your entire system will be an experiment! 

* standard data structure desc: 

Module Description 
sets sets, i.e. a collection of unique elements. 
gb_sets sets, but based on a general balanced data structure 
gb_tree a general balanced tree 
dict maps, also called associative arrays 
ets hash tables and ordered sets (trees) 
dets on-disk hash tables 

Suggestion: 
elments count: 0 - 100 | 100 - 10000  |  10000 - 
our select   :  list   |      ets     |  gb_tree 

* 通过code:clash/0 检测代码中是否有module冲突现象(sticky) 

* epmd -d -d 启动 epmd 可以查看erlang node之间的通讯 

* 将正常的逻辑代码和错误处理代码分离,发生错误时,尽管错误。由另一个错误处理模块进行处理 

* 类似于操作系统,我们的程序也可以分为kernel 和 user 两层, 对于kernel绝对不能出现错误, 对于user可以出现错误,进行恢复 

* process顶层loop涉及的代码及函数,最好在一个module中实现 

* process 的register name和module名称一致, 便于寻找代码 

* 每个process具有一个单一的角色,比如:supervisor 用来进行错误恢复, work 工作者,可以出现错误, trusted worker 不会出现错误 

* 通过函数调用可以实现的功能,就不要使用sever实现(如gen_server, 及类似的loop 实现) 

* 给消息加一个tag,在发生错误的时候,可以定位到消息,同时也有利于程序的稳健 

* 在消息循环中,对于unknown的消息,请调用lib:flush_receive/0 将其清除,减轻process msg queue的长度 

* server中总是书写尾递归的循环 

* 尽量使用record, 而不是原始的tuple来表现数据结构, 在使用record时,使用select match: 
#person{name = Name, age = Age} = Person 

* 对于返回值,最好也添加一个tag,用来说明返回值类型,或者执行成功与否 

* 尽可能少的使用catch和try,在erlang程序中,不推荐主动捕获异常。只有当我们的逻辑特别复杂,我们可以使用throw来返回数据,使用catch来获取返回值。 

* 当然程序与外界交互,外界数据不可靠时,需要使用catch和try 

* 慎重使用process dictory, 当你使用get/1, put/1时,你的应用会具有很大的slide effect。可以通过加入一个新的参数来保存原本需要存储到process dictory中数据 

* 如果不想使自己糊涂,请不要使用import 

* 使用export时,将功能类似的接口组合在一起,并添加合理的注视,这样你的接口更清晰,别人使用起来更方便 

* 不要书写嵌套太深的代码 

* 不要书写太长的module 

* 不要书写太长的函数 

* 每行代码不能太长 

* 避免使用 "_" 匿名变量,请为每个变量选择有意义的名称,如够某个变量暂时不使用,请以下划线 "_" 开始 

* {error, enfile} enfile error in socket 是以为内linux系统中 ulimit 限制, 在root下修改:ulimit -n 25000 

* {error, enotconn} 表示socket已经关闭 

* 在erlang开发时,慎重使用macro,因为erlang的single assign的缘故,同时调用某个marco,而macro又定义了某个变量,可能导致badmatch错误。 
比如: 
-define(ADDLINEINFO1(F), 
        ( 
        begin 
        Str1 = lists:concat(["[Mod:", ?MODULE, " Line:", ?LINE, "]"]), 
        Str1 ++ F 
        end 
        )). 
-define(WARN(Log, F, D), log4erl:warn(Log, ?ADDLINEINFO(F), D)). 
如果连续使用 WARN, 会出现此错误 

* erlang中可以定义很多环境变量: 
ERL_MAX_ETS_TABLES 设置最大的ets数目 默认1400 
ERL_MAX_PORTS erlang最大的port数目 默认1024 

* .app文件中的start_phases, 选项既可以用来作为include applications之间的同步启动,也可以用来对单个application进行分布启动。 
顺序如下 
包含included app: 

application:start(prim_app) 
=> prim_app_cb:start(normal, []) 
=> prim_app_cb:start_phase(init, normal, []) 
=> prim_app_cb:start_phase(go, normal, []) 
=> incl_app_cb:start_phase(go, normal, []) 
ok 

无included app: 
application:start(prim_app) 
=> prim_app_cb:start(normal, []) 
=> prim_app_cb:start_phase(init, normal, []) 
=> prim_app_cb:start_phase(go, normal, []) 
ok 

* 任何时候,都要重视函数的返回值,通过match确保您的预期,如果发生错误,那么就大胆的表达出来。

匿名函数的匹配定义: F = fun(false, B) -> B;(A, true) ->A;(A,B) ->{A, B} end.

原文地址:https://www.cnblogs.com/xiao0913/p/3597119.html