应用结构的搭建分为一下几个步骤:
(1)创建标准应用目录布局;
(2)编写**.app**文件
(3)编写应用行为模式实现模块,即sc_app
(4)实现顶层监督者,即sc_sup
一:应用目录结构的布局
首先新建一个名为simple cache的顶层应用目录。在该目录下,新建doc 、ebin 、include 、priv 和src等子目录。最终的目录树应该是这样的:
Erlang
simple_cache
|
| -doc
| -ebin
| -include
| -priv
| -src
虽然这个例子还用不上doc 、include 和priv 目录,不过,反正多建几个目录没有什么坏处,以备
不时之需吧。目录布局完毕后,下一步就是布置**.app**文件。
二:创建应用元数据
在启动应用或在执行运行时代码热升级时,OTP需要了解一些用于描述应用自身的元数据。存放元数据的**.app** 文件的文件名应该与应用名相匹配(但无须采用特定模块的名字),在这个例子中,该文件就是ebin/simple_cache.app 。.app文件当前内容如下:
Erlang
%% ebin/simple_cache.app
{application, simple_cache,
[{description, "A simple caching system"},
{vsn, "0.1.0"},
{modules, [
sc_app,
sc_sup
]},
{registered, [sc_sup]},
{applications, [kernel, stdlib]},
{mod, {sc_app, []}}
]}.
sc_app 和sc_sup这两个模块肯定是少不了的,已经罗列在内;其余模块则将在后续逐步加人该列表。另外,很明显根监督者应该以sc_sp为注册名进行注册。骨架已经搭建完毕,接下来就要给应用行为模式的实现模块添砖加瓦了。
三:实现应用行为模式
应用行为模式的实现位于文件src/sc_app.erl内,特别需要注意的是,.app文件中的mod元组给出了应用行为模式模块的模块名,系统就是从这里得知应该从何处启动和停止应用的。代码如下:
Erlang
%% src/sc_app.erl
-module(sc_app).
%% 行为模式声明
-behaviour(application).
%% 导出行为模式回调函数
-export([start/2, stop/1]).
%% 启动根监督者
start(_StartType, _StartArgs) ->
case sc_sup:start_link() of
{ok, Pid} ->
{ok, Pid};
Other ->
{error, Other}
end.
stop(_State) ->
ok.
sc_app模块唯一的任务就是在应用启动时启动根监督者 ,在应用停止时则什么也不用做。
四:实现监督者
根监督者在文件src/sc_sup.erl 中实现,但是这个监督者我们没有给这个监督者静态指派 任何永久子进程 ,但却可以给它动态添加任意多个同类型的临时子进程,代码如下:
Erlang
-module(sc_sup).
-behaviour(supervisor).
%% 动态启动子进程
-export([start_link/0, start_child/2]).
-export([init/1]).
-define(SERVER, ?MODULE).
start_link() ->
supervisor:start_link({local, ?SERVER}, ?MODULE, []).
start_child(Value, LeaseTime) ->
%% sc_element:start_link/2 的参数
supervisor:start_child(?SERVER, [Value, LeaseTime]).
init([]) ->
Element = {sc_element, {sc_element, start_link, []},
temporary, brutal_kill, worker, [sc_element]},
Children = [Element],
RestartStrategy = {simple_one_for_one, 0, 1},
{ok, {RestartStrategy, Children}}.
1.简易的一对一
该监督者的监督策略被设定为simple_one_for_one (简易一对一监督)。simple_one_for_one型监督者只能启动一种子进程,但却可以启动任意多个。它所有的子进程都是运行时动态添加的,监督者本身在启动时不会启动任何子进程。如下图所示是一个简易的一对一模块:
2.监督者模块
根监督进程开启的时候不会启动固定的子进程,而是可以动态添加任意多个同类型的子进程;通过调用supervisor:start_child/2 开启子进程,启动子进程的函数为sc_element:start_link(Value, LeaseTime) 。每次调用sc_sup:start_child/2 ,就会新启动一个带有自己的值和淘汰时间的sc_element子进程。
五:编写sc_element进程
sc_element 模块实现了sc_sup 的子进程,每当有新数据插人缓存时,sc_sup 就会派生出一个新
的sc_element 进程,用于存储与给定的键相关联的数据。我们打算以gen_server 行为模式为基
础来实现这类进程,数据将被保存在gen_server进程的进程状态中。
1.模块首部
每当新数据插入缓存时,sc_sup派生一个新的进程,用于存储与给定的键相关的数据。需要实现的主要功能有四个:新元素创建、元素值查询、元素值替换、以及元素删除。下面代码就是一个首部模块:
Erlang
%% src/sc_element.erl
-module(sc_element).
-behaviour(gen_server).
%% 导出的API函数
-export([
start_link/2,
create/2,
create/1,
fetch/1,
replace/2,
delete/1
]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
terminate/2, code_change/3]).
-define(SERVER, ?MODULE).
%% 一天中的总秒数
-define(DEFAULT_LEASE_TIME, (60 * 60 * 24)).
%% 状态记录
-record(state, {value, lease_time, start_time}).
键/值对在缓存中存活一段时间之后便会被清理出局,这段时间称作淘汰时间。DEFAULTLEASE_TIME 便是默认的淘汰时间 (以秒为单位)。设定淘汰时间的目的在于保证缓存中的内容足够新,缓存就是缓存,不是数据库。创建sc_element 进程时,你可以通过API自行调整这个值。模块首部的最后一项定义了用于表示gen_serverj进程状态的记录。它由3个字段组成:进程持有的值 、淘汰时间 ,以及进程启动时的时间戳。
2.API段和进程的启动
模块中的下一部分就是API的实现,如下述代码所示:
Erlang
%% src/sc_element.erl
start_link(Value, LeaseTime) ->
gen_server:start_link(?MODULE, [Value, LeaseTime], []).
create(Value, LeaseTime) ->
%% 将启动委托给sc_sup
sc_sup:start_child(Value, LeaseTime).
create(Value) ->
create(Value, ?DEFAULT_LEASE_TIME).
fetch(Pid) ->
gen_server:call(Pid, fetch).
replace(Pid, Value) ->
gen_server:cast(Pid, {replace, Value}).
delete(Pid) ->
gen_server:cast(Pid, delete).
子进程由监督者负责创建;个别细节则由监督者的API函数sc_sup:start_child/2 负责屏蔽。然而,监督者的存在属于实现细节,sc_element 的用户并不关心。为此,我们创建了API函数create/2 ,用于将创建子进程的任务委托给sc_sup 。此外,如果直接采用默认淘汰时间,还可以选用更为简化的create/1。这下即便把底层实现改个底儿朝天,也不用动接口层了。整体的调用流程如下图所示:
存储新存储时的调用流程。其中sc_element API向simple_cache屏蔽了sc_sup的存在。同时,sc sup也不关心sc_element的功能细节
3.gen_server回调段
sc_elementi进程启动之后的第一件事就是通过gen server回调函数init/l完成进程初始化,该函数应返回一个初始化完毕的进程状态记录。gen_server:start_link/3 调用将一直阻塞到init/1 返回为止。src/sc_element.erl的各个回调如下面代码所示:
Erlang
%% src/sc_element.erl
init([Value, LeaseTime]) ->
Now = calendar:local_time(),
StartTime = calendar:datetime_to_gregorian_seconds(Now),
{ok,
%% 初始化进程状态
#state{value = Value,
lease_time = LeaseTime,
start_time = StartTime},
%%初始化超时设置
time_left(StartTime, LeaseTime)}.
time_left(_StartTime, infinity) ->
infinity;
time_left(StartTime, LeaseTime) ->
Now = calendar:local_time(),
CurrentTime = calendar:datetime_to_gregorian_seconds(Now),
TimeElapsed = CurrentTime - StartTime,
case LeaseTime - TimeElapsed of
Time when Time =< 0 -> 0;
Time -> Time * 1000
end.
handle_call(fetch, _From, State) ->
#state{value = Value,
lease_time = LeaseTime,
start_time = StartTime} = State,
TimeLeft = time_left(StartTime, LeaseTime),
%% 取出进程状态中的值
{reply, {ok, Value}, State, TimeLeft}.
handle_cast({replace, Value}, State) ->
#state{lease_time = LeaseTime,
start_time = StartTime} = State,
TimeLeft = time_left(StartTime, LeaseTime),
{noreply, State#state{value = Value}, TimeLeft};
handle_cast(delete, State) ->
%% 发出关闭信号
{stop, normal, State}.
handle_info(timeout, State) ->
{stop, normal, State}.
terminate(_Reason, _State) ->
%% 删除进程的键
sc_store:delete(self()),
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
注:记得设置服务器超时,如果忘了在回调函数的返回值中设置新的超时,超时将被重置为1n1f11ty。因此旦用上服务器超时,切记在每个回调函数的每个子句中都设置好超时。
六:实现sc_store模块
至此,你已经实现了基本的应用结构和缓存的后端存储系统,其中还包括淘汰时间管理功能。现在,你将以此为基础构建出一整套完整的存储系统。目前,所有缺失环节中最关键的一环就是键与进程标识符之间的映射关系,有了它你才能根据给定的键查找到相应的值。下面展示了src/sc_store.erl 的代码。这一次,这个模块没有采用任何OTP行为模式,也没有与任何进程相关联一它只包含一组供其他进程调用的库函数。
Erlang
%% src/sc_store.erl
-module(sc_store).
-export([
init/0,
insert/2,
delete/1,
lookup/1
]).
-define(TABLE_ID, ?MODULE).
init() ->
ets:new(?TABLE_ID, [public, named_table]),
ok.
insert(Key, Pid) ->
ets:insert(?TABLE_ID, {Key, Pid}).
lookup(Key) ->
case ets:lookup(?TABLE_ID, Key) of
[{Key, Pid}] -> {ok, Pid};
[] -> {error, not_found}
end.
delete(Pid) ->
ets:match_delete(?TABLE_ID, {'_', Pid}).
API由init/1 (负责存储系统的初始化)和处理基本CRUD操作(创建 、读取 、更新和删除 )的3个函数组成,其中insert/2同时负责创建新表项和更新现存表项。这几个函数的实现都很简洁。具体实现如下:
1.存储的初始化
在init/1中,首先需要创建用于存放映射关系的ETS表 。第一种方法是直接调用ets:new/2 即可,第二种方法是利用表名。ETS接口要求每张表都有一个名字;但必须先设置named_table 才能用表名来访问表,此外,多张表可以共用一个表名。在此采用具名表的原因在于我们不希望库的用户去追踪表句柄,一旦这样做,你就必须将句柄传递给所有会用到sc_storel 的进程,而且sc_storel的每个API调用都必须包含该句柄。因此我们可以对src/app.erl 中的start/2函数进行如下改动:
Erlang
%% src/sc_app.erl
start(_StartType, _StartArgs) ->
%% 加到这个位置
sc_store:init(),
case sc_sup:start_link() of
{ok, Pid} ->
{ok, Pid};
Other ->
{error, Other}
end.
只要加上这么一句,sc_store便可以在应用启动后的第一时间完成初始化。如果再延迟初始化的时机(例如放在顶层监督者启动之后),就有可能在某些地方出现试图访问某张尚不存在的ETS表的风险。
2.表项的创建和更新
默认情况下,表中所有元组的第一个元素被视作键,其余元素被视作载荷(个数任意)。按键进行查找时,与该键对应的整个元组都将被返回。ETS默认表现为一个集合(set):同一时刻一个键只能与一个表项相对应,如果表中现有的某个表项与插入的新元组具有相同的键 ,那么旧表项将被新元组覆盖一这恰恰是你所需要的功能。
七:打造应用层API模块
应用层API模块通常与应用同名。在此,你将为simple_cache 应用建立一个名为simple_cache的API模块。该模块为缓存服务的终端用户提供了以下接口函数:
|----------|--------------|
| insert/2 | 将键/值对存入缓存 |
| lookup/1 | 按键查询值 |
| delete/1 | 按键从缓存中删除键/值对 |
这套API并未包含应用的启动、停止功能,相关功能将交由application:start/1等系统函数来处理。具体代码如下所示:
Erlang
%% src/simple_cache.erl
-module(simple_cache).
-export([insert/2, lookup/1, delete/1]).
insert(Key, Value) ->
%% 检查键是否已经存在
case sc_store:lookup(Key) of
{ok, Pid} ->
sc_element:replace(Pid, Value);
{error, _} ->
{ok, Pid} = sc_element:create(Value),
sc_store:insert(Key, Pid)
end.
lookup(Key) ->
try
%% 获取键对应的Pid
{ok, Pid} = sc_store:lookup(Key),
{ok, Value} = sc_element:fetch(Pid),
{ok, Value}
catch
_Class:_Exception ->
{error, not_found}
end.
delete(Key) ->
case sc_store:lookup(Key) of
{ok, Pid} ->
%% 清理
sc_element:delete(Pid);
{error, _Reason} ->
ok
end.
这样,我们的简易缓存就可以运作了。下面我们来尝试一下(强烈建议你亲自动手)。试运行之前,编译src目录下的所有模块,并将生成的**.beam** 文件悉数放到ebin目录下。然后请按下述指示(在应用根目录下)运行Erlang,启动应用:
Erlang
$ erl -pa ebin
Eshell V5.5.5 (abort with ^G)
1> application:start(simple_cache).
ok