7

考虑我有一个用 gen_fsm 实现的 FSM。对于某个 StateName 中的某个事件,我应该将数据写入数据库并回复用户结果。所以下面的 StateName 用一个函数来表示:

statename(Event, _From, StateData)  when Event=save_data->
    case my_db_module:write(StateData#state.data) of
         ok -> {stop, normal, ok, StateData};
         _  -> {reply, database_error, statename, StateData)
    end.

其中 my_db_module:write 是实现实际数据库写入的非功能代码的一部分。

我看到这段代码有两个主要问题:首先,FSM 的纯功能概念被部分非功能代码混合,这也使得 FSM 的单元测试成为不可能。其次,实现 FSM 的模块依赖于 my_db_module 的特定实现。

在我看来,两种解决方案是可能的:

  1. 实现 my_db_module:write_async 作为向某个进程处理数据库发送异步消息,不要在 statename 中回复,将 From 保存在 StateData 中,切换到 wait_for_db_answer 并等待来自 db 管理进程的结果作为句柄信息中的消息。

    statename(Event, From, StateData)  when Event=save_data->
        my_db_module:write_async(StateData#state.data),
        NewStateData=StateData#state{from=From},
        {next_state,wait_for_db_answer,NewStateData}
    
    handle_info({db, Result}, wait_for_db_answer, StateData) ->
        case Result of
             ok -> gen_fsm:reply(State#state.from, ok),
                   {stop, normal, ok, State};
             _  -> gen_fsm:reply(State#state.from, database_error),
                   {reply, database_error, statename, StateData)
        end.
    

    这种实现的优点是可以从 eunit 模块发送任意消息而不接触实际数据库。如果 db 较早回复,FSM 更改状态或另一个进程将 save_data 发送到 FSM,则该解决方案可能会遇到竞争条件。

  2. 使用在 StateData 的 init/1 期间编写的回调函数:

    init([Callback]) ->
    {ok, statename, #state{callback=Callback}}.
    
    statename(Event, _From, StateData)  when Event=save_data->
        case StateData#state.callback(StateData#state.data) of
             ok -> {stop, normal, ok, StateData};
              _  -> {reply, database_error, statename, StateData)
    end.
    

    此解决方案不受竞争条件的影响,但如果 FSM 使用许多回调,它确实会使代码不堪重负。尽管更改为实际的函数回调使单元测试成为可能,但它并没有解决函数代码分离的问题。

我对所有这些解决方案都不满意。是否有一些方法可以以纯 OTP/Erlang 方式处理这个问题?可能是我理解 OTP 和 eunit 的原理的问题。

4

1 回答 1

2

解决这个问题的一种方法是通过数据库模块的依赖注入。

您将状态记录定义为

 -record(state, { ..., db_mod }).

现在您可以在 gen_server 的 init/1 上注入 db_mod:

 init([]) ->
    {ok, DBMod} = application:get_env(my_app, db_mod),
    ...
    {ok, #state { ..., db_mod = DBMod }}.

所以当我们有你的代码时:

 statename(save_data, _From,
           #state { db_mod = DBMod, data = Data } = StateData) ->
   case DBMod:write(Data) of
     ok -> {stop, normal, ok, StateData};
     _  -> {reply, database_error, statename, StateData)
   end.

当使用另一个模块进行测试时,我们有能力覆盖数据库模块。注入存根现在非常容易,因此您可以根据需要更改数据库代码表示。

另一种选择是在meck测试时使用模拟数据库模块之类的工具,但我通常更喜欢使其可配置。

但总的来说,我倾向于将复杂的代码拆分为自己的模块,以便可以单独测试。我很少对其他模块进行大量单元测试,更喜欢大规模集成测试来处理这些部分中的错误。看看 Common Test、PropEr、Triq 和 Erlang QuickCheck(后者不是开源的,完整版也不是免费的)。

于 2012-10-08T12:14:35.487 回答