OTP的原则.如何在实践中分离功能和非功能代码?

gal*_*dog 6 erlang erlang-otp

考虑我有一个用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.
Run Code Online (Sandbox Code Playgroud)

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

我看到这个代码存在两个主要问题:第一,FSM的纯功能概念与非功能代码的一部分混合在一起,这也使得FSM的单元测试变得不可能.其次,实现FSM的模块依赖于my_db_module的特定实现.

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

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

    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.
    
    Run Code Online (Sandbox Code Playgroud)

    这种实现的优点是可以从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.
    
    Run Code Online (Sandbox Code Playgroud)

    这个解决方案不会受到竞争条件的影响,但如果FSM使用了很多回调,它真的会压倒代码.虽然更改为实际函数回调可以使单元测试成为可能,但它并不能解决功能代码分离的问题.

我对所有这些解决方案都不满意.是否有一些配方以纯OTP/Erlang方式处理此问题?可能是我低估了OTP和eunit原则的问题.

I G*_*ERS 2

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

您将状态记录定义为

 -record(state, { ..., db_mod }).
Run Code Online (Sandbox Code Playgroud)

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

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

因此,当我们有了您的代码时:

 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.
Run Code Online (Sandbox Code Playgroud)

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

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

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