ekfa/apps/efka/src/efka_subscription.erl
2026-05-11 22:28:44 +08:00

271 lines
11 KiB
Erlang
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

%%%-------------------------------------------------------------------
%%% @author anlicheng
%%% @copyright (C) 2025, <COMPANY>
%%% @doc
%%%
%%% @end
%%% Created : 28. 4月 2025 23:50
%%%-------------------------------------------------------------------
-module(efka_subscription).
-author("anlicheng").
-behaviour(gen_server).
%% API
-export([start_link/0]).
-export([subscribe/2, unsubscribe_all/1, publish/3, debug_info/0]).
-export([match_components/2, is_valid_components/1, of_components/1]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-define(SERVER, ?MODULE).
%% 定义订阅者
-record(subscriber, {
topic :: binary(),
subscriber_pid :: pid(),
components = [],
%% 优先级
%% 1. 完全匹配的topic优先级别最高
%% 2. 带 * 的订阅
%% 3. 带 + 的订阅
order :: integer()
}).
-record(state, {
subscribers = [],
%% qos未1并且未被消费的消息
remaining_messages = []
}).
%%%===================================================================
%%% API
%%%===================================================================
-spec subscribe(Topic :: binary(), SubscriberPid :: pid()) -> ok | {error, Reason :: binary()}.
subscribe(Topic, SubscriberPid) when is_binary(Topic), is_pid(SubscriberPid) ->
gen_server:call(?SERVER, {subscribe, Topic, SubscriberPid}).
-spec publish(Topic :: binary(), Qos :: integer(), Content :: binary()) -> ok.
publish(Topic, Qos, Content) when is_binary(Topic), is_integer(Qos), is_binary(Content) ->
gen_server:cast(?SERVER, {publish, Topic, Qos, Content}).
-spec unsubscribe_all(pid()) -> ok.
unsubscribe_all(SubscriberPid) when is_pid(SubscriberPid) ->
gen_server:call(?SERVER, {unsubscribe_all, SubscriberPid}).
-spec debug_info() -> {ok, Info :: map()}.
debug_info() ->
gen_server:call(?SERVER, debug_info).
%% @doc Spawns the server and registers the local name (unique)
-spec(start_link() ->
{ok, Pid :: pid()} | ignore | {error, Reason :: term()}).
start_link() ->
gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).
%%%===================================================================
%%% gen_server callbacks
%%%===================================================================
%% @private
%% @doc Initializes the server
-spec(init(Args :: term()) ->
{ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} |
{stop, Reason :: term()} | ignore).
init([]) ->
{ok, #state{}}.
%% @private
%% @doc Handling call messages
-spec(handle_call(Request :: term(), From :: {pid(), Tag :: term()},
State :: #state{}) ->
{reply, Reply :: term(), NewState :: #state{}} |
{reply, Reply :: term(), NewState :: #state{}, timeout() | hibernate} |
{noreply, NewState :: #state{}} |
{noreply, NewState :: #state{}, timeout() | hibernate} |
{stop, Reason :: term(), Reply :: term(), NewState :: #state{}} |
{stop, Reason :: term(), NewState :: #state{}}).
%% 同一个SubscriberPid只能订阅同一个topic一次
handle_call({subscribe, Topic, SubscriberPid}, _From, State = #state{subscribers = Subscribers, remaining_messages = RemainingMessages}) ->
Components = of_components(Topic),
case is_valid_components(Components) of
true ->
case has_subscription(Topic, SubscriberPid, Subscribers) of
true ->
{reply, ok, State};
false ->
Sub = #subscriber{topic = Topic, subscriber_pid = SubscriberPid, components = Components, order = order_num(Components)},
%% 只有首次看到该pid时才建立monitor避免重复monitor
case has_subscriber_pid(SubscriberPid, Subscribers) of
true ->
ok;
false ->
erlang:monitor(process, SubscriberPid)
end,
%% 处理遗留的消息
RestRemainingMessages = dispatch_remaining_messages(Sub, RemainingMessages),
{reply, ok, State#state{subscribers = Subscribers ++ [Sub], remaining_messages = RestRemainingMessages}}
end;
false ->
{reply, {error, <<"invalid topic name">>}, State}
end;
handle_call({unsubscribe_all, SubscriberPid}, _From, State = #state{subscribers = Subscribers}) ->
{reply, ok, State#state{subscribers = remove_subscriber_pid(SubscriberPid, Subscribers)}};
handle_call(debug_info, _From, State = #state{subscribers = Subscribers, remaining_messages = RemainingMessages}) ->
Info = #{
subscribes => Subscribers,
remaining_messages => RemainingMessages
},
{reply, {ok, Info}, State}.
%% @private
%% @doc Handling cast messages
-spec(handle_cast(Request :: term(), State :: #state{}) ->
{noreply, NewState :: #state{}} |
{noreply, NewState :: #state{}, timeout() | hibernate} |
{stop, Reason :: term(), NewState :: #state{}}).
%% 发布消息
handle_cast({publish, Topic, Qos, Content}, State = #state{subscribers = Subscribers, remaining_messages = RemainingMessages}) ->
MatchedSubscribers = match_subscribers(Subscribers, Topic),
logger:debug("[efka_subscription] topic: ~p, content: ~p, match subscribers: ~p", [Topic, Content, MatchedSubscribers]),
case MatchedSubscribers of
[_|_] ->
broadcast(Topic, Content, MatchedSubscribers),
{noreply, State};
[] when Qos =:= 0 ->
{noreply, State};
[] ->
{noreply, State#state{remaining_messages = [{Topic, Content}|RemainingMessages]}}
end.
%% @private
%% @doc Handling all non call/cast messages
-spec(handle_info(Info :: timeout() | term(), State :: #state{}) ->
{noreply, NewState :: #state{}} |
{noreply, NewState :: #state{}, timeout() | hibernate} |
{stop, Reason :: term(), NewState :: #state{}}).
handle_info({'DOWN', _Ref, process, SubscriberPid, Reason}, State = #state{subscribers = Subscribers}) ->
logger:debug("[efka_subscription] subscriber: ~p, down with reason: ~p", [SubscriberPid, Reason]),
NSubscribers = remove_subscriber_pid(SubscriberPid, Subscribers),
{noreply, State#state{subscribers = NSubscribers}};
handle_info(Info, State = #state{}) ->
logger:debug("[efka_subscription] get unknown info: ~p", [Info]),
{noreply, State}.
%% @private
%% @doc This function is called by a gen_server when it is about to
%% terminate. It should be the opposite of Module:init/1 and do any
%% necessary cleaning up. When it returns, the gen_server terminates
%% with Reason. The return value is ignored.
-spec(terminate(Reason :: (normal | shutdown | {shutdown, term()} | term()),
State :: #state{}) -> term()).
terminate(_Reason, _State = #state{}) ->
ok.
%% @private
%% @doc Convert process state when code is changed
-spec(code_change(OldVsn :: term() | {down, term()}, State :: #state{},
Extra :: term()) ->
{ok, NewState :: #state{}} | {error, Reason :: term()}).
code_change(_OldVsn, State = #state{}, _Extra) ->
{ok, State}.
%%%===================================================================
%%% Internal functions
%%%===================================================================
%% 查找满足条件订阅者
-spec match_subscribers(Subscribers :: [#subscriber{}], Topic :: binary()) -> [#subscriber{}].
match_subscribers(Subscribers, Topic) when is_list(Subscribers), is_binary(Topic) ->
Components = of_components(Topic),
lists:foldl(fun(S = #subscriber{components = Components0, subscriber_pid = Pid0}, Acc) ->
case match_components(Components0, Components) andalso not contain_channel(Pid0, Acc) of
true ->
[S|Acc];
false ->
Acc
end
end, [], Subscribers).
-spec contain_channel(Pid :: pid(), Subscribers :: list()) -> boolean().
contain_channel(Pid, Subscribers) when is_pid(Pid), is_list(Subscribers) ->
lists:search(fun(#subscriber{subscriber_pid = Pid0}) -> Pid == Pid0 end, Subscribers) /= false.
-spec has_subscriber_pid(pid(), [#subscriber{}]) -> boolean().
has_subscriber_pid(SubscriberPid, Subscribers) when is_pid(SubscriberPid), is_list(Subscribers) ->
lists:any(fun(#subscriber{subscriber_pid = SubscriberPid0}) -> SubscriberPid =:= SubscriberPid0 end, Subscribers).
-spec has_subscription(binary(), pid(), [#subscriber{}]) -> boolean().
has_subscription(Topic, SubscriberPid, Subscribers)
when is_binary(Topic), is_pid(SubscriberPid), is_list(Subscribers) ->
lists:any(fun(#subscriber{topic = Topic0, subscriber_pid = SubscriberPid0}) ->
Topic =:= Topic0 andalso SubscriberPid =:= SubscriberPid0
end, Subscribers).
%% 开始对比订阅的topic和发布的topic的Components信息
%% *表示单级匹配,+表示多级匹配;+只能出现一次,并且只能在末尾
-spec match_components(list(), list()) -> boolean().
match_components(A, B) when is_list(A), is_list(B) ->
match_components(A, B, false).
match_components([<<"+">>], [_|_], _) ->
true;
match_components([], [], _) ->
true;
match_components([<<"*">>|T0], [_|T1], _) ->
match_components(T0, T1, false);
match_components([C0|T0], [C0|T1], _) ->
match_components(T0, T1, false);
match_components(_, _, _) ->
false.
-spec of_components(Topic :: binary()) -> [binary()].
of_components(Topic) when is_binary(Topic) ->
binary:split(Topic, <<$/>>, [global]).
-spec is_valid_components([binary()]) -> boolean().
is_valid_components([]) ->
true;
is_valid_components([<<$+>>|T]) ->
length(T) =:= 0;
is_valid_components([<<$*>>|T]) ->
is_valid_components(T);
is_valid_components([_|T]) ->
is_valid_components(T).
-spec order_num(Components :: list()) -> integer().
order_num([]) ->
1;
order_num([<<$*>>|_]) ->
2;
order_num([<<$+>>|_]) ->
3;
order_num([_|Tail]) ->
order_num(Tail).
-spec broadcast(binary(), binary(), [#subscriber{}]) -> ok.
broadcast(Topic, Content, MatchedSubscribers) ->
lists:foreach(fun(#subscriber{subscriber_pid = SubscriberPid}) ->
SubscriberPid ! {topic_broadcast, Topic, Content}
end, MatchedSubscribers).
-spec remove_subscriber_pid(pid(), [#subscriber{}]) -> [#subscriber{}].
remove_subscriber_pid(SubscriberPid, Subscribers) when is_pid(SubscriberPid), is_list(Subscribers) ->
lists:filter(fun(#subscriber{subscriber_pid = SubscriberPid0}) -> SubscriberPid =/= SubscriberPid0 end, Subscribers).
-spec dispatch_remaining_messages(Subscriber :: #subscriber{}, RemainingMessages :: list()) -> RestRemainingMessages :: list().
dispatch_remaining_messages(#subscriber{subscriber_pid = SubscriberPid, components = Components}, RemainingMessages) when is_list(RemainingMessages) ->
%% 处理遗留的消息
lists:foldl(fun({Topic0, Content0}, Acc) ->
Components0 = of_components(Topic0),
case match_components(Components, Components0) of
true ->
SubscriberPid ! {topic_broadcast, Topic0, Content0},
Acc;
false ->
[{Topic0, Content0}|Acc]
end
end, [], RemainingMessages).