diff --git a/HTTP_API_README.md b/HTTP_API_README.md index 0b71f10..f06f2e9 100644 --- a/HTTP_API_README.md +++ b/HTTP_API_README.md @@ -27,9 +27,9 @@ %% {"result": Data} %%-------------------------------------------------------------------- json_data(Data) -> - jiffy:encode(#{ + iolist_to_binary(json:encode(#{ <<"result">> => Data - }, [force_utf8]). + })). %%-------------------------------------------------------------------- %% @doc @@ -42,12 +42,12 @@ json_data(Data) -> %% } %%-------------------------------------------------------------------- json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) -> - jiffy:encode(#{ + iolist_to_binary(json:encode(#{ <<"error">> => #{ <<"code">> => ErrCode, <<"message">> => ErrMessage } - }, [force_utf8]). + })). ``` ### 📘 返回格式说明 @@ -157,7 +157,7 @@ json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) | 参数名 | 类型 | 必填 | 说明 | |--------|------|------|------| | uuid | binary (string) | ✅ | 主机唯一标识符 | -| task_id | integer | ✅ | 任务 ID | +| task_id | integer | ✅ | 任务 ID,和 uuid 一起作为部署任务唯一标识 | | config | map | ✅ | 部署配置内容 | #### 响应参数 @@ -255,13 +255,16 @@ json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) #### 示例响应 ```json { - "result": { - "task_id": 1001, - "status": "deployed" - } + "data": "ok" } ``` +部署过程实时日志通过 SSE 获取: + +```http +GET /event_stream?uuid=qbxmjyzrkpntfgswaevodhluicqzxplkm&task_id=1 +``` + #### 错误响应 ```json { @@ -475,9 +478,9 @@ json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) %% {"result": Data} %%-------------------------------------------------------------------- json_data(Data) -> - jiffy:encode(#{ + iolist_to_binary(json:encode(#{ <<"result">> => Data - }, [force_utf8]). + })). %%-------------------------------------------------------------------- %% @doc @@ -490,12 +493,12 @@ json_data(Data) -> %% } %%-------------------------------------------------------------------- json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) -> - jiffy:encode(#{ + iolist_to_binary(json:encode(#{ <<"error">> => #{ <<"code">> => ErrCode, <<"message">> => ErrMessage } - }, [force_utf8]). + })). ``` ### 📘 返回格式说明 @@ -909,9 +912,9 @@ Content-Type: application/json %% {"result": Data} %%-------------------------------------------------------------------- json_data(Data) -> - jiffy:encode(#{ + iolist_to_binary(json:encode(#{ <<"result">> => Data - }, [force_utf8]). + })). %%-------------------------------------------------------------------- %% @doc @@ -924,12 +927,12 @@ json_data(Data) -> %% } %%-------------------------------------------------------------------- json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) -> - jiffy:encode(#{ + iolist_to_binary(json:encode(#{ <<"error">> => #{ <<"code">> => ErrCode, <<"message">> => ErrMessage } - }, [force_utf8]). + })). ``` ### 📘 返回格式说明 @@ -1079,7 +1082,7 @@ json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) | 参数名 | 类型 | 必填 | 说明 | |--------|------|------|------| | uuid | binary (string) | ✅ | 主机唯一标识符 | -| auth | boolean | ✅ | `true` 激活, `false` 取消激活 | +| auth | boolean | ✅ | `true` 激活, `false` 取消激活。该操作只修改 iot 本地和持久化授权状态,不通知 efka;efka 连接可继续保持在线,数据是否处理由 iot_host 状态决定。 | #### 响应参数 | 字段 | 类型 | 说明 | diff --git a/apps/iot/include/endpoint.hrl b/apps/endpoint/include/endpoint.hrl similarity index 100% rename from apps/iot/include/endpoint.hrl rename to apps/endpoint/include/endpoint.hrl diff --git a/apps/endpoint/src/endpoint.app.src b/apps/endpoint/src/endpoint.app.src new file mode 100644 index 0000000..abbec16 --- /dev/null +++ b/apps/endpoint/src/endpoint.app.src @@ -0,0 +1,24 @@ +{application, endpoint, + [{description, "Endpoint OTP application"}, + {vsn, "0.1.0"}, + {registered, [ + endpoint_sup, + endpoint_adapter_sup, + endpoint_subscription, + endpoint_log + ]}, + {mod, {endpoint_app, []}}, + {applications, [ + emqtt, + brod, + hackney, + gproc, + crypto, + kernel, + stdlib + ]}, + {env, []}, + {modules, []}, + {licenses, ["Apache 2.0"]}, + {links, []} + ]}. diff --git a/apps/iot/src/endpoint/endpoint.erl b/apps/endpoint/src/endpoint.erl similarity index 73% rename from apps/iot/src/endpoint/endpoint.erl rename to apps/endpoint/src/endpoint.erl index c1984e9..e7177d5 100644 --- a/apps/iot/src/endpoint/endpoint.erl +++ b/apps/endpoint/src/endpoint.erl @@ -31,32 +31,32 @@ start_link(Endpoint = #endpoint{id = Id, config = #kafka_endpoint{}}) -> LocalName = get_name(Id), endpoint_kafka:start_link(LocalName, Endpoint). --spec get_name(Id :: integer()) -> atom(). +-spec get_name(Id :: integer()) -> term(). get_name(Id) when is_integer(Id) -> - list_to_atom("endpoint:" ++ integer_to_list(Id)). + {endpoint, Id}. -spec get_pid(Id :: integer()) -> undefined | pid(). get_pid(Id) when is_integer(Id) -> - whereis(get_name(Id)). + gproc:whereis_name({n, l, get_name(Id)}). --spec get_alias_name(Name :: binary()) -> atom(). +-spec get_alias_name(Name :: binary()) -> term(). get_alias_name(Name) when is_binary(Name) -> - list_to_atom("endpoint:" ++ binary_to_list(Name)). + {endpoint_alias, Name}. -spec get_alias_pid(Name :: binary()) -> undefined | pid(). get_alias_pid(Name) when is_binary(Name) -> gproc:whereis_name({n, l, get_alias_name(Name)}). --spec forward(Pid :: pid(), Metric :: binary()) -> no_return(). +-spec forward(Pid :: pid(), Metric :: binary()) -> ok. forward(Pid, Metric) when is_pid(Pid), is_binary(Metric) -> gen_server:cast(Pid, {forward, Metric}). reload(Pid, NEndpoint = #endpoint{}) when is_pid(Pid) -> - gen_statem:cast(Pid, {reload, NEndpoint}). + gen_server:cast(Pid, {reload, NEndpoint}). -spec clean_up(Pid :: pid()) -> ok. clean_up(Pid) when is_pid(Pid) -> - gen_server:call(Pid, clean_up, 5000). + gen_server:cast(Pid, cleanup). -spec get_protocol(Endpoint :: #endpoint{}) -> atom(). get_protocol(#endpoint{config = #http_endpoint{}}) -> @@ -68,7 +68,7 @@ get_protocol(#endpoint{config = #kafka_endpoint{}}) -> -spec is_support(Protocol :: atom()) -> boolean(). is_support(Protocol) when is_atom(Protocol) -> - {ok, Props} = application:get_env(iot, endpoints), + {ok, Props} = application:get_env(endpoint, endpoints), SupportProtocols = proplists:get_value(support_protocols, Props, []), lists:member(Protocol, SupportProtocols). @@ -92,16 +92,27 @@ endpoint_record(#{<<"id">> := Id, <<"matcher">> := Matcher, <<"title">> := Title -spec parse_config(Protocol :: binary(), Config :: map()) -> {ok, #mqtt_endpoint{} | #kafka_endpoint{} | #http_endpoint{}} | {error, Errors :: [Error :: binary()]}. parse_config(<<"mqtt">>, #{<<"host">> := Host, <<"port">> := Port0, <<"client_id">> := ClientId, <<"username">> := Username, <<"password">> := Password, <<"topic">> := Topic, <<"qos">> := Qos}) -> - Port = if is_binary(Port0) -> binary_to_integer(Port0); is_integer(Port0) -> Port0 end, + {Port, PortErrors} = case parse_integer(Port0) of + {ok, ParsedPort} -> + {ParsedPort, []}; + error -> + {undefined, [<<"port invalid">>]} + end, - Errors = lists:filtermap(fun(Term) -> + CheckTerms = case Port of + undefined -> + [{host, Host}, {username, Username}, {password, Password}, {topic, Topic}, {qos, Qos}]; + _ -> + [{host, Host}, {port, Port}, {username, Username}, {password, Password}, {topic, Topic}, {qos, Qos}] + end, + Errors = PortErrors ++ lists:filtermap(fun(Term) -> case check_mqtt_argument(Term) of ok -> false; {error, Error} -> {true, Error} end - end, [{host, Host}, {port, Port}, {username, Username}, {password, Password}, {topic, Topic}, {qos, Qos}]), + end, CheckTerms), case Errors =:= [] of true -> {ok, #mqtt_endpoint{ @@ -118,11 +129,24 @@ parse_config(<<"mqtt">>, #{<<"host">> := Host, <<"port">> := Port0, <<"client_id end; parse_config(<<"http">>, C = #{<<"url">> := Url, <<"pool_size">> := PoolSize}) -> Token = maps:get(<<"token">>, C, <<>>), - {ok, #http_endpoint{ - url = Url, - token = Token, - pool_size = PoolSize - }}; + Errors = lists:filtermap(fun(Term) -> + case check_http_argument(Term) of + ok -> + false; + {error, Error} -> + {true, Error} + end + end, [{url, Url}, {token, Token}, {pool_size, PoolSize}]), + case Errors =:= [] of + true -> + {ok, #http_endpoint{ + url = Url, + token = Token, + pool_size = PoolSize + }}; + false -> + {error, Errors} + end; parse_config(<<"kafka">>, #{<<"sasl_config">> := #{<<"username">> := Username, <<"password">> := Password, <<"mechanism">> := Mechanism0}, <<"bootstrap_servers">> := BootstrapServers, <<"topic">> := Topic}) -> Errors = lists:filtermap(fun(Term) -> case check_kafka_argument(Term) of @@ -168,8 +192,8 @@ parse_config(_, _) -> -spec parse_kafka_bootstrap_servers(BootstrapServers :: [binary()]) -> Servers :: [{Host :: string(), Port :: integer()}]. parse_kafka_bootstrap_servers(BootstrapServers) when is_list(BootstrapServers) -> lists:map(fun(S) -> - [Host0, Port0] = binary:split(S, <<":">>), - {binary_to_list(Host0), binary_to_integer(Port0)} + {ok, Host, Port} = parse_kafka_bootstrap_server(S), + {Host, Port} end, BootstrapServers). -spec parse_kafka_mechanism(Mechanism0 :: binary()) -> atom(). @@ -218,17 +242,10 @@ check_kafka_argument({bootstrap_servers, BootstrapServers}) -> case is_list(BootstrapServers) andalso length(BootstrapServers) > 0 of true -> InvalidServers = lists:filtermap(fun(S) -> - case binary:split(S, <<":">>) of - [Host0, Port0] -> - Host = binary_to_list(Host0), - Port = binary_to_integer(Port0), - case not string:is_empty(Host) andalso (is_integer(Port) andalso Port > 0) of - true -> - false; - false -> - {true, S} - end; - _ -> + case parse_kafka_bootstrap_server(S) of + {ok, _Host, _Port} -> + false; + error -> {true, S} end end, BootstrapServers), @@ -243,6 +260,29 @@ check_kafka_argument({bootstrap_servers, BootstrapServers}) -> {error, <<"bootstrap_servers is empty">>} end. +-spec check_http_argument(tuple()) -> ok | {error, Reason :: binary()}. +check_http_argument({url, Url}) -> + case is_binary(Url) andalso Url /= <<>> of + true -> + ok; + false -> + {error, <<"url is empty">>} + end; +check_http_argument({token, Token}) -> + case is_binary(Token) of + true -> + ok; + false -> + {error, <<"token invalid">>} + end; +check_http_argument({pool_size, PoolSize}) -> + case is_integer(PoolSize) andalso PoolSize > 0 of + true -> + ok; + false -> + {error, <<"pool_size invalid">>} + end. + -spec check_mqtt_argument(tuple()) -> ok | {error, Reason :: binary()}. check_mqtt_argument({host, Host}) -> case Host /= <<>> of @@ -285,4 +325,35 @@ check_mqtt_argument({qos, Qos}) -> ok; false -> {error, <<"qos invalid">>} - end. \ No newline at end of file + end. + +-spec parse_integer(term()) -> {ok, integer()} | error. +parse_integer(Value) when is_integer(Value) -> + {ok, Value}; +parse_integer(Value) when is_binary(Value) -> + try binary_to_integer(Value) of + Int -> + {ok, Int} + catch + _:_ -> + error + end; +parse_integer(_) -> + error. + +-spec parse_kafka_bootstrap_server(term()) -> {ok, string(), integer()} | error. +parse_kafka_bootstrap_server(Server) when is_binary(Server) -> + case binary:split(Server, <<":">>) of + [Host0, Port0] -> + Host = binary_to_list(Host0), + case parse_integer(Port0) of + {ok, Port} when Host =/= [], Port > 0 -> + {ok, Host, Port}; + _ -> + error + end; + _ -> + error + end; +parse_kafka_bootstrap_server(_) -> + error. diff --git a/apps/iot/src/endpoint/endpoint_sup.erl b/apps/endpoint/src/endpoint_adapter_sup.erl similarity index 68% rename from apps/iot/src/endpoint/endpoint_sup.erl rename to apps/endpoint/src/endpoint_adapter_sup.erl index ad59018..09b63f1 100644 --- a/apps/iot/src/endpoint/endpoint_sup.erl +++ b/apps/endpoint/src/endpoint_adapter_sup.erl @@ -3,13 +3,14 @@ %% @end %%%------------------------------------------------------------------- --module(endpoint_sup). +-module(endpoint_adapter_sup). -behaviour(supervisor). -include("endpoint.hrl"). -export([start_link/0]). -export([ensured_endpoint_started/1, delete_endpoint/1]). +-export([start_endpoints/1]). -export([init/1]). @@ -29,21 +30,15 @@ start_link() -> %% modules => modules()} % optional init([]) -> SupFlags = #{strategy => one_for_one, intensity => 1000, period => 3600}, - Endpoints = iot_api:get_all_endpoints(), - ChildSpecs = lists:filtermap(fun(EndpointInfo) -> - case endpoint:endpoint_record(EndpointInfo) of - error -> - false; - {ok, Endpoint} -> - case endpoint:is_support(endpoint:get_protocol(Endpoint)) of - true -> - {true, child_spec(Endpoint)}; - false -> - false - end - end - end, Endpoints), - {ok, {SupFlags, ChildSpecs}}. + {ok, {SupFlags, []}}. + +-spec start_endpoints(Endpoints :: list()) -> ok. +start_endpoints(Endpoints) when is_list(Endpoints) -> + lists:foreach(fun(Endpoint) -> + Spec = child_spec(Endpoint), + {ok, _} = supervisor:start_child(?MODULE, Spec) + end, Endpoints), + ok. -spec ensured_endpoint_started(Endpoint :: #endpoint{}) -> {ok, Pid :: pid()} | {error, Reason :: any()}. ensured_endpoint_started(Endpoint = #endpoint{}) -> @@ -59,8 +54,24 @@ ensured_endpoint_started(Endpoint = #endpoint{}) -> -spec delete_endpoint(Id :: integer()) -> ok | {error, Reason :: any()}. delete_endpoint(Id) when is_integer(Id) -> Name = endpoint:get_name(Id), - supervisor:terminate_child(?MODULE, Name), - supervisor:delete_child(?MODULE, Name). + case supervisor:terminate_child(?MODULE, Name) of + ok -> + delete_endpoint_child(Name); + {error, not_found} -> + delete_endpoint_child(Name); + {error, Reason} -> + {error, Reason} + end. + +delete_endpoint_child(Name) -> + case supervisor:delete_child(?MODULE, Name) of + ok -> + ok; + {error, not_found} -> + ok; + {error, Reason} -> + {error, Reason} + end. child_spec(Endpoint = #endpoint{id = Id}) -> Name = endpoint:get_name(Id), @@ -69,4 +80,4 @@ child_spec(Endpoint = #endpoint{id = Id}) -> restart => permanent, shutdown => 2000, type => worker, - modules => ['endpoint']}. \ No newline at end of file + modules => ['endpoint']}. diff --git a/apps/endpoint/src/endpoint_app.erl b/apps/endpoint/src/endpoint_app.erl new file mode 100644 index 0000000..d86e2a7 --- /dev/null +++ b/apps/endpoint/src/endpoint_app.erl @@ -0,0 +1,15 @@ +%%%------------------------------------------------------------------- +%% @doc endpoint application entrypoint. +%% @end +%%%------------------------------------------------------------------- +-module(endpoint_app). + +-behaviour(application). + +-export([start/2, stop/1]). + +start(_StartType, _StartArgs) -> + endpoint_sup:start_link(). + +stop(_State) -> + ok. diff --git a/apps/endpoint/src/endpoint_buffer.erl b/apps/endpoint/src/endpoint_buffer.erl new file mode 100644 index 0000000..05f5da7 --- /dev/null +++ b/apps/endpoint/src/endpoint_buffer.erl @@ -0,0 +1,304 @@ + +%%%------------------------------------------------------------------- +%%% @author aresei +%%% @copyright (C) 2023, +%%% @doc +%%% +%%% @end +%%% Created : 06. 7月 2023 12:02 +%%%------------------------------------------------------------------- +-module(endpoint_buffer). + +-include("endpoint.hrl"). + +%% 消息重发间隔 +-define(RETRY_INTERVAL, 5000). +%% 最大重试次数,不包含首次发送 +-define(MAX_RETRY_TIMES, 3). +%% 与 endpoint_outbox 的单条记录限制保持一致。 +-define(MAX_PAYLOAD_BYTES, 32 * 1024). + +-export([new/2, append/2, append_only/2, trigger_next/1, trigger_n/1, handle_timeout/4, cleanup/1, recover_inflight/1, ack/2, stat/1, resize/2]). +-export_type([buffer/0]). + +-type flight_source() :: outbox | memory. +-type timer_entry() :: {reference(), binary(), non_neg_integer(), flight_source()}. + +-record(buffer, { + endpoint :: #endpoint{}, + outbox :: endpoint_outbox:outbox(), + %% 当前待 ack 的数据及其重试定时器 #{Id => {TimerRef, Payload, RetryTimes, Source}} + timer_map = #{} :: #{integer() => timer_entry()}, + %% 窗口大小,允许最大的未确认消息数 + window_size = 10, + %% 未确认的消息数 + flight_num = 0, + %% 记录成功处理的消息数 + acc_num = 0 +}). + +-type buffer() :: #buffer{}. + +-spec new(Endpoint :: #endpoint{}, WindowSize :: integer()) -> Buffer :: #buffer{}. +new(Endpoint = #endpoint{id = Id}, WindowSize) when is_integer(WindowSize), WindowSize > 0 -> + %% 读取配置文件 + {ok, Endpoints} = application:get_env(endpoint, endpoints), + RootDir = proplists:get_value(root_dir, Endpoints), + OutboxDir = filename:join(RootDir, integer_to_list(Id)), + + {ok, Outbox} = endpoint_outbox:open(OutboxDir, #{}), + #buffer{outbox = Outbox, endpoint = Endpoint, window_size = WindowSize}. + +-spec append(Payload :: binary(), Buffer :: #buffer{}) -> NBuffer :: #buffer{}. +append(Payload, Buffer = #buffer{}) when is_binary(Payload) -> + case validate_payload_size(Payload, Buffer) of + ok -> + %% Persist before dispatch so a VM crash does not lose in-flight data. + case append_to_outbox(Payload, Buffer) of + {ok, NBuffer} -> + trigger_next(NBuffer); + {dropped, NBuffer} -> + trigger_next(NBuffer); + {error, NBuffer} -> + NBuffer + end; + error -> + Buffer + end. + +-spec append_only(Payload :: binary(), Buffer :: #buffer{}) -> NBuffer :: #buffer{}. +append_only(Payload, Buffer = #buffer{}) when is_binary(Payload) -> + case validate_payload_size(Payload, Buffer) of + ok -> + case append_to_outbox(Payload, Buffer) of + {ok, NBuffer} -> + NBuffer; + {dropped, NBuffer} -> + NBuffer; + {error, NBuffer} -> + NBuffer + end; + error -> + Buffer + end. + +-spec trigger_n(Buffer :: #buffer{}) -> NBuffer :: #buffer{}. +trigger_n(Buffer = #buffer{window_size = WindowSize}) -> + %% 最多允许window_size + lists:foldl(fun(_, Buffer0) -> trigger_next(Buffer0) end, Buffer, lists:seq(1, WindowSize)). + +%% 触发读取下一条数据 +-spec trigger_next(Buffer :: #buffer{}) -> NBuffer :: #buffer{}. +trigger_next(Buffer = #buffer{outbox = Outbox, flight_num = FlightNum, window_size = WindowSize}) -> + case FlightNum < WindowSize of + false -> + Buffer; + true -> + case endpoint_outbox:next(Outbox) of + eof -> + Buffer; + {ok, Id, Payload, NOutbox} -> + ReceiverPid = self(), + ReceiverPid ! {next_data, Id, Payload}, + schedule_retry(Id, Payload, 0, outbox, Buffer#buffer{outbox = NOutbox, flight_num = FlightNum + 1}); + {error, Reason} -> + logger:warning("[endpoint_buffer] read next outbox failed, endpoint_id: ~p, reason: ~p", + [buffer_endpoint_id(Buffer), Reason]), + Buffer + end + end. + +-spec handle_timeout(reference(), integer(), binary(), #buffer{}) -> #buffer{}. +handle_timeout(TimerRef, Id, _Payload, Buffer = #buffer{timer_map = TimerMap}) + when is_reference(TimerRef), is_integer(Id) -> + case maps:take(Id, TimerMap) of + {{TimerRef, Payload, RetryTimes, Source}, NTimerMap} when RetryTimes < ?MAX_RETRY_TIMES -> + logger:warning("[endpoint_buffer] retry message, endpoint_id: ~p, id: ~p, retry: ~p/~p", + [buffer_endpoint_id(Buffer), Id, RetryTimes + 1, ?MAX_RETRY_TIMES]), + ReceiverPid = self(), + ReceiverPid ! {next_data, Id, Payload}, + schedule_retry(Id, Payload, RetryTimes + 1, Source, Buffer#buffer{timer_map = NTimerMap}); + {{TimerRef, Payload, RetryTimes, outbox}, NTimerMap} -> + logger:warning("[endpoint_buffer] drop message after retries exhausted, endpoint_id: ~p, id: ~p, retries: ~p", + [buffer_endpoint_id(Buffer), Id, RetryTimes]), + drop_outbox_message(Id, Payload, RetryTimes, Buffer#buffer{timer_map = NTimerMap}); + {{TimerRef, _, RetryTimes, memory}, NTimerMap} -> + logger:warning("[endpoint_buffer] drop memory message after retries exhausted, endpoint_id: ~p, id: ~p, retries: ~p", + [buffer_endpoint_id(Buffer), Id, RetryTimes]), + drop_memory_message(Buffer#buffer{timer_map = NTimerMap}); + {{_OtherTimerRef, _OtherPayload, _RetryTimes, _Source}, _NTimerMap} -> + Buffer; + error -> + Buffer + end. + +-spec ack(Id :: integer(), Buffer :: #buffer{}) -> NBuffer :: #buffer{}. +ack(Id, Buffer = #buffer{timer_map = TimerMap, outbox = Outbox, acc_num = AccNum, flight_num = FlightNum}) when is_integer(Id) -> + case maps:take(Id, TimerMap) of + {{TimerRef, _Payload, _RetryTimes, memory}, NTimerMap} -> + _ = erlang:cancel_timer(TimerRef), + NBuffer = Buffer#buffer{ + timer_map = NTimerMap, + acc_num = AccNum + 1, + flight_num = max(FlightNum - 1, 0) + }, + trigger_next(NBuffer); + {{TimerRef, Payload, RetryTimes, outbox}, NTimerMap} -> + _ = erlang:cancel_timer(TimerRef), + case endpoint_outbox:ack(Id, Outbox) of + {ok, NOutbox} -> + NBuffer = Buffer#buffer{ + timer_map = NTimerMap, + outbox = NOutbox, + acc_num = AccNum + 1, + flight_num = max(FlightNum - 1, 0) + }, + trigger_next(NBuffer); + {error, Reason} -> + logger:warning("[endpoint_buffer] ack outbox failed, endpoint_id: ~p, id: ~p, reason: ~p", + [buffer_endpoint_id(Buffer), Id, Reason]), + schedule_retry(Id, Payload, RetryTimes, outbox, Buffer#buffer{timer_map = NTimerMap}) + end; + error -> + Buffer + end. + +%% 获取当前统计信息 +-spec stat(Buffer :: #buffer{}) -> map(). +stat(#buffer{acc_num = AccNum, outbox = Outbox, flight_num = FlightNum, timer_map = TimerMap}) -> + OutboxStat = endpoint_outbox:stat(Outbox), + WriteSeq = maps:get(write_seq, OutboxStat, 0), + AckedSeq = maps:get(acked_seq, OutboxStat, 0), + OutboxFlightNum = count_inflight(outbox, TimerMap), + MemoryFlightNum = count_inflight(memory, TimerMap), + QueueNum = max(WriteSeq - AckedSeq - OutboxFlightNum, 0), + OutboxStat#{ + <<"acc_num">> => AccNum, + <<"queue_num">> => QueueNum, + <<"inflight_num">> => FlightNum, + <<"outbox_inflight_num">> => OutboxFlightNum, + <<"memory_inflight_num">> => MemoryFlightNum + }. + +-spec cleanup(Buffer :: #buffer{}) -> #buffer{}. +cleanup(Buffer = #buffer{timer_map = TimerMap}) -> + cancel_timers(TimerMap), + NBuffer0 = persist_memory_inflight(Buffer), + reset_reader_after_recover(NBuffer0#buffer{timer_map = #{}, flight_num = 0}). + +-spec recover_inflight(Buffer :: #buffer{}) -> #buffer{}. +recover_inflight(Buffer = #buffer{timer_map = TimerMap}) -> + cancel_timers(TimerMap), + NBuffer0 = persist_memory_inflight(Buffer), + case endpoint_outbox:reset_reader(NBuffer0#buffer.outbox) of + {ok, NOutbox} -> + NBuffer0#buffer{outbox = NOutbox, timer_map = #{}, flight_num = 0} + end. + +-spec resize(Buffer :: #buffer{}, WindowSize :: integer()) -> #buffer{}. +resize(Buffer = #buffer{}, WindowSize) when is_integer(WindowSize), WindowSize > 0 -> + trigger_n(Buffer#buffer{window_size = WindowSize}). + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +-spec buffer_endpoint_id(buffer()) -> integer(). +buffer_endpoint_id(#buffer{endpoint = #endpoint{id = Id}}) -> + Id. + +-spec validate_payload_size(binary(), buffer()) -> ok | error. +validate_payload_size(Payload, Buffer) -> + case byte_size(Payload) =< ?MAX_PAYLOAD_BYTES of + true -> + ok; + false -> + logger:warning("[endpoint_buffer] payload too large, endpoint_id: ~p, size: ~p, max_size: ~p", + [buffer_endpoint_id(Buffer), byte_size(Payload), ?MAX_PAYLOAD_BYTES]), + error + end. + +-spec append_to_outbox(binary(), buffer()) -> {ok | dropped | error, buffer()}. +append_to_outbox(Payload, Buffer = #buffer{outbox = Outbox}) -> + case endpoint_outbox:append(Payload, Outbox) of + {ok, _Seq, NOutbox} -> + {ok, Buffer#buffer{outbox = NOutbox}}; + {dropped, capacity_reached, NOutbox} -> + logger:warning("[endpoint_buffer] outbox capacity reached, endpoint_id: ~p", [buffer_endpoint_id(Buffer)]), + {dropped, Buffer#buffer{outbox = NOutbox}}; + {error, Reason} -> + logger:warning("[endpoint_buffer] append outbox failed, endpoint_id: ~p, reason: ~p", + [buffer_endpoint_id(Buffer), Reason]), + {error, Buffer} + end. + +-spec schedule_retry(integer(), binary(), non_neg_integer(), flight_source(), buffer()) -> buffer(). +schedule_retry(Id, Payload, RetryTimes, Source, Buffer = #buffer{timer_map = TimerMap}) + when is_integer(Id), is_binary(Payload), is_integer(RetryTimes), RetryTimes >= 0 -> + TimerRef = erlang:start_timer(?RETRY_INTERVAL, self(), {endpoint_buffer_retry, Id, Payload}), + Buffer#buffer{timer_map = maps:put(Id, {TimerRef, Payload, RetryTimes, Source}, TimerMap)}. + +-spec cancel_timers(#{integer() => timer_entry()}) -> ok. +cancel_timers(TimerMap) -> + lists:foreach(fun({_Id, {TimerRef, _Payload, _RetryTimes, _Source}}) -> + _ = erlang:cancel_timer(TimerRef), + ok + end, maps:to_list(TimerMap)), + ok. + +-spec drop_outbox_message(integer(), binary(), non_neg_integer(), buffer()) -> buffer(). +drop_outbox_message(Id, Payload, RetryTimes, Buffer = #buffer{outbox = Outbox, acc_num = AccNum, flight_num = FlightNum}) -> + case endpoint_outbox:ack(Id, Outbox) of + {ok, NOutbox} -> + trigger_next(Buffer#buffer{ + outbox = NOutbox, + acc_num = AccNum + 1, + flight_num = max(FlightNum - 1, 0) + }); + {error, Reason} -> + logger:warning("[endpoint_buffer] ack dropped message failed, endpoint_id: ~p, id: ~p, reason: ~p", + [buffer_endpoint_id(Buffer), Id, Reason]), + schedule_retry(Id, Payload, RetryTimes, outbox, Buffer) + end. + +-spec drop_memory_message(buffer()) -> buffer(). +drop_memory_message(Buffer = #buffer{acc_num = AccNum, flight_num = FlightNum}) -> + trigger_next(Buffer#buffer{ + acc_num = AccNum + 1, + flight_num = max(FlightNum - 1, 0) + }). + +-spec persist_memory_inflight(buffer()) -> buffer(). +persist_memory_inflight(Buffer = #buffer{timer_map = TimerMap}) -> + MemoryInflight = lists:sort( + fun({IdA, _PayloadA}, {IdB, _PayloadB}) -> IdA > IdB end, + [{Id, Payload} || {Id, {_TimerRef, Payload, _RetryTimes, memory}} <- maps:to_list(TimerMap)] + ), + lists:foldl(fun({_Id, Payload}, AccBuffer) -> + case append_to_outbox(Payload, AccBuffer) of + {ok, NBuffer} -> + NBuffer; + {dropped, NBuffer} -> + NBuffer; + {error, NBuffer} -> + NBuffer + end + end, Buffer, MemoryInflight). + +-spec reset_reader_after_recover(buffer()) -> buffer(). +reset_reader_after_recover(Buffer = #buffer{outbox = Outbox}) -> + case endpoint_outbox:reset_reader(Outbox) of + {ok, NOutbox} -> + Buffer#buffer{outbox = NOutbox} + end. + +-spec count_inflight(flight_source(), #{integer() => timer_entry()}) -> non_neg_integer(). +count_inflight(Source, TimerMap) -> + maps:fold(fun(_Id, {_TimerRef, _Payload, _RetryTimes, EntrySource}, Acc) -> + case EntrySource =:= Source of + true -> + Acc + 1; + false -> + Acc + end + end, 0, TimerMap). diff --git a/apps/iot/src/endpoint/endpoint_http.erl b/apps/endpoint/src/endpoint_http.erl similarity index 58% rename from apps/iot/src/endpoint/endpoint_http.erl rename to apps/endpoint/src/endpoint_http.erl index 752b274..38a36fe 100644 --- a/apps/iot/src/endpoint/endpoint_http.erl +++ b/apps/endpoint/src/endpoint_http.erl @@ -30,10 +30,10 @@ %%%=================================================================== %% @doc Spawns the server and registers the local name (unique) --spec(start_link(LocalName :: atom(), Endpoint :: #endpoint{}) -> +-spec(start_link(LocalName :: term(), Endpoint :: #endpoint{}) -> {ok, Pid :: pid()} | ignore | {error, Reason :: term()}). -start_link(LocalName, Endpoint = #endpoint{config = #http_endpoint{}}) when is_atom(LocalName) -> - gen_server:start_link({local, LocalName}, ?MODULE, [Endpoint], []). +start_link(LocalName, Endpoint = #endpoint{config = #http_endpoint{}}) -> + gen_server:start_link({via, gproc, {n, l, LocalName}}, ?MODULE, [Endpoint], []). %%%=================================================================== %%% gen_server callbacks @@ -44,9 +44,10 @@ start_link(LocalName, Endpoint = #endpoint{config = #http_endpoint{}}) when is_a -spec(init(Args :: term()) -> {ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} | {stop, Reason :: term()} | ignore). -init([Endpoint = #endpoint{matcher = Matcher}]) -> +init([Endpoint = #endpoint{matcher = Matcher, config = #http_endpoint{pool_size = PoolSize}}]) -> + ok = endpoint_util:set_metadata(), endpoint_subscription:subscribe(Matcher, self()), - Buffer = endpoint_buffer:new(Endpoint, 10), + Buffer = endpoint_buffer:new(Endpoint, PoolSize), {ok, #state{endpoint = Endpoint, buffer = Buffer}}. %% @private @@ -69,20 +70,17 @@ handle_call(get_stat, _From, State = #state{buffer = Buffer}) -> {noreply, NewState :: #state{}} | {noreply, NewState :: #state{}, timeout() | hibernate} | {stop, Reason :: term(), NewState :: #state{}}). -handle_cast({forward, Metric}, State = #state{buffer = Buffer, endpoint = #endpoint{config = #http_endpoint{token = Token}}}) -> - Tuple = case is_binary(Token) andalso Token /= <<>> of - true -> - Sign = iot_util:sha256(erlang:iolist_to_binary([Token, Metric, Token])), - {Metric, Sign}; - false -> - {Metric, <<>>} - end, - NBuffer = endpoint_buffer:append(Tuple, Buffer), +handle_cast({forward, Metric}, State = #state{buffer = Buffer}) -> + NBuffer = endpoint_buffer:append(Metric, Buffer), {noreply, State#state{buffer = NBuffer}}; +handle_cast({reload, NEndpoint = #endpoint{matcher = NMatcher, config = #http_endpoint{pool_size = PoolSize}}}, State = #state{endpoint = #endpoint{matcher = Matcher}, buffer = Buffer}) -> + ensure_subscription(Matcher, NMatcher), + NBuffer = endpoint_buffer:resize(Buffer, PoolSize), + {noreply, State#state{endpoint = NEndpoint, buffer = NBuffer}}; handle_cast(cleanup, State = #state{buffer = Buffer}) -> - endpoint_buffer:cleanup(Buffer), - {noreply, State}. + NBuffer = endpoint_buffer:cleanup(Buffer), + {noreply, State#state{buffer = NBuffer}}. %% @private %% @doc Handling all non call/cast messages @@ -90,32 +88,25 @@ handle_cast(cleanup, State = #state{buffer = Buffer}) -> {noreply, NewState :: #state{}} | {noreply, NewState :: #state{}, timeout() | hibernate} | {stop, Reason :: term(), NewState :: #state{}}). -handle_info({next_data, Id, {Metric, Sign}}, State = #state{buffer = Buffer, endpoint = #endpoint{config = #http_endpoint{url = Url}}}) -> - BaseHeaders = [{<<"Content-Type">>, <<"application/json">>}], - ExtraHeaders = if - Sign =:= <<>> -> []; - true -> [{<<"X-Signature">>, Sign}] - end, - Headers = BaseHeaders ++ ExtraHeaders, - +handle_info({next_data, Id, Metric}, State = #state{buffer = Buffer, endpoint = #endpoint{config = #http_endpoint{url = Url, token = Token}}}) -> + Headers = [{<<"Content-Type">>, <<"application/json">>}] ++ patch_headers(Metric, Token), case hackney:request(post, Url, Headers, Metric) of - {ok, 200, _, ClientRef} -> - {ok, RespBody} = hackney:body(ClientRef), - hackney:close(ClientRef), - lager:debug("[endpoint_http] url: ~p, response is: ~p", [Url, RespBody]), + {ok, HttpCode, _, ClientRef} when HttpCode >= 200, HttpCode < 300 -> + RespBody = read_response_body(ClientRef), + logger:debug("[endpoint_http] url: ~p, response is: ~p", [Url, RespBody]), NBuffer = endpoint_buffer:ack(Id, Buffer), - {noreply, State#state{buffer = NBuffer}}; {ok, HttpCode, _, ClientRef} -> - {ok, RespBody} = hackney:body(ClientRef), - hackney:close(ClientRef), - lager:debug("[endpoint_http] url: ~p, http_code: ~p, response is: ~p", [Url, HttpCode, RespBody]), - NBuffer = endpoint_buffer:ack(Id, Buffer), - {noreply, State#state{buffer = NBuffer}}; + RespBody = read_response_body(ClientRef), + logger:warning("[endpoint_http] url: ~p, http_code: ~p, response is: ~p", [Url, HttpCode, RespBody]), + {noreply, State}; {error, Reason} -> - lager:warning("[endpoint_http] url: ~p, get error: ~p", [Url, Reason]), + logger:warning("[endpoint_http] url: ~p, get error: ~p", [Url, Reason]), {noreply, State} - end. + end; +handle_info({timeout, TimerRef, {endpoint_buffer_retry, Id, Payload}}, State = #state{buffer = Buffer}) -> + NBuffer = endpoint_buffer:handle_timeout(TimerRef, Id, Payload, Buffer), + {noreply, State#state{buffer = NBuffer}}. %% @private %% @doc This function is called by a gen_server when it is about to @@ -124,7 +115,8 @@ handle_info({next_data, Id, {Metric, Sign}}, State = #state{buffer = Buffer, end %% with Reason. The return value is ignored. -spec(terminate(Reason :: (normal | shutdown | {shutdown, term()} | term()), State :: #state{}) -> term()). -terminate(_Reason, _State = #state{}) -> +terminate(_Reason, #state{buffer = Buffer}) -> + _ = endpoint_buffer:cleanup(Buffer), ok. %% @private @@ -138,3 +130,28 @@ code_change(_OldVsn, State = #state{}, _Extra) -> %%%=================================================================== %%% Internal functions %%%=================================================================== + +-spec ensure_subscription(binary(), binary()) -> ok. +ensure_subscription(Matcher, Matcher) -> + ok; +ensure_subscription(Matcher, NMatcher) -> + ok = endpoint_subscription:unsubscribe(Matcher, self()), + endpoint_subscription:subscribe(NMatcher, self()). + +-spec read_response_body(reference()) -> binary() | term(). +read_response_body(ClientRef) -> + case hackney:body(ClientRef) of + {ok, RespBody} -> + hackney:close(ClientRef), + RespBody; + {error, Reason} -> + hackney:close(ClientRef), + Reason + end. + +-spec patch_headers(Metric :: binary(), any()) -> list(). +patch_headers(Metric, Token) when is_binary(Token), Token /= <<>> -> + Sign = endpoint_util:sha256(erlang:iolist_to_binary([Token, Metric, Token])), + [{<<"X-Signature">>, Sign}]; +patch_headers(_, _) -> + []. diff --git a/apps/endpoint/src/endpoint_kafka.erl b/apps/endpoint/src/endpoint_kafka.erl new file mode 100644 index 0000000..2286c30 --- /dev/null +++ b/apps/endpoint/src/endpoint_kafka.erl @@ -0,0 +1,261 @@ +%%%------------------------------------------------------------------- +%%% @author aresei +%%% @copyright (C) 2023, +%%% @doc +%%% +%%% @end +%%% Created : 06. 7月 2023 12:02 +%%%------------------------------------------------------------------- +-module(endpoint_kafka). + +-include("endpoint.hrl"). +-behaviour(gen_statem). + +%% API +-export([start_link/2]). + +%% gen_statem callbacks +-export([callback_mode/0, init/1, terminate/3, code_change/4]). +-export([disconnected/3, connected/3]). + +%% 消息重发间隔 +-define(RETRY_INTERVAL, 5000). + +-record(state, { + endpoint :: #endpoint{}, + buffer :: endpoint_buffer:buffer(), + client_id :: atom(), + client_pid :: undefined | pid() +}). + +-type kafka_state() :: disconnected | connected. + +%%%=================================================================== +%%% API +%%%=================================================================== + +-spec start_link(LocalName :: term(), Endpoint :: #endpoint{}) -> + {ok, pid()} | ignore | {error, term()}. +start_link(LocalName, Endpoint = #endpoint{}) -> + gen_statem:start_link({via, gproc, {n, l, LocalName}}, ?MODULE, [Endpoint], []). + +%%%=================================================================== +%%% gen_statem callbacks +%%%=================================================================== + +-spec callback_mode() -> state_functions. +callback_mode() -> + state_functions. + +-spec init(term()) -> gen_statem:init_result(kafka_state(), #state{}). +init([Endpoint = #endpoint{id = Id, matcher = Matcher}]) -> + ok = endpoint_util:set_metadata(), + erlang:process_flag(trap_exit, true), + ok = endpoint_subscription:subscribe(Matcher, self()), + Buffer = endpoint_buffer:new(Endpoint, 10), + ClientId = list_to_atom("brod_client:" ++ integer_to_list(Id)), + {ok, disconnected, #state{endpoint = Endpoint, buffer = Buffer, client_id = ClientId}, + [{state_timeout, 0, connect}]}. + +-spec disconnected(gen_statem:event_type(), term(), #state{}) -> + gen_statem:event_handler_result(kafka_state(), #state{}). +disconnected({call, From}, get_stat, State = #state{buffer = Buffer}) -> + reply_stat(From, Buffer, State); +disconnected(cast, {forward, Metric}, State = #state{buffer = Buffer}) -> + store_metric(Metric, Buffer, State); +disconnected(cast, cleanup, State = #state{buffer = Buffer}) -> + cleanup_buffer(Buffer, State); +disconnected(cast, {reload, NEndpoint = #endpoint{matcher = NMatcher}}, + State = #state{endpoint = #endpoint{matcher = Matcher}, client_id = ClientId}) -> + reload_endpoint(Matcher, NMatcher, ClientId, NEndpoint, State); +disconnected(state_timeout, connect, State) -> + try_connect(State); +disconnected(info, {timeout, TimerRef, {endpoint_buffer_retry, Id, Payload}}, State = #state{buffer = Buffer}) -> + NBuffer = endpoint_buffer:handle_timeout(TimerRef, Id, Payload, Buffer), + {keep_state, State#state{buffer = NBuffer}}; +disconnected(info, {next_data, _Id, _Tuple}, State) -> + {keep_state, State}; +disconnected(info, {ack, Id}, State = #state{buffer = Buffer}) -> + ack_buffer(Id, Buffer, State); +disconnected(info, Info, State) -> + unknown_info(Info, disconnected, State); +disconnected(EventType, EventContent, State) -> + unknown_event(EventType, EventContent, disconnected, State). + +-spec connected(gen_statem:event_type(), term(), #state{}) -> + gen_statem:event_handler_result(kafka_state(), #state{}). +connected({call, From}, get_stat, State = #state{buffer = Buffer}) -> + reply_stat(From, Buffer, State); +connected(cast, {forward, Metric}, State = #state{buffer = Buffer}) -> + forward_metric(Metric, Buffer, State); +connected(cast, cleanup, State = #state{buffer = Buffer}) -> + cleanup_buffer(Buffer, State); +connected(cast, {reload, NEndpoint = #endpoint{matcher = NMatcher}}, + State = #state{endpoint = #endpoint{matcher = Matcher}, client_id = ClientId}) -> + reload_endpoint(Matcher, NMatcher, ClientId, NEndpoint, State); +connected(info, {next_data, Id, Metric}, + State = #state{ + buffer = Buffer, + client_pid = ClientPid, + client_id = ClientId, + endpoint = #endpoint{config = #kafka_endpoint{topic = Topic}} + }) -> + ReceiverPid = self(), + AckCb = fun(Partition, BaseOffset) -> + logger:debug("[endpoint_kafka] ack partion: ~p, offset: ~p", [Partition, BaseOffset]), + ReceiverPid ! {ack, Id} + end, + case catch brod:produce_cb(ClientPid, Topic, random, <<>>, Metric, AckCb) of + {ok, _CallRef} -> + {keep_state, State}; + {ok, _CallRef, _ProducerPid} -> + {keep_state, State}; + {error, Reason} -> + logger:warning("[endpoint_kafka] produce topic: ~p, get error: ~p", [Topic, Reason]), + stop_kafka_client(ClientId), + NBuffer = endpoint_buffer:recover_inflight(Buffer), + {next_state, disconnected, State#state{client_pid = undefined, buffer = NBuffer}, + [{state_timeout, ?RETRY_INTERVAL, connect}]}; + {'EXIT', Reason} -> + logger:warning("[endpoint_kafka] produce topic: ~p, exit with reason: ~p", [Topic, Reason]), + stop_kafka_client(ClientId), + NBuffer = endpoint_buffer:recover_inflight(Buffer), + {next_state, disconnected, State#state{client_pid = undefined, buffer = NBuffer}, + [{state_timeout, ?RETRY_INTERVAL, connect}]} + end; +connected(info, {timeout, TimerRef, {endpoint_buffer_retry, Id, Payload}}, State = #state{buffer = Buffer}) -> + NBuffer = endpoint_buffer:handle_timeout(TimerRef, Id, Payload, Buffer), + {keep_state, State#state{buffer = NBuffer}}; +connected(info, {ack, Id}, State = #state{buffer = Buffer}) -> + ack_buffer(Id, Buffer, State); +connected(info, {'EXIT', ClientPid, Reason}, + State = #state{client_pid = ClientPid, endpoint = #endpoint{title = Title}, buffer = Buffer}) -> + logger:warning("[endpoint_kafka] endpoint: ~p, conn pid exit with reason: ~p", [Title, Reason]), + NBuffer = endpoint_buffer:recover_inflight(Buffer), + {next_state, disconnected, State#state{client_pid = undefined, buffer = NBuffer}, + [{state_timeout, ?RETRY_INTERVAL, connect}]}; +connected(info, Info, State) -> + unknown_info(Info, connected, State); +connected(EventType, EventContent, State) -> + unknown_event(EventType, EventContent, connected, State). + +-spec terminate(term(), kafka_state(), #state{}) -> term(). +terminate(Reason, _StateName, #state{endpoint = #endpoint{title = Title}, buffer = Buffer, client_id = ClientId}) -> + logger:debug("[endpoint_kafka] endpoint: ~p, terminate with reason: ~p", [Title, Reason]), + stop_kafka_client(ClientId), + endpoint_buffer:cleanup(Buffer), + ok. + +-spec code_change(term() | {down, term()}, kafka_state(), #state{}, term()) -> + {ok, kafka_state(), #state{}} | {error, term()}. +code_change(_OldVsn, StateName, State = #state{}, _Extra) -> + {ok, StateName, State}. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +-spec reply_stat(gen_statem:from(), endpoint_buffer:buffer(), #state{}) -> + gen_statem:event_handler_result(kafka_state(), #state{}). +reply_stat(From, Buffer, State) -> + Stat = endpoint_buffer:stat(Buffer), + {keep_state, State, [{reply, From, {ok, Stat}}]}. + +-spec forward_metric(binary(), endpoint_buffer:buffer(), #state{}) -> + gen_statem:event_handler_result(kafka_state(), #state{}). +forward_metric(Metric, Buffer, State) -> + NBuffer = endpoint_buffer:append(Metric, Buffer), + {keep_state, State#state{buffer = NBuffer}}. + +-spec store_metric(binary(), endpoint_buffer:buffer(), #state{}) -> + gen_statem:event_handler_result(kafka_state(), #state{}). +store_metric(Metric, Buffer, State) -> + NBuffer = endpoint_buffer:append_only(Metric, Buffer), + {keep_state, State#state{buffer = NBuffer}}. + +-spec cleanup_buffer(endpoint_buffer:buffer(), #state{}) -> + gen_statem:event_handler_result(kafka_state(), #state{}). +cleanup_buffer(Buffer, _State) -> + NBuffer = endpoint_buffer:cleanup(Buffer), + {keep_state, _State#state{buffer = NBuffer}}. + +-spec ack_buffer(integer(), endpoint_buffer:buffer(), #state{}) -> + gen_statem:event_handler_result(kafka_state(), #state{}). +ack_buffer(Id, Buffer, State) -> + NBuffer = endpoint_buffer:ack(Id, Buffer), + {keep_state, State#state{buffer = NBuffer}}. + +-spec reload_endpoint(binary(), binary(), atom(), #endpoint{}, #state{}) -> + gen_statem:event_handler_result(kafka_state(), #state{}). +reload_endpoint(Matcher, NMatcher, ClientId, NEndpoint, State = #state{}) -> + ensure_subscription(Matcher, NMatcher), + stop_kafka_client(ClientId), + NBuffer = endpoint_buffer:recover_inflight(State#state.buffer), + {next_state, disconnected, State#state{endpoint = NEndpoint, client_pid = undefined, buffer = NBuffer}, + [{state_timeout, 0, connect}]}. + +-spec try_connect(#state{}) -> gen_statem:event_handler_result(kafka_state(), #state{}). +try_connect(State = #state{ + buffer = Buffer, + client_id = ClientId, + endpoint = #endpoint{ + title = Title, + config = #kafka_endpoint{ + sasl_config = SaslConfig, + bootstrap_servers = BootstrapServers, + topic = Topic + } + } + }) -> + logger:debug("[endpoint_kafka] endpoint: ~p, create postman", [Title]), + BaseConfig = [ + {reconnect_cool_down_seconds, 5}, + {socket_options, [{keepalive, true}]} + ], + ClientConfig = case SaslConfig of + {Mechanism, Username, Password} -> + [{sasl, {Mechanism, Username, Password}} | BaseConfig]; + undefined -> + BaseConfig + end, + case catch brod:start_link_client(BootstrapServers, ClientId, ClientConfig) of + {ok, ClientPid} -> + case brod:start_producer(ClientId, Topic, []) of + ok -> + NBuffer = endpoint_buffer:trigger_n(Buffer), + {next_state, connected, State#state{buffer = NBuffer, client_pid = ClientPid}}; + {error, Reason} -> + logger:debug("[endpoint_kafka] start_producer: ~p, get error: ~p", [ClientId, Reason]), + stop_kafka_client(ClientId), + {keep_state, State#state{client_pid = undefined}, + [{state_timeout, ?RETRY_INTERVAL, connect}]} + end; + Error -> + logger:debug("[endpoint_kafka] start_client: ~p, get error: ~p", [ClientId, Error]), + {keep_state, State#state{client_pid = undefined}, + [{state_timeout, ?RETRY_INTERVAL, connect}]} + end. + +-spec unknown_info(term(), kafka_state(), #state{}) -> + gen_statem:event_handler_result(kafka_state(), #state{}). +unknown_info(Info, StateName, State) -> + logger:warning("[endpoint_kafka] unknown message: ~p, status: ~p", [Info, StateName]), + {keep_state, State}. + +-spec unknown_event(gen_statem:event_type(), term(), kafka_state(), #state{}) -> + gen_statem:event_handler_result(kafka_state(), #state{}). +unknown_event(EventType, EventContent, StateName, State) -> + logger:warning("[endpoint_kafka] unknown event: ~p, content: ~p, status: ~p", [EventType, EventContent, StateName]), + {keep_state, State}. + +-spec ensure_subscription(binary(), binary()) -> ok. +ensure_subscription(Matcher, Matcher) -> + ok; +ensure_subscription(Matcher, NMatcher) -> + ok = endpoint_subscription:unsubscribe(Matcher, self()), + endpoint_subscription:subscribe(NMatcher, self()). + +-spec stop_kafka_client(atom()) -> ok. +stop_kafka_client(ClientId) when is_atom(ClientId) -> + _ = catch brod:stop_client(ClientId), + ok. diff --git a/apps/endpoint/src/endpoint_log.erl b/apps/endpoint/src/endpoint_log.erl new file mode 100644 index 0000000..3967c3e --- /dev/null +++ b/apps/endpoint/src/endpoint_log.erl @@ -0,0 +1,227 @@ +%%%------------------------------------------------------------------- +%%% @doc Endpoint diagnostic log. +%%% +%%% 当前用于记录 publish 时没有匹配到任何订阅者的数据,避免这类信息进入 +%%% endpoint_subscription 的热路径同步磁盘写入。 +%%%------------------------------------------------------------------- +-module(endpoint_log). + +-behaviour(gen_server). + +%% API +-export([start_link/0, unmatched_publish/2]). + +%% gen_server callbacks +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). + +-define(SERVER, ?MODULE). +-define(LOG_NAME, endpoint_unmatched_publish_log). +-define(DEFAULT_PATH_TEMPLATE, "${endpoint_root}/endpoint_log/unmatched_publish.log"). +-define(DEFAULT_ENDPOINT_ROOT_DIR, "log"). +-define(DEFAULT_MAX_BYTES, 10485760). +-define(DEFAULT_MAX_FILES, 10). +-define(MAX_LOG_CONTENT_BYTES, 32 * 1024). + +-record(state, { + enabled = false :: boolean(), + log_name = ?LOG_NAME :: atom() +}). + +%%%=================================================================== +%%% API +%%%=================================================================== + +-spec start_link() -> {ok, pid()} | ignore | {error, term()}. +start_link() -> + gen_server:start_link({local, ?SERVER}, ?MODULE, [], []). + +-spec unmatched_publish(RouteKey :: binary(), Content :: binary()) -> ok. +unmatched_publish(RouteKey, Content) when is_binary(RouteKey), is_binary(Content) -> + case erlang:whereis(?SERVER) of + undefined -> + ok; + Pid when is_pid(Pid) -> + gen_server:cast(Pid, {unmatched_publish, RouteKey, Content}), + ok + end. + +%%%=================================================================== +%%% gen_server callbacks +%%%=================================================================== + +-spec init(term()) -> {ok, #state{}}. +init([]) -> + ok = endpoint_util:set_metadata(), + case open_log() of + ok -> + {ok, #state{enabled = true}}; + {error, Reason} -> + logger:warning("[endpoint_log] open disk_log failed, reason: ~p", [Reason]), + {ok, #state{enabled = false}} + end. + +-spec handle_call(term(), gen_server:from(), #state{}) -> + {reply, ok, #state{}}. +handle_call(_Request, _From, State) -> + {reply, ok, State}. + +-spec handle_cast(term(), #state{}) -> {noreply, #state{}}. +handle_cast({unmatched_publish, RouteKey, Content}, State = #state{enabled = true, log_name = LogName}) -> + Entry = encode_unmatched_publish(RouteKey, Content), + case disk_log:blog(LogName, Entry) of + ok -> + {noreply, State}; + {error, Reason} -> + logger:warning("[endpoint_log] write unmatched publish log failed, reason: ~p", [Reason]), + {noreply, State#state{enabled = false}} + end; +handle_cast({unmatched_publish, _RouteKey, _Content}, State) -> + {noreply, State}; +handle_cast(_Request, State) -> + {noreply, State}. + +-spec handle_info(term(), #state{}) -> {noreply, #state{}}. +handle_info(Info, State) -> + logger:debug("[endpoint_log] unknown info: ~p", [Info]), + {noreply, State}. + +-spec terminate(term(), #state{}) -> ok. +terminate(_Reason, #state{enabled = true, log_name = LogName}) -> + _ = disk_log:close(LogName), + ok; +terminate(_Reason, _State) -> + ok. + +-spec code_change(term() | {down, term()}, #state{}, term()) -> + {ok, #state{}} | {error, term()}. +code_change(_OldVsn, State = #state{}, _Extra) -> + {ok, State}. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +-spec open_log() -> ok | {error, term()}. +open_log() -> + Config = log_config(), + Path = log_path(Config), + ok = filelib:ensure_dir(Path), + case disk_log:open([ + {name, ?LOG_NAME}, + {file, Path}, + {type, wrap}, + {format, external}, + {size, {config_pos_integer(max_bytes, Config, ?DEFAULT_MAX_BYTES), + config_pos_integer(max_files, Config, ?DEFAULT_MAX_FILES)}}, + {linkto, self()}, + {repair, true} + ]) of + {ok, ?LOG_NAME} -> + ok; + {repaired, ?LOG_NAME, _Recovered, _BadBytes} -> + ok; + {error, Reason} -> + {error, Reason} + end. + +-spec log_config() -> proplists:proplist(). +log_config() -> + case application:get_env(endpoint, endpoint_log) of + {ok, Config} when is_list(Config) -> + Config; + _ -> + [] + end. + +-spec log_path(proplists:proplist()) -> file:filename_all(). +log_path(Config) -> + Template = proplists:get_value(path, Config, ?DEFAULT_PATH_TEMPLATE), + expand_path_template(Template). + +-spec endpoint_root_dir() -> file:filename_all(). +endpoint_root_dir() -> + case application:get_env(endpoint, endpoints) of + {ok, Endpoints} -> + proplists:get_value(root_dir, Endpoints, ?DEFAULT_ENDPOINT_ROOT_DIR); + undefined -> + ?DEFAULT_ENDPOINT_ROOT_DIR + end. + +-spec config_pos_integer(atom(), proplists:proplist(), pos_integer()) -> pos_integer(). +config_pos_integer(Key, Config, Default) -> + case proplists:get_value(Key, Config, Default) of + Value when is_integer(Value), Value > 0 -> + Value; + _ -> + Default + end. + +-spec expand_path_template(file:filename_all()) -> file:filename_all(). +expand_path_template(Template) -> + TemplateBin = iolist_to_binary(Template), + Vars = path_vars(), + Expanded = lists:foldl(fun({Name, Value}, Acc) -> + replace_path_var(Name, Value, Acc) + end, TemplateBin, Vars), + binary_to_list(Expanded). + +-spec path_vars() -> [{binary(), binary()}]. +path_vars() -> + {{Year, Month, Day}, {Hour, _Minute, _Second}} = calendar:local_time(), + [ + {<<"endpoint_root">>, iolist_to_binary(endpoint_root_dir())}, + {<<"date">>, format_date(Year, Month, Day)}, + {<<"year">>, integer_to_binary(Year)}, + {<<"month">>, two_digits(Month)}, + {<<"day">>, two_digits(Day)}, + {<<"hour">>, two_digits(Hour)}, + {<<"node">>, atom_to_binary(node(), utf8)} + ]. + +-spec replace_path_var(binary(), binary(), binary()) -> binary(). +replace_path_var(Name, Value, Template) -> + Template0 = binary:replace(Template, <<"${", Name/binary, "}">>, Value, [global]), + binary:replace(Template0, <<"{", Name/binary, "}">>, Value, [global]). + +-spec encode_unmatched_publish(binary(), binary()) -> binary(). +encode_unmatched_publish(RouteKey, Content) -> + {LoggedContent, Truncated} = maybe_truncate(Content), + iolist_to_binary([ + format_local_time(), <<"\t">>, + <<"unmatched_publish">>, <<"\t">>, + RouteKey, <<"\t">>, + integer_to_binary(byte_size(Content)), <<"\t">>, + boolean_to_binary(Truncated), <<"\t">>, + base64:encode(LoggedContent), <<"\n">> + ]). + +-spec boolean_to_binary(boolean()) -> binary(). +boolean_to_binary(true) -> + <<"true">>; +boolean_to_binary(false) -> + <<"false">>. + +-spec format_local_time() -> binary(). +format_local_time() -> + MilliSeconds = erlang:system_time(millisecond) rem 1000, + {{Year, Month, Day}, {Hour, Minute, Second}} = calendar:local_time(), + iolist_to_binary(io_lib:format( + "~4..0B-~2..0B-~2..0B ~2..0B:~2..0B:~2..0B.~3..0B", + [Year, Month, Day, Hour, Minute, Second, MilliSeconds] + )). + +-spec format_date(integer(), integer(), integer()) -> binary(). +format_date(Year, Month, Day) -> + iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B", [Year, Month, Day])). + +-spec two_digits(integer()) -> binary(). +two_digits(Value) -> + iolist_to_binary(io_lib:format("~2..0B", [Value])). + +-spec maybe_truncate(binary()) -> {binary(), boolean()}. +maybe_truncate(Content) when byte_size(Content) =< ?MAX_LOG_CONTENT_BYTES -> + {Content, false}; +maybe_truncate(Content) -> + MaxBytes = ?MAX_LOG_CONTENT_BYTES, + <> = Content, + {LoggedContent, true}. diff --git a/apps/endpoint/src/endpoint_mqtt.erl b/apps/endpoint/src/endpoint_mqtt.erl new file mode 100644 index 0000000..ce75188 --- /dev/null +++ b/apps/endpoint/src/endpoint_mqtt.erl @@ -0,0 +1,285 @@ +%%%------------------------------------------------------------------- +%%% @author aresei +%%% @copyright (C) 2023, +%%% @doc +%%% +%%% @end +%%% Created : 06. 7月 2023 12:02 +%%%------------------------------------------------------------------- +-module(endpoint_mqtt). + +-include("endpoint.hrl"). +-behaviour(gen_statem). + +%% API +-export([start_link/2]). + +%% gen_statem callbacks +-export([callback_mode/0, init/1, terminate/3, code_change/4]). +-export([disconnected/3, connected/3]). + +%% 消息重发间隔 +-define(RETRY_INTERVAL, 15000). + +-record(state, { + endpoint :: #endpoint{}, + buffer :: endpoint_buffer:buffer(), + conn_pid :: undefined | pid(), + %% 待确认的数据, #{PacketId :: integer() => Id :: integer()} + inflight = #{} +}). + +-type mqtt_state() :: disconnected | connected. + +%%%=================================================================== +%%% API +%%%=================================================================== + +-spec start_link(LocalName :: term(), Endpoint :: #endpoint{}) -> + {ok, pid()} | ignore | {error, term()}. +start_link(LocalName, Endpoint = #endpoint{}) -> + gen_statem:start_link({via, gproc, {n, l, LocalName}}, ?MODULE, [Endpoint], []). + +%%%=================================================================== +%%% gen_statem callbacks +%%%=================================================================== + +-spec callback_mode() -> state_functions. +callback_mode() -> + state_functions. + +-spec init(term()) -> gen_statem:init_result(mqtt_state(), #state{}). +init([Endpoint = #endpoint{matcher = Matcher}]) -> + ok = endpoint_util:set_metadata(), + erlang:process_flag(trap_exit, true), + ok = endpoint_subscription:subscribe(Matcher, self()), + Buffer = endpoint_buffer:new(Endpoint, 10), + {ok, disconnected, #state{endpoint = Endpoint, buffer = Buffer}, [{next_event, internal, connect}]}. + +-spec disconnected(gen_statem:event_type(), term(), #state{}) -> + gen_statem:event_handler_result(mqtt_state(), #state{}). +disconnected({call, From}, get_stat, State = #state{buffer = Buffer}) -> + reply_stat(From, Buffer, State); +disconnected(cast, {forward, Metric}, State = #state{buffer = Buffer}) -> + store_metric(Metric, Buffer, State); +disconnected(cast, cleanup, State = #state{buffer = Buffer}) -> + cleanup_buffer(Buffer, State); +disconnected(cast, {reload, NEndpoint = #endpoint{matcher = NMatcher}}, + State = #state{endpoint = #endpoint{matcher = Matcher}, conn_pid = ConnPid}) -> + reload_endpoint(Matcher, NMatcher, ConnPid, NEndpoint, State); +disconnected(internal, connect, State) -> + {keep_state, State, [{next_event, internal, do_connect}]}; +disconnected(state_timeout, connect, State) -> + {keep_state, State, [{next_event, internal, do_connect}]}; +disconnected(internal, do_connect, + State = #state{ + buffer = Buffer, + endpoint = #endpoint{ + title = Title, + config = #mqtt_endpoint{ + host = Host, + port = Port, + username = Username, + password = Password, + client_id = ClientId + } + } + }) -> + logger:debug("[endpoint_mqtt] endpoint: ~ts, create postman", [Title]), + Opts = [ + {owner, self()}, + {clientid, ClientId}, + {host, binary_to_list(Host)}, + {port, Port}, + {tcp_opts, []}, + {username, binary_to_list(Username)}, + {password, binary_to_list(Password)}, + {keepalive, 86400}, + {auto_ack, true}, + {connect_timeout, 5000}, + {proto_ver, v5}, + {retry_interval, 5000} + ], + case connect_mqtt(Opts) of + {ok, ConnPid} -> + logger:debug("[endpoint_mqtt] connect success, pid: ~p", [ConnPid]), + NBuffer = endpoint_buffer:trigger_n(Buffer), + {next_state, connected, State#state{conn_pid = ConnPid, buffer = NBuffer}}; + {error, Reason} -> + logger:warning("[endpoint_mqtt] connect get error: ~p", [Reason]), + {keep_state, State#state{conn_pid = undefined, inflight = #{}}, + [{state_timeout, ?RETRY_INTERVAL, connect}]} + end; +disconnected(info, {timeout, TimerRef, {endpoint_buffer_retry, Id, Payload}}, State = #state{buffer = Buffer}) -> + NBuffer = endpoint_buffer:handle_timeout(TimerRef, Id, Payload, Buffer), + {keep_state, State#state{buffer = NBuffer}}; +disconnected(info, {next_data, _Id, _Tuple}, State) -> + {keep_state, State}; +disconnected(info, {'EXIT', ConnPid, Reason}, + State = #state{endpoint = #endpoint{title = Title}, conn_pid = ConnPid}) -> + logger:warning("[endpoint_mqtt] endpoint: ~p, conn pid exit with reason: ~p", [Title, Reason]), + {keep_state, State#state{conn_pid = undefined, inflight = #{}}, + [{state_timeout, ?RETRY_INTERVAL, connect}]}; +disconnected(info, Info, State) -> + unknown_info(Info, disconnected, State); +disconnected(EventType, EventContent, State) -> + unknown_event(EventType, EventContent, disconnected, State). + +-spec connected(gen_statem:event_type(), term(), #state{}) -> + gen_statem:event_handler_result(mqtt_state(), #state{}). +connected({call, From}, get_stat, State = #state{buffer = Buffer}) -> + reply_stat(From, Buffer, State); +connected(cast, {forward, Metric}, State = #state{buffer = Buffer}) -> + forward_metric(Metric, Buffer, State); +connected(cast, cleanup, State = #state{buffer = Buffer}) -> + cleanup_buffer(Buffer, State); +connected(cast, {reload, NEndpoint = #endpoint{matcher = NMatcher}}, + State = #state{endpoint = #endpoint{matcher = Matcher}, conn_pid = ConnPid}) -> + reload_endpoint(Matcher, NMatcher, ConnPid, NEndpoint, State); +connected(internal, connect, State) -> + {keep_state, State}; +connected(info, {next_data, Id, Metric}, + State = #state{ + conn_pid = ConnPid, + buffer = Buffer, + inflight = InFlight, + endpoint = #endpoint{config = #mqtt_endpoint{topic = Topic, qos = Qos}} + }) -> + logger:debug("[endpoint_mqtt] will publish topic: ~p, metric: ~p, qos: ~p", [Topic, Metric, Qos]), + case emqtt:publish(ConnPid, Topic, #{}, Metric, [{qos, Qos}, {retain, true}]) of + ok -> + NBuffer = endpoint_buffer:ack(Id, Buffer), + {keep_state, State#state{buffer = NBuffer}}; + {ok, PacketId} -> + {keep_state, State#state{inflight = maps:put(PacketId, Id, InFlight)}}; + {error, Reason} -> + logger:warning("[endpoint_mqtt] send message to topic: ~p, get error: ~p", [Topic, Reason]), + stop_mqtt_conn(ConnPid), + NBuffer = endpoint_buffer:recover_inflight(Buffer), + {next_state, disconnected, State#state{conn_pid = undefined, inflight = #{}, buffer = NBuffer}, + [{state_timeout, ?RETRY_INTERVAL, connect}]} + end; +connected(info, {timeout, TimerRef, {endpoint_buffer_retry, Id, Payload}}, State = #state{buffer = Buffer}) -> + NBuffer = endpoint_buffer:handle_timeout(TimerRef, Id, Payload, Buffer), + {keep_state, State#state{buffer = NBuffer}}; +connected(info, {disconnected, ReasonCode, Properties}, + State = #state{conn_pid = ConnPid, buffer = Buffer}) -> + logger:debug("[endpoint_mqtt] Recv a DISONNECT packet - ReasonCode: ~p, Properties: ~p", [ReasonCode, Properties]), + stop_mqtt_conn(ConnPid), + NBuffer = endpoint_buffer:recover_inflight(Buffer), + {next_state, disconnected, State#state{conn_pid = undefined, inflight = #{}, buffer = NBuffer}, + [{state_timeout, ?RETRY_INTERVAL, connect}]}; +connected(info, {publish, Message = #{packet_id := _PacketId, payload := Payload}}, State) -> + logger:debug("[endpoint_mqtt] Recv a publish packet: ~p, payload: ~p", [Message, Payload]), + {keep_state, State}; +connected(info, {puback, #{packet_id := PacketId}}, + State = #state{inflight = Inflight, buffer = Buffer}) -> + case maps:take(PacketId, Inflight) of + {Id, RestInflight} -> + NBuffer = endpoint_buffer:ack(Id, Buffer), + {keep_state, State#state{buffer = NBuffer, inflight = RestInflight}}; + error -> + {keep_state, State} + end; +connected(info, {'EXIT', ConnPid, Reason}, + State = #state{endpoint = #endpoint{title = Title}, conn_pid = ConnPid, buffer = Buffer}) -> + logger:warning("[endpoint_mqtt] endpoint: ~p, conn pid exit with reason: ~p", [Title, Reason]), + NBuffer = endpoint_buffer:recover_inflight(Buffer), + {next_state, disconnected, State#state{conn_pid = undefined, inflight = #{}, buffer = NBuffer}, + [{state_timeout, ?RETRY_INTERVAL, connect}]}; +connected(info, Info, State) -> + unknown_info(Info, connected, State); +connected(EventType, EventContent, State) -> + unknown_event(EventType, EventContent, connected, State). + +-spec terminate(term(), mqtt_state(), #state{}) -> term(). +terminate(Reason, _StateName, #state{endpoint = #endpoint{title = Title}, buffer = Buffer, conn_pid = ConnPid}) -> + logger:debug("[endpoint_mqtt] endpoint: ~p, terminate with reason: ~p", [Title, Reason]), + stop_mqtt_conn(ConnPid), + endpoint_buffer:cleanup(Buffer), + ok. + +-spec code_change(term() | {down, term()}, mqtt_state(), #state{}, term()) -> + {ok, mqtt_state(), #state{}} | {error, term()}. +code_change(_OldVsn, StateName, State = #state{}, _Extra) -> + {ok, StateName, State}. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +-spec reply_stat(gen_statem:from(), endpoint_buffer:buffer(), #state{}) -> + gen_statem:event_handler_result(mqtt_state(), #state{}). +reply_stat(From, Buffer, State) -> + Stat = endpoint_buffer:stat(Buffer), + {keep_state, State, [{reply, From, {ok, Stat}}]}. + +-spec forward_metric(binary(), endpoint_buffer:buffer(), #state{}) -> + gen_statem:event_handler_result(mqtt_state(), #state{}). +forward_metric(Metric, Buffer, State) -> + NBuffer = endpoint_buffer:append(Metric, Buffer), + {keep_state, State#state{buffer = NBuffer}}. + +-spec store_metric(binary(), endpoint_buffer:buffer(), #state{}) -> + gen_statem:event_handler_result(mqtt_state(), #state{}). +store_metric(Metric, Buffer, State) -> + NBuffer = endpoint_buffer:append_only(Metric, Buffer), + {keep_state, State#state{buffer = NBuffer}}. + +-spec cleanup_buffer(endpoint_buffer:buffer(), #state{}) -> + gen_statem:event_handler_result(mqtt_state(), #state{}). +cleanup_buffer(Buffer, State) -> + NBuffer = endpoint_buffer:cleanup(Buffer), + {keep_state, State#state{buffer = NBuffer}}. + +-spec reload_endpoint(binary(), binary(), undefined | pid(), #endpoint{}, #state{}) -> + gen_statem:event_handler_result(mqtt_state(), #state{}). +reload_endpoint(Matcher, NMatcher, ConnPid, NEndpoint, State = #state{}) -> + ensure_subscription(Matcher, NMatcher), + stop_mqtt_conn(ConnPid), + NBuffer = endpoint_buffer:recover_inflight(State#state.buffer), + {next_state, disconnected, State#state{endpoint = NEndpoint, conn_pid = undefined, inflight = #{}, buffer = NBuffer}, + [{next_event, internal, do_connect}]}. + +-spec unknown_info(term(), mqtt_state(), #state{}) -> + gen_statem:event_handler_result(mqtt_state(), #state{}). +unknown_info(Info, StateName, State) -> + logger:warning("[endpoint_mqtt] unknown message: ~p, status: ~p", [Info, StateName]), + {keep_state, State}. + +-spec unknown_event(gen_statem:event_type(), term(), mqtt_state(), #state{}) -> + gen_statem:event_handler_result(mqtt_state(), #state{}). +unknown_event(EventType, EventContent, StateName, State) -> + logger:warning("[endpoint_mqtt] unknown event: ~p, content: ~p, status: ~p", [EventType, EventContent, StateName]), + {keep_state, State}. + +-spec ensure_subscription(binary(), binary()) -> ok. +ensure_subscription(Matcher, Matcher) -> + ok; +ensure_subscription(Matcher, NMatcher) -> + ok = endpoint_subscription:unsubscribe(Matcher, self()), + endpoint_subscription:subscribe(NMatcher, self()). + +-spec connect_mqtt(list()) -> {ok, pid()} | {error, term()}. +connect_mqtt(Opts) -> + try + {ok, ConnPid} = emqtt:start_link(Opts), + logger:debug("[endpoint_mqtt] start connect, options: ~p", [Opts]), + case emqtt:connect(ConnPid, 5000) of + {ok, _} -> + {ok, ConnPid}; + {error, Reason} -> + stop_mqtt_conn(ConnPid), + {error, Reason} + end + catch + _:Error -> + {error, Error} + end. + +-spec stop_mqtt_conn(undefined | pid()) -> ok. +stop_mqtt_conn(undefined) -> + ok; +stop_mqtt_conn(ConnPid) when is_pid(ConnPid) -> + _ = catch emqtt:stop(ConnPid), + ok. diff --git a/apps/endpoint/src/endpoint_outbox.erl b/apps/endpoint/src/endpoint_outbox.erl new file mode 100644 index 0000000..d918813 --- /dev/null +++ b/apps/endpoint/src/endpoint_outbox.erl @@ -0,0 +1,953 @@ +%%%------------------------------------------------------------------- +%%% @author Codex +%%% @doc +%%% endpoint 持久化 outbox 的第一阶段实现: +%%% - 一个 endpoint 一个目录 +%%% - segment 按递增编号命名 +%%% - metadata 只记录连续 ack 指针 +%%% - inflight 不落盘,崩溃后从 acked_seq + 1 重放 +%%%------------------------------------------------------------------- +-module(endpoint_outbox). + +-export([open/2, close/1, append/2, ack/2, next/1, reset_reader/1, stat/1]). +-export_type([outbox/0]). + +-record(segment, { + id :: pos_integer(), + path :: file:filename_all(), + start_seq = 0 :: non_neg_integer(), + end_seq = 0 :: non_neg_integer(), + records = 0 :: non_neg_integer(), + bytes = 0 :: non_neg_integer() +}). + +-record(outbox_writer, { + segment = 1 :: pos_integer(), + next_seq = 1 :: pos_integer(), + fd = undefined :: undefined | file:fd() +}). + +-record(outbox_reader, { + segment :: undefined | pos_integer(), + offset = 0 :: non_neg_integer(), + fd = undefined :: undefined | file:fd() +}). + +-record(outbox_checkpoint, { + acked_segment = 1 :: pos_integer(), + acked_seq = 0 :: non_neg_integer(), + pending_acks = [] :: [pos_integer()] +}). + +-record(outbox, { + dir :: file:filename_all(), + metadata_path :: file:filename_all(), + max_records = 500000 :: pos_integer(), + max_bytes = 134217728 :: pos_integer(), + max_segments = 10 :: pos_integer(), + segments = [] :: [#segment{}], + writer = #outbox_writer{} :: #outbox_writer{}, + reader = #outbox_reader{} :: #outbox_reader{}, + checkpoint = #outbox_checkpoint{} :: #outbox_checkpoint{} +}). + +-type outbox() :: #outbox{}. + +-define(METADATA_FILE, "metadata.json"). +-define(SEGMENT_EXT, ".log"). +-define(MAX_PAYLOAD_BYTES, 32 * 1024). +-define(RECORD_MAGIC, <<"EPOBOX01">>). +-define(RECORD_VERSION, 1). +-define(RECORD_HEADER_BYTES, 21). +-define(RECORD_FOOTER_BYTES, 4). +-define(RECORD_OVERHEAD_BYTES, ?RECORD_HEADER_BYTES + ?RECORD_FOOTER_BYTES). +-define(RESYNC_READ_BYTES, 4096). + +%%%=================================================================== +%%% API +%%%=================================================================== + +%% Opts 支持的参数: +%% - max_records => pos_integer() +%% 单个 segment 允许的最大记录数,默认 500000 +%% - max_bytes => pos_integer() +%% 单个 segment 允许的最大字节数,默认 134217728 +%% - max_segments => pos_integer() +%% 未确认 segment 的最大数量,默认 10 +-spec open(file:filename_all(), map()) -> {ok, outbox()} | {error, term()}. +open(Dir, Opts) when is_map(Opts) -> + ok = ensure_dir(Dir), + MetadataPath = filename:join(Dir, ?METADATA_FILE), + Segments = load_segments(Dir), + Meta = read_metadata(MetadataPath), + LastSegmentId = last_segment_id(Segments, maps:get(write_segment, Meta, 1)), + LastSeq = last_seq(Segments, maps:get(write_seq, Meta, 0)), + AckedSeq = min(maps:get(acked_seq, Meta, 0), LastSeq), + AckedSegment = segment_id_for_seq(Segments, AckedSeq, maps:get(acked_segment, Meta, 1)), + Outbox0 = #outbox{ + dir = Dir, + metadata_path = MetadataPath, + max_records = maps:get(max_records, Opts, 500000), + max_bytes = maps:get(max_bytes, Opts, 134217728), + max_segments = maps:get(max_segments, Opts, 10), + writer = #outbox_writer{ + segment = LastSegmentId, + next_seq = LastSeq + 1 + }, + checkpoint = #outbox_checkpoint{ + acked_segment = AckedSegment, + acked_seq = AckedSeq + }, + segments = Segments + }, + {ReadSegment, ReadOffset} = locate_reader(Outbox0), + Outbox1 = Outbox0#outbox{ + reader = #outbox_reader{ + segment = ReadSegment, + offset = ReadOffset + } + }, + maybe + {ok, Outbox} ?= open_fds(Outbox1), + ok ?= persist_metadata(Outbox), + {ok, Outbox} + else + {error, Reason} -> + {error, Reason} + end. + +-spec close(outbox()) -> ok. +close(Outbox = #outbox{}) -> + _ = close_open_fds(Outbox), + ok. + +-spec append(binary(), outbox()) -> + {ok, Seq :: pos_integer(), outbox()} | {dropped, capacity_reached, outbox()} | {error, term()}. +append(Payload, Outbox = #outbox{}) when is_binary(Payload) -> + case validate_payload_size(Payload) of + ok -> + RecordBytes = encoded_record_size(Payload), + case ensure_writable_segment(RecordBytes, Outbox) of + {drop, NOutbox} -> + {dropped, capacity_reached, NOutbox}; + {ok, SegmentId, NOutbox0} -> + maybe + {ok, NOutbox1} ?= ensure_write_fd(SegmentId, NOutbox0), + Writer1 = NOutbox1#outbox.writer, + Seq = Writer1#outbox_writer.next_seq, + SegmentPath = segment_path(NOutbox1#outbox.dir, SegmentId), + ok ?= append_record(Writer1#outbox_writer.fd, Seq, Payload), + Segments = update_written_segment( + NOutbox1#outbox.segments, SegmentId, SegmentPath, Seq, RecordBytes), + NOutbox2 = NOutbox1#outbox{ + writer = Writer1#outbox_writer{ + segment = SegmentId, + next_seq = Seq + 1 + }, + segments = Segments + }, + {ok, NOutbox} ?= maybe_reset_reader_after_append(Outbox, NOutbox2), + ok ?= persist_metadata(NOutbox), + {ok, Seq, NOutbox} + else + {error, Reason} -> + {error, Reason} + end + end; + {error, Reason} -> + {error, Reason} + end. + +-spec ack(pos_integer(), outbox()) -> {ok, outbox()} | {error, term()}. +ack(Seq, Outbox = #outbox{checkpoint = Checkpoint}) when is_integer(Seq), Seq > 0 -> + AckedSeq = Checkpoint#outbox_checkpoint.acked_seq, + case Seq =< AckedSeq of + true -> + {ok, Outbox}; + false -> + PendingAcks0 = ordsets:add_element(Seq, Checkpoint#outbox_checkpoint.pending_acks), + {NackedSeq, PendingAcks} = advance_acked_seq(AckedSeq, PendingAcks0), + NackedSegment = segment_id_for_seq( + Outbox#outbox.segments, + NackedSeq, + Checkpoint#outbox_checkpoint.acked_segment), + Outbox0 = Outbox#outbox{ + checkpoint = Checkpoint#outbox_checkpoint{ + acked_seq = NackedSeq, + acked_segment = NackedSegment, + pending_acks = PendingAcks + } + }, + maybe + {ok, Outbox1} ?= prune_acked_segments(Outbox0), + ok ?= maybe_persist_metadata(NackedSeq =/= AckedSeq, Outbox1), + {ok, Outbox1} + else + {error, Reason} -> + {error, Reason} + end + end. + +-spec next(outbox()) -> + eof | {ok, Seq :: pos_integer(), Payload :: binary(), outbox()} | {error, term()}. +next(#outbox{reader = #outbox_reader{segment = undefined}}) -> + eof; +next(Outbox = #outbox{ + reader = Reader, + segments = Segments + }) -> + SegmentId = Reader#outbox_reader.segment, + Offset = Reader#outbox_reader.offset, + case ensure_read_fd(Outbox) of + {ok, NOutbox0} -> + Reader0 = NOutbox0#outbox.reader, + case read_next_record(Reader0#outbox_reader.fd, Offset) of + eof -> + case next_segment_id(Segments, SegmentId) of + undefined -> + eof; + NextSegmentId -> + case switch_read_segment(NextSegmentId, 0, NOutbox0) of + {ok, NOutbox1} -> + next(NOutbox1); + {error, Reason} -> + {error, Reason} + end + end; + {ok, _RecordOffset, Seq, Payload, NextOffset} -> + {ok, Seq, Payload, NOutbox0#outbox{ + reader = Reader0#outbox_reader{offset = NextOffset} + }}; + {error, Reason} -> + {error, Reason} + end; + {error, Reason} -> + {error, Reason} + end. + +-spec reset_reader(outbox()) -> {ok, outbox()}. +reset_reader(Outbox = #outbox{}) -> + {ReadSegment, ReadOffset} = locate_reader(Outbox), + switch_read_segment(ReadSegment, ReadOffset, Outbox). + +-spec stat(outbox()) -> map(). +stat(#outbox{ + dir = Dir, + writer = #outbox_writer{ + segment = WriteSegment, + next_seq = NextSeq + }, + checkpoint = #outbox_checkpoint{ + acked_segment = AckedSegment, + acked_seq = AckedSeq + }, + segments = Segments + }) -> + #{ + dir => Dir, + write_segment => WriteSegment, + write_seq => max(NextSeq - 1, 0), + acked_segment => AckedSegment, + acked_seq => AckedSeq, + segment_num => length(Segments), + pending_segment_num => pending_segment_count(Segments, AckedSeq) + }. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +-spec ensure_dir(file:filename_all()) -> ok. +ensure_dir(Dir) -> + ok = filelib:ensure_dir(filename:join(Dir, "dummy")), + ok. + +-spec read_metadata(file:filename_all()) -> map(). +read_metadata(Path) -> + case file:read_file(Path) of + {ok, Bin} -> + case catch json:decode(Bin) of + #{<<"write_segment">> := WriteSegment, + <<"write_seq">> := WriteSeq, + <<"acked_segment">> := AckedSegment, + <<"acked_seq">> := AckedSeq} + when is_integer(WriteSegment), is_integer(WriteSeq), + is_integer(AckedSegment), is_integer(AckedSeq) -> + #{ + write_segment => WriteSegment, + write_seq => WriteSeq, + acked_segment => AckedSegment, + acked_seq => AckedSeq + }; + _ -> + default_metadata() + end; + {error, enoent} -> + default_metadata(); + {error, _} -> + default_metadata() + end. + +-spec default_metadata() -> map(). +default_metadata() -> + #{ + write_segment => 1, + write_seq => 0, + acked_segment => 1, + acked_seq => 0 + }. + +-spec persist_metadata(outbox()) -> ok | {error, term()}. +persist_metadata(#outbox{ + metadata_path = MetadataPath, + writer = #outbox_writer{ + segment = WriteSegment, + next_seq = NextSeq + }, + checkpoint = #outbox_checkpoint{ + acked_segment = AckedSegment, + acked_seq = AckedSeq + } + }) -> + TmpPath = MetadataPath ++ ".tmp", + Metadata = #{ + <<"write_segment">> => WriteSegment, + <<"write_seq">> => max(NextSeq - 1, 0), + <<"acked_segment">> => AckedSegment, + <<"acked_seq">> => AckedSeq + }, + Bin = iolist_to_binary(json:encode(Metadata)), + ok = file:write_file(TmpPath, Bin), + ok = file:rename(TmpPath, MetadataPath), + ok. + +-spec load_segments(file:filename_all()) -> [#segment{}]. +load_segments(Dir) -> + Pattern = filename:join(Dir, "*" ++ ?SEGMENT_EXT), + SegmentFiles = lists:sort(filelib:wildcard(Pattern)), + lists:filtermap(fun(Path) -> + case parse_segment_id(filename:basename(Path)) of + {ok, Id} -> + case scan_segment(Path, Id) of + {ok, Segment = #segment{records = Records}} when Records > 0 -> + {true, Segment}; + _ -> + false + end; + error -> + false + end + end, SegmentFiles). + +-spec parse_segment_id(string()) -> {ok, pos_integer()} | error. +parse_segment_id(Filename) -> + case filename:extension(Filename) of + ?SEGMENT_EXT -> + Base = filename:rootname(Filename, ?SEGMENT_EXT), + try + Id = list_to_integer(Base), + case Id > 0 of + true -> + {ok, Id}; + false -> + error + end + catch + _:_ -> + error + end; + _ -> + error + end. + +-spec scan_segment(file:filename_all(), pos_integer()) -> {ok, #segment{}} | {error, term()}. +scan_segment(Path, Id) -> + case file:open(Path, [read, raw, binary]) of + {ok, Fd} -> + try + scan_segment_records(Fd, 0, #segment{id = Id, path = Path}) + after + ok = file:close(Fd) + end; + {error, Reason} -> + {error, Reason} + end. + +-spec scan_segment_records(file:fd(), non_neg_integer(), #segment{}) -> {ok, #segment{}} | {error, term()}. +scan_segment_records(Fd, Offset, Segment = #segment{}) -> + case read_next_record(Fd, Offset) of + eof -> + {ok, Segment}; + {ok, RecordOffset, Seq, _Payload, NextOffset} -> + RecordBytes = NextOffset - RecordOffset, + NextSegment = Segment#segment{ + start_seq = case Segment#segment.records of + 0 -> Seq; + _ -> Segment#segment.start_seq + end, + end_seq = Seq, + records = Segment#segment.records + 1, + bytes = Segment#segment.bytes + RecordBytes + }, + scan_segment_records(Fd, NextOffset, NextSegment); + {error, Reason} -> + {error, Reason} + end. + +-spec encoded_record_size(binary()) -> pos_integer(). +encoded_record_size(Payload) -> + ?RECORD_OVERHEAD_BYTES + byte_size(Payload). + +-spec validate_payload_size(binary()) -> ok | {error, payload_too_large}. +validate_payload_size(Payload) when byte_size(Payload) =< ?MAX_PAYLOAD_BYTES -> + ok; +validate_payload_size(_Payload) -> + {error, payload_too_large}. + +-spec ensure_writable_segment(pos_integer(), outbox()) -> + {ok, pos_integer(), outbox()} | {drop, outbox()}. +ensure_writable_segment(RecordBytes, Outbox = #outbox{ + max_records = MaxRecords, + max_bytes = MaxBytes, + max_segments = MaxSegments, + segments = Segments, + writer = #outbox_writer{segment = WriteSegment}, + checkpoint = #outbox_checkpoint{acked_seq = AckedSeq} + }) -> + case find_segment(Segments, WriteSegment) of + false -> + {ok, WriteSegment, Outbox}; + #segment{records = Records, bytes = Bytes} -> + NeedRoll = Records > 0 andalso (Records >= MaxRecords orelse Bytes + RecordBytes > MaxBytes), + case NeedRoll of + false -> + {ok, WriteSegment, Outbox}; + true -> + case pending_segment_count(Segments, AckedSeq) >= MaxSegments of + true -> + {drop, Outbox}; + false -> + {ok, WriteSegment + 1, Outbox#outbox{ + writer = ((Outbox#outbox.writer)#outbox_writer{ + segment = WriteSegment + 1 + }) + }} + end + end + end. + +-spec append_record(file:fd(), pos_integer(), binary()) -> ok | {error, term()}. +append_record(Fd, Seq, Payload) when Fd =/= undefined -> + PayloadSize = byte_size(Payload), + Crc = record_crc(?RECORD_VERSION, Seq, PayloadSize, Payload), + Bin = << + ?RECORD_MAGIC/binary, + ?RECORD_VERSION:8, + Seq:64/unsigned-big-integer, + PayloadSize:32/unsigned-big-integer, + Payload/binary, + Crc:32/unsigned-big-integer + >>, + case file:write(Fd, Bin) of + ok -> + ok; + {error, Reason} -> + {error, Reason} + end. + +-spec update_written_segment([#segment{}], pos_integer(), file:filename_all(), pos_integer(), pos_integer()) -> [#segment{}]. +update_written_segment(Segments, SegmentId, Path, Seq, RecordBytes) -> + case take_segment(SegmentId, Segments) of + {undefined, Rest} -> + lists:sort(fun compare_segment/2, [ + #segment{ + id = SegmentId, + path = Path, + start_seq = Seq, + end_seq = Seq, + records = 1, + bytes = RecordBytes + } | Rest + ]); + {Segment, Rest} -> + lists:sort(fun compare_segment/2, [ + Segment#segment{ + end_seq = Seq, + records = Segment#segment.records + 1, + bytes = Segment#segment.bytes + RecordBytes + } | Rest + ]) + end. + +-spec compare_segment(#segment{}, #segment{}) -> boolean(). +compare_segment(#segment{id = Id0}, #segment{id = Id1}) -> + Id0 =< Id1. + +-spec find_segment([#segment{}], pos_integer()) -> false | #segment{}. +find_segment(Segments, SegmentId) -> + lists:keyfind(SegmentId, #segment.id, Segments). + +-spec take_segment(pos_integer(), [#segment{}]) -> {undefined | #segment{}, [#segment{}]}. +take_segment(SegmentId, Segments) -> + case lists:partition(fun(#segment{id = Id}) -> Id =:= SegmentId end, Segments) of + {[Segment], Rest} -> + {Segment, Rest}; + {[], Rest} -> + {undefined, Rest} + end. + +-spec last_segment_id([#segment{}], pos_integer()) -> pos_integer(). +last_segment_id([], Default) -> + Default; +last_segment_id(Segments, _Default) -> + (lists:last(Segments))#segment.id. + +-spec last_seq([#segment{}], non_neg_integer()) -> non_neg_integer(). +last_seq([], Default) -> + Default; +last_seq(Segments, _Default) -> + (lists:last(Segments))#segment.end_seq. + +-spec segment_id_for_seq([#segment{}], non_neg_integer(), pos_integer()) -> pos_integer(). +segment_id_for_seq(_Segments, 0, Default) -> + Default; +segment_id_for_seq(Segments, Seq, Default) -> + case lists:dropwhile(fun(#segment{end_seq = EndSeq}) -> EndSeq < Seq end, Segments) of + [#segment{id = SegmentId, start_seq = StartSeq, end_seq = EndSeq} | _] + when Seq >= StartSeq, Seq =< EndSeq -> + SegmentId; + _ -> + Default + end. + +-spec pending_segment_count([#segment{}], non_neg_integer()) -> non_neg_integer(). +pending_segment_count(Segments, AckedSeq) -> + length([Segment || Segment = #segment{end_seq = EndSeq} <- Segments, EndSeq > AckedSeq]). + +-spec advance_acked_seq(non_neg_integer(), [pos_integer()]) -> {non_neg_integer(), [pos_integer()]}. +advance_acked_seq(AckedSeq, PendingAcks) -> + NextSeq = AckedSeq + 1, + case ordsets:is_element(NextSeq, PendingAcks) of + true -> + advance_acked_seq(NextSeq, ordsets:del_element(NextSeq, PendingAcks)); + false -> + {AckedSeq, PendingAcks} + end. + +-spec prune_acked_segments(outbox()) -> {ok, outbox()} | {error, term()}. +prune_acked_segments(Outbox = #outbox{ + segments = Segments, + checkpoint = #outbox_checkpoint{acked_seq = AckedSeq} + }) -> + {DeleteSegments, KeepSegments} = lists:partition(fun(#segment{end_seq = EndSeq}) -> + EndSeq =< AckedSeq + end, Segments), + Outbox0 = detach_deleted_fds(DeleteSegments, Outbox#outbox{segments = KeepSegments}), + ok = lists:foldl(fun(#segment{path = Path}, ok) -> + case file:delete(Path) of + ok -> + ok; + {error, enoent} -> + ok; + {error, Reason} -> + throw({delete_segment_failed, Path, Reason}) + end + end, ok, DeleteSegments), + {ok, reset_reader_after_prune(Outbox0)}. + +-spec reset_reader_after_prune(outbox()) -> outbox(). +reset_reader_after_prune(Outbox = #outbox{ + reader = #outbox_reader{segment = ReadSegment}, + segments = Segments + }) -> + case ReadSegment =:= undefined orelse lists:keymember(ReadSegment, #segment.id, Segments) of + true -> + Outbox; + false -> + case reset_reader(Outbox) of + {ok, NOutbox} -> + NOutbox + end + end. + +-spec locate_reader(outbox()) -> {undefined | pos_integer(), non_neg_integer()}. +locate_reader(#outbox{ + segments = Segments, + checkpoint = #outbox_checkpoint{acked_seq = AckedSeq} + }) -> + NextSeq = AckedSeq + 1, + case lists:dropwhile(fun(#segment{end_seq = EndSeq}) -> EndSeq < NextSeq end, Segments) of + [] -> + {undefined, 0}; + [#segment{id = SegmentId, start_seq = StartSeq} = Segment | _] -> + case NextSeq =< StartSeq of + true -> + {SegmentId, 0}; + false -> + case locate_offset_for_seq(Segment, NextSeq) of + {ok, Offset} -> + {SegmentId, Offset}; + {error, _} -> + {SegmentId, 0} + end + end + end. + +-spec locate_offset_for_seq(#segment{}, pos_integer()) -> {ok, non_neg_integer()} | {error, term()}. +locate_offset_for_seq(#segment{path = Path}, TargetSeq) -> + case file:open(Path, [read, raw, binary]) of + {ok, Fd} -> + try + locate_offset_loop(Fd, TargetSeq, 0) + after + ok = file:close(Fd) + end; + {error, Reason} -> + {error, Reason} + end. + +-spec locate_offset_loop(file:fd(), pos_integer(), non_neg_integer()) -> + {ok, non_neg_integer()} | {error, term()}. +locate_offset_loop(Fd, TargetSeq, Offset) -> + case read_next_record(Fd, Offset) of + eof -> + {ok, Offset}; + {ok, RecordOffset, Seq, _Payload, _NextOffset} when Seq >= TargetSeq -> + {ok, RecordOffset}; + {ok, _RecordOffset, _Seq, _Payload, NextOffset} -> + locate_offset_loop(Fd, TargetSeq, NextOffset); + {error, Reason} -> + {error, Reason} + end. + +-spec read_next_record(file:fd(), non_neg_integer()) -> + eof | {ok, non_neg_integer(), pos_integer(), binary(), non_neg_integer()} | {error, term()}. +read_next_record(Fd, Offset) -> + case read_record_at(Fd, Offset) of + eof -> + eof; + {ok, Seq, Payload, NextOffset} -> + {ok, Offset, Seq, Payload, NextOffset}; + {error, _Reason} -> + resync_next_record(Fd, Offset + 1) + end. + +-spec read_record_at(file:fd(), non_neg_integer()) -> + eof | {ok, pos_integer(), binary(), non_neg_integer()} | {error, term()}. +read_record_at(Fd, Offset) -> + maybe + {ok, _} ?= file:position(Fd, Offset), + read_record(Fd, Offset) + else + {error, Reason} -> + {error, Reason} + end. + +-spec read_record(file:fd(), non_neg_integer()) -> + eof | {ok, pos_integer(), binary(), non_neg_integer()} | {error, term()}. +read_record(Fd, Offset) -> + case file:read(Fd, ?RECORD_HEADER_BYTES) of + eof -> + eof; + {ok, << + Magic:8/binary, + Version:8, + Seq:64/unsigned-big-integer, + PayloadSize:32/unsigned-big-integer + >>} when Magic =:= ?RECORD_MAGIC, Version =:= ?RECORD_VERSION -> + maybe + ok ?= validate_read_payload_size(PayloadSize), + read_record_body(Fd, Offset, Seq, PayloadSize) + else + {error, Reason} -> + {error, Reason} + end; + {ok, Header} when byte_size(Header) < ?RECORD_HEADER_BYTES -> + {error, truncated_header}; + {ok, _} -> + {error, invalid_record_header}; + {error, Reason} -> + {error, Reason} + end. + +-spec validate_read_payload_size(non_neg_integer()) -> ok | {error, payload_too_large}. +validate_read_payload_size(PayloadSize) when PayloadSize =< ?MAX_PAYLOAD_BYTES -> + ok; +validate_read_payload_size(_PayloadSize) -> + {error, payload_too_large}. + +-spec read_record_body(file:fd(), non_neg_integer(), pos_integer(), non_neg_integer()) -> + {ok, pos_integer(), binary(), non_neg_integer()} | {error, term()}. +read_record_body(Fd, Offset, Seq, PayloadSize) -> + case file:read(Fd, PayloadSize + ?RECORD_FOOTER_BYTES) of + {ok, <>} -> + ExpectedCrc = record_crc(?RECORD_VERSION, Seq, PayloadSize, Payload), + case StoredCrc =:= ExpectedCrc of + true -> + {ok, Seq, Payload, Offset + ?RECORD_OVERHEAD_BYTES + PayloadSize}; + false -> + {error, invalid_record_crc} + end; + eof -> + {error, truncated_payload}; + {ok, _} -> + {error, truncated_payload}; + {error, Reason} -> + {error, Reason} + end. + +-spec record_crc(byte(), pos_integer(), non_neg_integer(), binary()) -> non_neg_integer(). +record_crc(Version, Seq, PayloadSize, Payload) -> + erlang:crc32(<< + Version:8, + Seq:64/unsigned-big-integer, + PayloadSize:32/unsigned-big-integer, + Payload/binary + >>). + +-spec resync_next_record(file:fd(), non_neg_integer()) -> + eof | {ok, non_neg_integer(), pos_integer(), binary(), non_neg_integer()} | {error, term()}. +resync_next_record(Fd, SearchOffset) -> + case find_next_magic(Fd, SearchOffset) of + eof -> + eof; + {ok, RecordOffset} -> + case read_record_at(Fd, RecordOffset) of + eof -> + eof; + {ok, Seq, Payload, NextOffset} -> + {ok, RecordOffset, Seq, Payload, NextOffset}; + {error, _Reason} -> + resync_next_record(Fd, RecordOffset + 1) + end; + {error, Reason} -> + {error, Reason} + end. + +-spec find_next_magic(file:fd(), non_neg_integer()) -> + eof | {ok, non_neg_integer()} | {error, term()}. +find_next_magic(Fd, Offset) -> + maybe + {ok, _} ?= file:position(Fd, Offset), + find_next_magic_loop(Fd, Offset, <<>>) + else + {error, Reason} -> + {error, Reason} + end. + +-spec find_next_magic_loop(file:fd(), non_neg_integer(), binary()) -> + eof | {ok, non_neg_integer()} | {error, term()}. +find_next_magic_loop(Fd, Offset, Tail) -> + case file:read(Fd, ?RESYNC_READ_BYTES) of + eof -> + eof; + {ok, Bin} -> + SearchBin = <>, + BaseOffset = Offset - byte_size(Tail), + case binary:match(SearchBin, ?RECORD_MAGIC) of + {Pos, _Len} -> + {ok, BaseOffset + Pos}; + nomatch -> + NextTail = trailing_bytes(SearchBin, byte_size(?RECORD_MAGIC) - 1), + find_next_magic_loop(Fd, Offset + byte_size(Bin), NextTail) + end; + {error, Reason} -> + {error, Reason} + end. + +-spec trailing_bytes(binary(), non_neg_integer()) -> binary(). +trailing_bytes(Bin, KeepBytes) -> + Size = byte_size(Bin), + case Size =< KeepBytes of + true -> + Bin; + false -> + binary:part(Bin, Size - KeepBytes, KeepBytes) + end. + +-spec next_segment_id([#segment{}], pos_integer()) -> undefined | pos_integer(). +next_segment_id(Segments, SegmentId) -> + case lists:dropwhile(fun(#segment{id = Id}) -> Id =< SegmentId end, Segments) of + [#segment{id = NextSegmentId} | _] -> + NextSegmentId; + [] -> + undefined + end. + +-spec segment_path(file:filename_all(), pos_integer()) -> file:filename_all(). +segment_path(Dir, SegmentId) -> + filename:join(Dir, io_lib:format("~6..0B~s", [SegmentId, ?SEGMENT_EXT])). + +-spec open_fds(outbox()) -> {ok, outbox()} | {error, term()}. +open_fds(Outbox) -> + case maybe_open_write_fd(Outbox) of + {ok, Outbox0} -> + maybe + {ok, NOutbox} ?= maybe_open_read_fd(Outbox0), + {ok, NOutbox} + else + {error, Reason} -> + _ = close_open_fds(Outbox0), + {error, Reason} + end; + {error, Reason} -> + {error, Reason} + end. + +-spec maybe_open_write_fd(outbox()) -> {ok, outbox()} | {error, term()}. +maybe_open_write_fd(Outbox = #outbox{ + segments = Segments, + writer = #outbox_writer{ + segment = WriteSegment, + fd = undefined + } + }) -> + case find_segment(Segments, WriteSegment) of + false -> + {ok, Outbox}; + _ -> + ensure_write_fd(WriteSegment, Outbox) + end. + +-spec maybe_open_read_fd(outbox()) -> {ok, outbox()} | {error, term()}. +maybe_open_read_fd(Outbox = #outbox{reader = #outbox_reader{segment = undefined}}) -> + {ok, Outbox}; +maybe_open_read_fd(Outbox = #outbox{reader = #outbox_reader{fd = undefined}}) -> + ensure_read_fd(Outbox); +maybe_open_read_fd(Outbox = #outbox{}) -> + {ok, Outbox}. + +-spec ensure_write_fd(pos_integer(), outbox()) -> {ok, outbox()} | {error, term()}. +ensure_write_fd(SegmentId, Outbox = #outbox{ + writer = #outbox_writer{ + segment = SegmentId, + fd = Fd + } + }) + when Fd =/= undefined -> + {ok, Outbox}; +ensure_write_fd(SegmentId, Outbox = #outbox{dir = Dir}) -> + Writer0 = (Outbox#outbox.writer)#outbox_writer{segment = SegmentId}, + Outbox0 = close_write_fd(Outbox#outbox{writer = Writer0}), + Path = segment_path(Dir, SegmentId), + case file:open(Path, [append, raw, binary]) of + {ok, Fd} -> + Writer1 = (Outbox0#outbox.writer)#outbox_writer{fd = Fd}, + {ok, Outbox0#outbox{writer = Writer1}}; + {error, Reason} -> + {error, Reason} + end. + +-spec ensure_read_fd(outbox()) -> {ok, outbox()} | {error, term()}. +ensure_read_fd(Outbox = #outbox{reader = #outbox_reader{segment = undefined}}) -> + {ok, Outbox}; +ensure_read_fd(Outbox = #outbox{reader = #outbox_reader{fd = Fd}}) when Fd =/= undefined -> + {ok, Outbox}; +ensure_read_fd(Outbox = #outbox{ + reader = Reader, + dir = Dir + }) -> + SegmentId = Reader#outbox_reader.segment, + Offset = Reader#outbox_reader.offset, + Path = segment_path(Dir, SegmentId), + case open_read_fd(Path, Offset) of + {ok, Fd} -> + {ok, Outbox#outbox{ + reader = Reader#outbox_reader{fd = Fd} + }}; + {error, Reason} -> + {error, Reason} + end. + +-spec switch_read_segment(undefined | pos_integer(), non_neg_integer(), outbox()) -> + {ok, outbox()} | {error, term()}. +switch_read_segment(undefined, _Offset, Outbox) -> + Reader0 = #outbox_reader{segment = undefined, offset = 0}, + {ok, close_read_fd(Outbox#outbox{reader = Reader0})}; +switch_read_segment(SegmentId, Offset, Outbox = #outbox{reader = Reader}) -> + ensure_read_fd(close_read_fd(Outbox#outbox{ + reader = Reader#outbox_reader{ + segment = SegmentId, + offset = Offset + } + })). + +-spec maybe_reset_reader_after_append(outbox(), outbox()) -> {ok, outbox()} | {error, term()}. +maybe_reset_reader_after_append(#outbox{reader = #outbox_reader{segment = undefined}}, Outbox) -> + reset_reader(Outbox); +maybe_reset_reader_after_append(_PrevOutbox, Outbox) -> + {ok, Outbox}. + +-spec detach_deleted_fds([#segment{}], outbox()) -> outbox(). +detach_deleted_fds(DeleteSegments, Outbox = #outbox{ + reader = #outbox_reader{segment = ReadSegment}, + writer = #outbox_writer{segment = WriteSegment} + }) -> + DeleteIds = [SegmentId || #segment{id = SegmentId} <- DeleteSegments], + Outbox0 = case lists:member(ReadSegment, DeleteIds) of + true -> + close_read_fd(Outbox); + false -> + Outbox + end, + case lists:member(WriteSegment, DeleteIds) of + true -> + close_write_fd(Outbox0); + false -> + Outbox0 + end. + +-spec close_open_fds(outbox()) -> outbox(). +close_open_fds(Outbox) -> + close_write_fd(close_read_fd(Outbox)). + +-spec close_read_fd(outbox()) -> outbox(). +close_read_fd(Outbox = #outbox{reader = #outbox_reader{fd = undefined}}) -> + Outbox; +close_read_fd(Outbox = #outbox{reader = Reader}) -> + Fd = Reader#outbox_reader.fd, + _ = maybe_close_fd(Fd), + Outbox#outbox{reader = Reader#outbox_reader{fd = undefined}}. + +-spec close_write_fd(outbox()) -> outbox(). +close_write_fd(Outbox = #outbox{writer = #outbox_writer{fd = undefined}}) -> + Outbox; +close_write_fd(Outbox = #outbox{writer = Writer}) -> + Fd = Writer#outbox_writer.fd, + _ = maybe_close_fd(Fd), + Outbox#outbox{writer = Writer#outbox_writer{fd = undefined}}. + +-spec maybe_close_fd(file:fd()) -> ok. +maybe_close_fd(Fd) -> + case file:close(Fd) of + ok -> + ok; + {error, badarg} -> + ok; + {error, terminated} -> + ok; + {error, _Reason} -> + ok + end. + +-spec maybe_persist_metadata(boolean(), outbox()) -> ok | {error, term()}. +maybe_persist_metadata(true, Outbox) -> + persist_metadata(Outbox); +maybe_persist_metadata(false, _Outbox) -> + ok. + +-spec open_read_fd(file:filename_all(), non_neg_integer()) -> {ok, file:fd()} | {error, term()}. +open_read_fd(Path, Offset) -> + case file:open(Path, [read, raw, binary]) of + {ok, Fd} -> + maybe + {ok, _} ?= file:position(Fd, Offset), + {ok, Fd} + else + {error, Reason} -> + ok = file:close(Fd), + {error, Reason} + end; + {error, Reason} -> + {error, Reason} + end. diff --git a/apps/endpoint/src/endpoint_subscription.erl b/apps/endpoint/src/endpoint_subscription.erl new file mode 100644 index 0000000..742feb3 --- /dev/null +++ b/apps/endpoint/src/endpoint_subscription.erl @@ -0,0 +1,457 @@ +%%%------------------------------------------------------------------- +%%% @author anlicheng +%%% @copyright (C) 2025, +%%% @doc +%%% Endpoint 数据分发订阅索引。 +%%% +%%% 当前实现面向较大订阅量场景,publish 热路径不再全量遍历订阅表,而是把 +%%% 订阅拆成 exact 和 wildcard 两类: +%%% +%%%
    +%%%
  • 精确订阅写入 endpoint_subscription_exact,publish 时按 RouteKey +%%% 直接 ets:lookup/2。
  • +%%%
  • 包含通配符的订阅写入 ETS trie。trie 边存放在 +%%% endpoint_subscription_trie_edge,节点订阅存放在 +%%% endpoint_subscription_trie_sub。
  • +%%%
  • endpoint_subscription_reverse 保存 SubscriberPid 到订阅对象的反向索引, +%%% 用于 unsubscribe 和进程 DOWN 清理。
  • +%%%
  • endpoint_subscription_pid 记录每个 SubscriberPid 的 monitor 和订阅计数, +%%% 同一个 pid 只 monitor 一次。
  • +%%%
+%%% +%%% 所有 matcher 共用同一个 root node,root node id 固定为 0。新 trie 节点使用 +%%% 单调递增的 integer node id。subscribe/unsubscribe 通过本 gen_server 串行写 ETS; +%%% publish/2 直接并发读取 ETS,避免中心 gen_server 成为数据分发瓶颈。 +%%% +%%% 匹配规则: +%%% +%%%
    +%%%
  • 普通 segment 必须完全相等,例如 <<"device/a/temp">> 只精确匹配 +%%% 同名订阅。
  • +%%%
  • <<"*">> 是单级通配,只匹配一个 segment。例如 +%%% <<"device/*/temp">> 匹配 <<"device/a/temp">>,不匹配 +%%% <<"device/a/b/temp">>。
  • +%%%
  • <<"+">> 是末尾多级通配,只允许出现在 matcher 最后一段,并且至少 +%%% 匹配一个剩余 segment。例如 <<"device/+">> 匹配 <<"device/a">> +%%% 和 <<"device/a/temp">>,不匹配 <<"device">>。
  • +%%%
+%%% +%%% publish/2 的匹配流程: +%%% +%%%
    +%%%
  1. 从 exact 表直接查找 RouteKey 对应的订阅。
  2. +%%%
  3. 把 RouteKey 按 "/" 拆分后,从 trie root 开始逐层匹配。每层最多查找 +%%% 当前 segment 分支和 <<"*">> 分支。
  4. +%%%
  5. 每消费一层前,收集当前候选节点上的 plus 订阅;全部 segment 消费完后, +%%% 收集候选节点上的 exact 订阅。
  6. +%%%
  7. 合并 exact 和 trie 命中结果,按原有优先级排序,再按 SubscriberPid 去重。 +%%% 同一个 pid 同时被多个 matcher 命中时,保留优先级最高的订阅。
  8. +%%%
  9. 对最终订阅者执行 endpoint:forward/2。
  10. +%%%
+%%% @end +%%% Created : 07. 11月 2025 16:27 +%%%------------------------------------------------------------------- +-module(endpoint_subscription). +-author("anlicheng"). + +-behaviour(gen_server). + +%% API +-export([start_link/0]). +-export([subscribe/2, unsubscribe/2, publish/2, get_subscribers/0]). +-export([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). +-define(EXACT_TAB, endpoint_subscription_exact). +-define(TRIE_EDGE_TAB, endpoint_subscription_trie_edge). +-define(TRIE_SUB_TAB, endpoint_subscription_trie_sub). +-define(REVERSE_TAB, endpoint_subscription_reverse). +-define(PID_TAB, endpoint_subscription_pid). +-define(ROOT_NODE, 0). + +%% 定义订阅者 +-record(subscriber, { + topic :: binary(), + subscriber_pid :: pid(), + components = [], + monitor_ref :: undefined | reference(), + %% 优先级 + %% 1. 完全匹配的topic优先级别最高 + %% 2. 带 * 的订阅 + %% 3. 带 + 的订阅 + order :: integer() +}). + +-record(state, { + exact_tid :: ets:tid(), + edge_tid :: ets:tid(), + trie_sub_tid :: ets:tid(), + reverse_tid :: ets:tid(), + pid_tid :: ets:tid(), + next_node_id = 1 :: pos_integer() +}). + +%%%=================================================================== +%%% 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 unsubscribe(Topic :: binary(), SubscriberPid :: pid()) -> ok. +unsubscribe(Topic, SubscriberPid) when is_binary(Topic), is_pid(SubscriberPid) -> + gen_server:call(?SERVER, {unsubscribe, Topic, SubscriberPid}). + +-spec get_subscribers() -> {ok, Subscribers :: list()}. +get_subscribers() -> + gen_server:call(?SERVER, get_subscribers). + +-spec publish(RouteKey :: binary(), Content :: binary()) -> ok. +publish(RouteKey, Content) when is_binary(RouteKey), is_binary(Content) -> + case ets:info(?EXACT_TAB) of + undefined -> + ok; + _ -> + MatchedSubscribers = match_route_key(RouteKey), + lists:foreach(fun(#subscriber{subscriber_pid = SubscriberPid}) -> + endpoint:forward(SubscriberPid, Content) + end, MatchedSubscribers), + maybe_log_unmatched_publish(RouteKey, Content, MatchedSubscribers), + logger:debug("[endpoint_subscription] route_key: ~p, match_count: ~p", [RouteKey, length(MatchedSubscribers)]), + ok + end. + +%% @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 = endpoint_util:set_metadata(), + ExactTid = ets:new(?EXACT_TAB, [named_table, protected, bag, {read_concurrency, true}]), + EdgeTid = ets:new(?TRIE_EDGE_TAB, [named_table, protected, set, {read_concurrency, true}]), + TrieSubTid = ets:new(?TRIE_SUB_TAB, [named_table, protected, bag, {read_concurrency, true}]), + ReverseTid = ets:new(?REVERSE_TAB, [named_table, protected, bag]), + PidTid = ets:new(?PID_TAB, [named_table, protected, set]), + {ok, #state{ + exact_tid = ExactTid, + edge_tid = EdgeTid, + trie_sub_tid = TrieSubTid, + reverse_tid = ReverseTid, + pid_tid = PidTid + }}. + +%% @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(get_subscribers, _From, State = #state{exact_tid = ExactTid, trie_sub_tid = TrieSubTid}) -> + Subscribers = exact_subscribers(ExactTid) ++ trie_subscribers(TrieSubTid), + {reply, {ok, Subscribers}, State}; + +handle_call({subscribe, Topic, SubscriberPid}, _From, State = #state{reverse_tid = ReverseTid}) -> + Components = of_components(Topic), + case is_valid_components(Components) of + true -> + case has_subscription(ReverseTid, Topic, SubscriberPid) of + true -> + {reply, ok, State}; + false -> + {MonitorRef, State1} = ensure_pid_monitor(SubscriberPid, State), + Sub = #subscriber{ + topic = Topic, + subscriber_pid = SubscriberPid, + components = Components, + monitor_ref = MonitorRef, + order = order_num(Components) + }, + State2 = insert_subscription(Topic, Components, SubscriberPid, Sub, State1), + {reply, ok, State2} + end; + false -> + {reply, {error, <<"invalid topic name">>}, State} + end; + +handle_call({unsubscribe, Topic, SubscriberPid}, _From, State = #state{reverse_tid = ReverseTid}) -> + Removed = [Reverse || Reverse = {SubscriberPid0, Topic0, _Kind, _NodeId, _Sub} <- ets:lookup(ReverseTid, SubscriberPid), + SubscriberPid =:= SubscriberPid0, Topic =:= Topic0], + lists:foreach(fun(Reverse) -> delete_subscription(Reverse, State) end, Removed), + State1 = release_pid_monitor(SubscriberPid, length(Removed), State), + {reply, ok, State1}. + +%% @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(_Request, State = #state{}) -> + {noreply, State}. + +%% @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', MonitorRef, process, SubscriberPid, Reason}, State = #state{reverse_tid = ReverseTid, pid_tid = PidTid}) -> + logger:debug("[endpoint_subscription] subscriber: ~p, down with reason: ~p", [SubscriberPid, Reason]), + Removed = ets:lookup(ReverseTid, SubscriberPid), + lists:foreach(fun(Reverse) -> delete_subscription(Reverse, State) end, Removed), + case ets:lookup(PidTid, SubscriberPid) of + [{SubscriberPid, MonitorRef, _Count}] -> + ets:delete(PidTid, SubscriberPid); + _ -> + ok + end, + {noreply, State}; + +handle_info(Info, State = #state{}) -> + logger:debug("[endpoint_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_route_key(binary()) -> [#subscriber{}]. +match_route_key(RouteKey) when is_binary(RouteKey) -> + ExactSubs = [Sub || {_Topic, Sub} <- ets:lookup(?EXACT_TAB, RouteKey)], + TrieSubs = match_trie(of_components(RouteKey)), + Sorted = lists:sort(fun compare_subscriber/2, ExactSubs ++ TrieSubs), + dedupe_subscribers(Sorted). + +-spec maybe_log_unmatched_publish(binary(), binary(), [#subscriber{}]) -> ok. +maybe_log_unmatched_publish(RouteKey, Content, []) -> + endpoint_log:unmatched_publish(RouteKey, Content); +maybe_log_unmatched_publish(_RouteKey, _Content, _MatchedSubscribers) -> + ok. + +-spec of_components(Topic :: binary()) -> [binary()]. +of_components(Topic) when is_binary(Topic) -> + binary:split(Topic, <<$/>>, [global]). + +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 has_subscription(ets:tid(), binary(), pid()) -> boolean(). +has_subscription(ReverseTid, Topic, SubscriberPid) -> + Subscriptions = ets:lookup(ReverseTid, SubscriberPid), + lists:any(fun({SubscriberPid0, Topic0, _Kind, _NodeId, _Sub}) -> + Topic =:= Topic0 andalso SubscriberPid =:= SubscriberPid0 + end, Subscriptions). + +-spec compare_subscriber(#subscriber{}, #subscriber{}) -> boolean(). +compare_subscriber(#subscriber{order = Order0, topic = Topic0}, #subscriber{order = Order1, topic = Topic1}) -> + case Order0 =:= Order1 of + true -> + Topic0 =< Topic1; + false -> + Order0 < Order1 + end. + +-spec dedupe_subscribers([#subscriber{}]) -> [#subscriber{}]. +dedupe_subscribers(Subscribers) -> + {_, Result} = lists:foldl(fun(S = #subscriber{subscriber_pid = SubscriberPid}, {Seen, Acc}) -> + case sets:is_element(SubscriberPid, Seen) of + true -> + {Seen, Acc}; + false -> + {sets:add_element(SubscriberPid, Seen), [S | Acc]} + end + end, {sets:new(), []}, Subscribers), + lists:reverse(Result). + +-spec insert_subscription(binary(), [binary()], pid(), #subscriber{}, #state{}) -> #state{}. +insert_subscription(Topic, Components, SubscriberPid, Sub, State = #state{exact_tid = ExactTid, trie_sub_tid = TrieSubTid, reverse_tid = ReverseTid}) -> + case has_wildcard(Components) of + false -> + true = ets:insert(ExactTid, {Topic, Sub}), + true = ets:insert(ReverseTid, {SubscriberPid, Topic, exact, undefined, Sub}), + State; + true -> + {Kind, NodeId, State1} = insert_trie_subscription(Components, State), + true = ets:insert(TrieSubTid, {{NodeId, subscription_match_type(Kind)}, Sub}), + true = ets:insert(ReverseTid, {SubscriberPid, Topic, Kind, NodeId, Sub}), + State1 + end. + +-spec insert_trie_subscription([binary()], #state{}) -> {trie_exact | trie_plus, non_neg_integer(), #state{}}. +insert_trie_subscription(Components, State) -> + case Components of + [] -> + {trie_exact, ?ROOT_NODE, State}; + _ -> + case lists:last(Components) of + <<"+">> -> + Prefix = lists:sublist(Components, length(Components) - 1), + {NodeId, State1} = ensure_path(Prefix, State), + {trie_plus, NodeId, State1}; + _ -> + {NodeId, State1} = ensure_path(Components, State), + {trie_exact, NodeId, State1} + end + end. + +-spec subscription_match_type(trie_exact | trie_plus) -> exact | plus. +subscription_match_type(trie_exact) -> + exact; +subscription_match_type(trie_plus) -> + plus. + +-spec ensure_path([binary()], #state{}) -> {non_neg_integer(), #state{}}. +ensure_path(Components, State) -> + ensure_path(?ROOT_NODE, Components, State). + +-spec ensure_path(non_neg_integer(), [binary()], #state{}) -> {non_neg_integer(), #state{}}. +ensure_path(NodeId, [], State) -> + {NodeId, State}; +ensure_path(NodeId, [Segment | Rest], State = #state{edge_tid = EdgeTid, next_node_id = NextNodeId}) -> + EdgeKey = {NodeId, Segment}, + case ets:lookup(EdgeTid, EdgeKey) of + [{EdgeKey, ChildNodeId}] -> + ensure_path(ChildNodeId, Rest, State); + [] -> + true = ets:insert(EdgeTid, {EdgeKey, NextNodeId}), + ensure_path(NextNodeId, Rest, State#state{next_node_id = NextNodeId + 1}) + end. + +-spec delete_subscription(tuple(), #state{}) -> ok. +delete_subscription(Reverse = {_SubscriberPid, Topic, exact, undefined, Sub}, #state{exact_tid = ExactTid, reverse_tid = ReverseTid}) -> + true = ets:delete_object(ExactTid, {Topic, Sub}), + true = ets:delete_object(ReverseTid, Reverse), + ok; +delete_subscription(Reverse = {_SubscriberPid, _Topic, Kind, NodeId, Sub}, #state{trie_sub_tid = TrieSubTid, reverse_tid = ReverseTid}) -> + true = ets:delete_object(TrieSubTid, {{NodeId, subscription_match_type(Kind)}, Sub}), + true = ets:delete_object(ReverseTid, Reverse), + ok. + +-spec ensure_pid_monitor(pid(), #state{}) -> {reference(), #state{}}. +ensure_pid_monitor(SubscriberPid, State = #state{pid_tid = PidTid}) -> + case ets:lookup(PidTid, SubscriberPid) of + [{SubscriberPid, MonitorRef, Count}] -> + true = ets:insert(PidTid, {SubscriberPid, MonitorRef, Count + 1}), + {MonitorRef, State}; + [] -> + MonitorRef = erlang:monitor(process, SubscriberPid), + true = ets:insert(PidTid, {SubscriberPid, MonitorRef, 1}), + {MonitorRef, State} + end. + +-spec release_pid_monitor(pid(), non_neg_integer(), #state{}) -> #state{}. +release_pid_monitor(_SubscriberPid, 0, State) -> + State; +release_pid_monitor(SubscriberPid, RemovedCount, State = #state{pid_tid = PidTid}) -> + case ets:lookup(PidTid, SubscriberPid) of + [{SubscriberPid, MonitorRef, Count}] when Count =< RemovedCount -> + erlang:demonitor(MonitorRef, [flush]), + ets:delete(PidTid, SubscriberPid), + State; + [{SubscriberPid, MonitorRef, Count}] -> + true = ets:insert(PidTid, {SubscriberPid, MonitorRef, Count - RemovedCount}), + State; + [] -> + State + end. + +-spec match_trie([binary()]) -> [#subscriber{}]. +match_trie(Components) -> + match_trie([?ROOT_NODE], Components, []). + +-spec match_trie([non_neg_integer()], [binary()], [#subscriber{}]) -> [#subscriber{}]. +match_trie(NodeIds, [], Acc) -> + collect_trie_subscribers(NodeIds, exact) ++ Acc; +match_trie(NodeIds, [Segment | Rest], Acc) -> + PlusSubs = collect_trie_subscribers(NodeIds, plus), + NextNodeIds = next_trie_nodes(NodeIds, Segment), + match_trie(NextNodeIds, Rest, PlusSubs ++ Acc). + +-spec collect_trie_subscribers([non_neg_integer()], exact | plus) -> [#subscriber{}]. +collect_trie_subscribers(NodeIds, MatchType) -> + lists:flatmap(fun(NodeId) -> + [Sub || {{NodeId0, MatchType0}, Sub} <- ets:lookup(?TRIE_SUB_TAB, {NodeId, MatchType}), + NodeId0 =:= NodeId, MatchType0 =:= MatchType] + end, NodeIds). + +-spec next_trie_nodes([non_neg_integer()], binary()) -> [non_neg_integer()]. +next_trie_nodes(NodeIds, Segment) -> + lists:usort(lists:flatmap(fun(NodeId) -> + lookup_child(NodeId, Segment) ++ lookup_child(NodeId, <<"*">>) + end, NodeIds)). + +-spec lookup_child(non_neg_integer(), binary()) -> [non_neg_integer()]. +lookup_child(NodeId, Segment) -> + EdgeKey = {NodeId, Segment}, + case ets:lookup(?TRIE_EDGE_TAB, EdgeKey) of + [{EdgeKey, ChildNodeId}] -> + [ChildNodeId]; + [] -> + [] + end. + +-spec has_wildcard([binary()]) -> boolean(). +has_wildcard(Components) -> + lists:any(fun(Component) -> Component =:= <<"*">> orelse Component =:= <<"+">> end, Components). + +-spec exact_subscribers(ets:tid()) -> [#subscriber{}]. +exact_subscribers(ExactTid) -> + [Sub || {_Topic, Sub} <- ets:tab2list(ExactTid)]. + +-spec trie_subscribers(ets:tid()) -> [#subscriber{}]. +trie_subscribers(TrieSubTid) -> + [Sub || {_Key, Sub} <- ets:tab2list(TrieSubTid)]. diff --git a/apps/iot/src/endpoint/endpoint_sup_sup.erl b/apps/endpoint/src/endpoint_sup.erl similarity index 75% rename from apps/iot/src/endpoint/endpoint_sup_sup.erl rename to apps/endpoint/src/endpoint_sup.erl index 8ff2b9b..8bdfe89 100644 --- a/apps/iot/src/endpoint/endpoint_sup_sup.erl +++ b/apps/endpoint/src/endpoint_sup.erl @@ -3,7 +3,7 @@ %% @end %%%------------------------------------------------------------------- --module(endpoint_sup_sup). +-module(endpoint_sup). -behaviour(supervisor). -include("endpoint.hrl"). @@ -28,6 +28,15 @@ start_link() -> init([]) -> SupFlags = #{strategy => one_for_all, intensity => 1000, period => 3600}, ChildSpecs = [ + #{ + id => endpoint_log, + start => {'endpoint_log', start_link, []}, + restart => permanent, + shutdown => 2000, + type => worker, + modules => ['endpoint_log'] + }, + #{ id => endpoint_subscription, start => {'endpoint_subscription', start_link, []}, @@ -38,12 +47,12 @@ init([]) -> }, #{ - id => 'endpoint_sup', - start => {'endpoint_sup', start_link, []}, + id => 'endpoint_adapter_sup', + start => {'endpoint_adapter_sup', start_link, []}, restart => permanent, shutdown => 2000, type => supervisor, - modules => ['endpoint_sup'] + modules => ['endpoint_adapter_sup'] } ], - {ok, {SupFlags, ChildSpecs}}. \ No newline at end of file + {ok, {SupFlags, ChildSpecs}}. diff --git a/apps/endpoint/src/endpoint_tester.erl b/apps/endpoint/src/endpoint_tester.erl new file mode 100644 index 0000000..262e080 --- /dev/null +++ b/apps/endpoint/src/endpoint_tester.erl @@ -0,0 +1,93 @@ +%%%------------------------------------------------------------------- +%%% @author anlicheng +%%% @copyright (C) 2026, +%%% @doc +%%% +%%% @end +%%% Created : 11. 5月 2026 20:16 +%%%------------------------------------------------------------------- +-module(endpoint_tester). +-author("anlicheng"). +-include("endpoint.hrl"). + +%% API +-export([test/1]). + +%% http接口的测试 +test(#http_endpoint{url = Url, pool_size = PoolSize}) when is_integer(PoolSize), PoolSize > 0 -> + Body = <<"">>, + ContentType = "application/json", + case httpc:request(post, {Url, [], ContentType, Body}, [], []) of + {ok, _} -> + ok; + {error, Reason} -> + {error, Reason} + end; + +%% 测试mqtt +test(#mqtt_endpoint{host = Host, port = Port, username = Username, password = Password}) -> + %% 用户测试的client + ClientId = "mqtt_client_test:" ++ iot_util:rand_bytes(16), + Opts = [ + {owner, self()}, + {clientid, ClientId}, + {host, binary_to_list(Host)}, + {port, Port}, + {tcp_opts, []}, + {username, binary_to_list(Username)}, + {password, binary_to_list(Password)}, + {keepalive, 86400}, + {auto_ack, true}, + {connect_timeout, 5000}, + {proto_ver, v5}, + {retry_interval, 5000} + ], + + case emqtt:start_link(Opts) of + {ok, ConnPid} -> + case catch emqtt:connect(ConnPid, 5000) of + {ok, _} -> + emqtt:stop(ConnPid), + ok; + {error, _Reason} -> + {error, <<"connect mqtt server failed">>}; + _Error -> + emqtt:stop(ConnPid), + {error, <<"connect mqtt server failed">>} + end; + Other -> + logger:warning("[endpint_handler] test connect mqtt with options: ~p, get error: ~p", [Opts, Other]), + {error, <<"connect mqtt server failed">>} + end; + +%% 测试kafka +test(#kafka_endpoint{sasl_config = SaslConfig, bootstrap_servers = BootstrapServers, topic = Topic}) -> + BaseConfig = [ + {reconnect_cool_down_seconds, 5}, + {socket_options, [{keepalive, true}]} + ], + + ClientConfig = case SaslConfig of + {Mechanism, Username, Password} -> + [{sasl, {Mechanism, Username, Password}}|BaseConfig]; + undefined -> + BaseConfig + end, + ClientId = brod_client_test, + _ = catch brod:stop_client(ClientId), + + case catch brod:start_link_client(BootstrapServers, ClientId, ClientConfig) of + {ok, _ClientPid} -> + case brod:start_producer(ClientId, Topic, _ProducerConfig = []) of + ok -> + ok = brod:stop_client(ClientId), + ok; + {error, Reason} -> + logger:debug("[endpint_handler] start_producer: ~p, get error: ~p", [ClientId, Reason]), + _ = catch brod:stop_client(ClientId), + {error, <<"config kafka server failed">>} + end; + Error -> + logger:debug("[endpint_handler] start_client: ~p, get error: ~p", [ClientId, Error]), + {error, <<"config kafka server failed">>} + end. \ No newline at end of file diff --git a/apps/endpoint/src/endpoint_util.erl b/apps/endpoint/src/endpoint_util.erl new file mode 100644 index 0000000..bdcd3f3 --- /dev/null +++ b/apps/endpoint/src/endpoint_util.erl @@ -0,0 +1,18 @@ +%%%------------------------------------------------------------------- +%%% @doc Endpoint-local utility functions. +%%%------------------------------------------------------------------- +-module(endpoint_util). + +-export([set_metadata/0, sha256/1]). + +-spec set_metadata() -> ok. +set_metadata() -> + logger:set_process_metadata(#{domain => [endpoint]}). + +-spec sha256(string() | binary()) -> binary(). +sha256(Str) when is_list(Str) -> + sha256(unicode:characters_to_binary(Str)); +sha256(Bin) when is_binary(Bin) -> + HashBin = crypto:hash(sha256, Bin), + HexStr = lists:flatten([io_lib:format("~2.16.0B", [B]) || B <- binary:bin_to_list(HashBin)]), + list_to_binary(string:lowercase(HexStr)). diff --git a/apps/iot/include/domain_model.hrl b/apps/iot/include/domain_model.hrl new file mode 100644 index 0000000..3c89f80 --- /dev/null +++ b/apps/iot/include/domain_model.hrl @@ -0,0 +1,20 @@ +%%%------------------------------------------------------------------- +%%% @author Codex +%%% @doc +%%% 领域模型定义 +%%% @end +%%%------------------------------------------------------------------- + +-record(host_info, { + id :: integer(), + uuid :: binary(), + authorize_status :: integer(), + status :: integer() +}). + +-record(device_info, { + id :: integer(), + host_id :: integer(), + device_uuid :: binary(), + status :: integer() +}). diff --git a/apps/iot/include/iot.hrl b/apps/iot/include/iot.hrl index ac02e04..0ea5bfd 100644 --- a/apps/iot/include/iot.hrl +++ b/apps/iot/include/iot.hrl @@ -13,6 +13,10 @@ -define(HOST_ONLINE, 1). -define(HOST_NOT_JOINED, -1). +%% 主机是否授权处理上报数据 +-define(HOST_DENIED, 0). +-define(HOST_AUTHORIZED, 1). + %% 设备是否在线状态 -define(DEVICE_OFFLINE, 0). -define(DEVICE_ONLINE, 1). @@ -22,52 +26,3 @@ -define(TASK_STATUS_INIT, -1). %% 未接入 -define(TASK_STATUS_FAILED, 0). %% 离线 -define(TASK_STATUS_OK, 1). %% 在线 - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%%%% 二级分类定义 -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -%% 缓存数据库表 --record(kv, { - key :: binary(), - val :: binary() | list() | map() | sets:set(), - expire_at = 0 :: integer(), - type :: atom() -}). - -%% id生成器 --record(id_generator, { - tab :: atom(), - increment_id = 0 :: integer() -}). - -%% 统计项 --record(option, { - success_num = 0, - fail_num = 0 -}). - -%% 北向数据 --record(north_data, { - id = 0 :: integer(), - location_code :: binary(), - %% 数据库类型的endpoint, 可以返回list: [{K, V}, {K1, V1}] - fields :: [{K :: binary(), V :: any()}], - timestamp = 0 :: integer() -}). - -%% 事件相关的数据 --record(event_data, { - id = 0 :: integer(), - location_code :: binary(), - event_type :: integer(), - params :: map() -}). - -%% 发送数据 --record(post_data, { - id = 0 :: integer(), - location_code :: binary(), - %% 数据库类型的endpoint, 可以返回list: [{K, V}, {K1, V1}] - body :: binary() | list() -}). \ No newline at end of file diff --git a/apps/iot/include/iot_tables.hrl b/apps/iot/include/iot_tables.hrl index 7eebc7c..372b69a 100644 --- a/apps/iot/include/iot_tables.hrl +++ b/apps/iot/include/iot_tables.hrl @@ -1,23 +1,17 @@ %%%------------------------------------------------------------------- %%% @author anlicheng -%%% @copyright (C) 2025, +%%% @copyright (C) 2026, %%% @doc %%% %%% @end -%%% Created : 08. 5月 2025 12:08 +%%% Created : 24. 4月 2026 16:54 %%%------------------------------------------------------------------- -author("anlicheng"). -%% 用来保存微服务的配置 --record(service_config, { - service_id :: binary(), - host_uuid :: binary(), - config_json = <<>> :: binary(), - %% 保持上一个版本的内容,错误时回滚 - last_config_json = <<>> :: binary(), - %% 最后一次修改的用户id - last_edit_user :: integer(), - %% 状态: 0: 停止, 1: 运行中 - update_ts = 0, - create_ts = 0 -}). \ No newline at end of file +-record(efka_client, { + uuid :: binary(), + token_hash :: binary(), + salt :: binary(), + heartbeat_secret :: binary(), + timestamp = 0 :: integer() +}). diff --git a/apps/iot/include/message.hrl b/apps/iot/include/message.hrl deleted file mode 100644 index 6673fa3..0000000 --- a/apps/iot/include/message.hrl +++ /dev/null @@ -1,86 +0,0 @@ -%%%------------------------------------------------------------------- -%%% @author anlicheng -%%% @copyright (C) 2025, -%%% @doc -%%% 扩展部分, 1: 支持基于topic的pub/sub机制; 2. 基于target的单点通讯和广播 -%%% @end -%%% Created : 21. 4月 2025 17:28 -%%%------------------------------------------------------------------- --author("anlicheng"). - -%% efka主动发起的消息体类型, 消息大类 --define(PACKET_REQUEST, 16#01). --define(PACKET_RESPONSE, 16#02). - -%% efka主动发起不需要返回的数据 --define(PACKET_CAST, 16#03). - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%%%% 二级分类定义 -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -%% 主机端上报数据类型标识 --define(MESSAGE_AUTH_REQUEST, 16#01). --define(MESSAGE_AUTH_REPLY, 16#02). - --define(MESSAGE_COMMAND, 16#03). --define(MESSAGE_DEPLOY, 16#04). --define(MESSAGE_PUB, 16#05). - --define(MESSAGE_DATA, 16#06). --define(MESSAGE_EVENT, 16#07). - -%% efka主动上报的event-stream流, 单向消息,主要是: docker-create的实时处理逻辑上报 --define(MESSAGE_EVENT_STREAM, 16#08). - --define(MESSAGE_JSONRPC_REQUEST, 16#F0). --define(MESSAGE_JSONRPC_REPLY, 16#F1). - -%%%% 命令类型子分类, 不需要返回值 -%% 授权 --define(COMMAND_AUTH, 16#08). - --record(auth_request, { - uuid :: binary(), - username :: binary(), - salt :: binary(), - token :: binary(), - timestamp :: integer() -}). - --record(auth_reply, { - code :: integer(), - payload :: binary() -}). - --record(pub, { - topic :: binary(), - qos = 0 :: integer(), - content :: binary() -}). - --record(command, { - command_type :: integer(), - command :: binary() -}). - --record(jsonrpc_request, { - method :: binary(), - params = <<>> :: any() -}). - --record(jsonrpc_reply, { - result :: any() | undefined, - error :: any() | undefined -}). - --record(data, { - route_key :: binary(), - metric :: binary() -}). - --record(task_event_stream, { - task_id :: integer(), - type :: binary(), - stream :: binary() -}). \ No newline at end of file diff --git a/apps/iot/include/protocol.hrl b/apps/iot/include/protocol.hrl new file mode 100644 index 0000000..61660ad --- /dev/null +++ b/apps/iot/include/protocol.hrl @@ -0,0 +1,16 @@ +%%%------------------------------------------------------------------- +%%% @author anlicheng +%%% @copyright (C) 2025, +%%% @doc +%%% 传输层协议常量定义 +%%% @end +%%%------------------------------------------------------------------- +-author("anlicheng"). + +%% 一级帧类型 +%% REQUEST: 需要响应的请求帧 +%% REPLY: 对 REQUEST 的响应帧 +%% CAST: 单向消息帧,不需要响应 +-define(FRAME_REQUEST, 16#01). +-define(FRAME_REPLY, 16#02). +-define(FRAME_CAST, 16#03). \ No newline at end of file diff --git a/apps/iot/priv/2024-01-18-log.txt b/apps/iot/priv/2024-01-18-log.txt deleted file mode 100644 index c778700..0000000 --- a/apps/iot/priv/2024-01-18-log.txt +++ /dev/null @@ -1,20 +0,0 @@ -{"device_uuid":"20118448129232486417014256831677", "key":"epi", "label":"kWh", "name":"正向总有功电能", "timestamp":1705576081, "type":"AI", "unit":5, "value":314498} -{"device_uuid":"20118448129232486417014256831677", "key":"a_voltage", "label":"V", "name":"A相电压", "timestamp":1705576081, "type":"AI", "unit":0, "value":224.1} -{"device_uuid":"20118448129232486417014256831677", "key":"a_current", "label":"A", "name":"A相电流", "timestamp":1705576081, "type":"AI", "unit":3, "value":0.394} -{"device_uuid":"20118448129232486417014256831677", "key":"b_voltage", "label":"V", "name":"B相电压", "timestamp":1705576081, "type":"AI", "unit":0, "value":225.3} -{"device_uuid":"20118448129232486417014256831677", "key":"b_current", "label":"A", "name":"B相电流", "timestamp":1705576081, "type":"AI", "unit":3, "value":0.323} -{"device_uuid":"20118448129232486417014256831677", "key":"c_voltage", "label":"V", "name":"C相电压", "timestamp":1705576081, "type":"AI", "unit":0, "value":225} -{"device_uuid":"20118448129232486417014256831677", "key":"c_current", "label":"A", "name":"C相电流", "timestamp":1705576081, "type":"AI", "unit":3, "value":0.269} -{"device_uuid":"20118448129232486417014256831677", "key":"active_power", "label":"kW", "name":"瞬时总有功功率", "timestamp":1705576081, "type":"AI", "unit":23, "value":20.91} -{"device_uuid":"20118448129232486417014256831677", "key":"power_factor", "label":"", "name":"总功率因数", "timestamp":1705576081, "type":"AI", "unit":16, "value":0.994} -{"device_uuid":"20118448549921177617014256841701", "key":"epi", "label":"kWh", "name":"正向总有功电能", "timestamp":1705576038, "type":"AI", "unit":5, "value":299493.6} -{"device_uuid":"20118448549921177617014256841701", "key":"a_voltage", "label":"V", "name":"A相电压", "timestamp":1705576038, "type":"AI", "unit":0, "value":225.9} -{"device_uuid":"20118448549921177617014256841701", "key":"a_current", "label":"A", "name":"A相电流", "timestamp":1705576038, "type":"AI", "unit":3, "value":0.318} -{"device_uuid":"20118448549921177617014256841701", "key":"b_voltage", "label":"V", "name":"B相电压", "timestamp":1705576038, "type":"AI", "unit":0, "value":225.7} -{"device_uuid":"20118448549921177617014256841701", "key":"b_current", "label":"A", "name":"B相电流", "timestamp":1705576038, "type":"AI", "unit":3, "value":0.305} -{"device_uuid":"20118448549921177617014256841701", "key":"c_voltage", "label":"V", "name":"C相电压", "timestamp":1705576038, "type":"AI", "unit":0, "value":225} -{"device_uuid":"20118448549921177617014256841701", "key":"c_current", "label":"A", "name":"C相电流", "timestamp":1705576038, "type":"AI", "unit":3, "value":0.3} -{"device_uuid":"20118448549921177617014256841701", "key":"active_power", "label":"kW", "name":"瞬时总有功功率", "timestamp":1705576038, "type":"AI", "unit":23, "value":15.056} -{"device_uuid":"20118448549921177617014256841701", "key":"power_factor", "label":"", "name":"总功率因数", "timestamp":1705576038, "type":"AI", "unit":16, "value":1} -{"device_uuid":"20118448970190438417014256851724", "key":"epi", "label":"kWh", "name":"正向总有功电能", "timestamp":1705576117, "type":"AI", "unit":5, "value":285011} -{"device_uuid":"20118448970190438417014256851724", "key":"a_voltage", "label":"V", "name":"A相电压", "timestamp":1705576117, "type":"AI", "unit":0, "value":225.1} \ No newline at end of file diff --git a/apps/iot/priv/ssl/server.crt b/apps/iot/priv/ssl/server.crt new file mode 100644 index 0000000..5dd301c --- /dev/null +++ b/apps/iot/priv/ssl/server.crt @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDwzCCAqugAwIBAgIUMhNyuVo6MieWs2I9LsyTTiDKtYgwDQYJKoZIhvcNAQEL +BQAwgYkxCzAJBgNVBAYTAkNOMRAwDgYDVQQIDAdCZWlqaW5nMRAwDgYDVQQHDAdC +ZWlqaW5nMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxEjAQBgNV +BAMMCWFubGljaGVuZzEfMB0GCSqGSIb3DQEJARYQMjQ0MTA4NzE1QHFxLmNvbTAe +Fw0yNTA0MjEwMzUyNTlaFw0yNjA0MjEwMzUyNTlaMIGJMQswCQYDVQQGEwJDTjEQ +MA4GA1UECAwHQmVpamluZzEQMA4GA1UEBwwHQmVpamluZzEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMRIwEAYDVQQDDAlhbmxpY2hlbmcxHzAdBgkq +hkiG9w0BCQEWEDI0NDEwODcxNUBxcS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IB +DwAwggEKAoIBAQDdxxyYG0zV2KzeiVH5AGj1X1h7vLAHVS8FGkPL2QBBqW5/PrJ/ +z/sv3QJz6dB3ElOnk14GsY8lWk8uX/fjgNIPawN1G2/bCdMrlPOCEJEIOqZvzQWg +PLRPGjoZx+wjCM/H7h47KVr4GNbo8MakLJeg6QB3rEEIiPrhQIoq9N7AhcjbEvWR +NIxIQrpSqMCE2A5RoKUxAMum9rEoe/6PBw2GgEsQl5E6suRZw3wavg3aUU+6MMyx +iiKEA6fCD5gDqgSo/xjgDUWJIOTTrmW6RBTcWP9iBUjWnCovQQ9zc6CzDXRgfsz9 +7uXQt9fuQEi++lcGnrsePjC99PnirCvmj/C/AgMBAAGjITAfMB0GA1UdDgQWBBT6 +n4jUTWQNcastC5jPHuU7CDzLFjANBgkqhkiG9w0BAQsFAAOCAQEAZJ9fY2Z+4vKr +bBqwHmfBjEnGgS7L2mC7uS2x/x2meBRKlAlw5+nKdaUBccyI2baI3P1mh/iV72Wr +OTcwUwSS6gIOB7JeWSB0UT7KbEOJ1tM/1HYs5F9tOT94P4Adm2gcKY81UlJMfSNQ +WyWFLjWOk/5fUP42BmgbUIafTT9p+LeP6NOyEs6b4hGpF3q5L1QDwMUfASpOWtHn +O7VyBvFyCGkVchnorWJ3ZXPaa7hy+2ULOK/d9zH3xxq4LKRclAS5XAMWHuw6/+tu +CoxA+RPnaqHexPSzYlEElOT286FDyZHadjUDD0q/0Um92NUM3r+UrNEs/OHp7/nU +M3BVM7H6LQ== +-----END CERTIFICATE----- diff --git a/apps/iot/priv/ssl/server.csr b/apps/iot/priv/ssl/server.csr new file mode 100644 index 0000000..f8a18cf --- /dev/null +++ b/apps/iot/priv/ssl/server.csr @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICzzCCAbcCAQAwgYkxCzAJBgNVBAYTAkNOMRAwDgYDVQQIDAdCZWlqaW5nMRAw +DgYDVQQHDAdCZWlqaW5nMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBM +dGQxEjAQBgNVBAMMCWFubGljaGVuZzEfMB0GCSqGSIb3DQEJARYQMjQ0MTA4NzE1 +QHFxLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN3HHJgbTNXY +rN6JUfkAaPVfWHu8sAdVLwUaQ8vZAEGpbn8+sn/P+y/dAnPp0HcSU6eTXgaxjyVa +Ty5f9+OA0g9rA3Ubb9sJ0yuU84IQkQg6pm/NBaA8tE8aOhnH7CMIz8fuHjspWvgY +1ujwxqQsl6DpAHesQQiI+uFAiir03sCFyNsS9ZE0jEhCulKowITYDlGgpTEAy6b2 +sSh7/o8HDYaASxCXkTqy5FnDfBq+DdpRT7owzLGKIoQDp8IPmAOqBKj/GOANRYkg +5NOuZbpEFNxY/2IFSNacKi9BD3NzoLMNdGB+zP3u5dC31+5ASL76Vwaeux4+ML30 ++eKsK+aP8L8CAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQBHdTrwcS0Ip5XO/zJ/ +wFZvxe8wcdMBaHjSRx09Lgy3V1L9d0DPOfuUP4LP8UCxniucA2JLugJO1wr/5nZA +FS9GcyCgbMKX/EskFkpuz72EA11WhyqFp/9nWZsxZB0t1cs3bUFeoFd2SE6QIo4N +UY+guZCcF8hVwppceOxyaUQ9cudAClH9JSMR1XoIwvv3X7FJkPhM12DvcsLr062S +fwG7/1h+VoSpAM/UYtBHBkU0MIVn1Gw6lJvkabUV0oYYBzl9ejsq1Qo2nUMdOEQY +SS/VhVLONMyMCIERt3/GJTXdrJ12VqawJACFwv8g2i6NxbQvbZpF7TpS8amduO8+ +aRE6 +-----END CERTIFICATE REQUEST----- diff --git a/apps/iot/priv/ssl/server.key b/apps/iot/priv/ssl/server.key new file mode 100644 index 0000000..ea81719 --- /dev/null +++ b/apps/iot/priv/ssl/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDdxxyYG0zV2Kze +iVH5AGj1X1h7vLAHVS8FGkPL2QBBqW5/PrJ/z/sv3QJz6dB3ElOnk14GsY8lWk8u +X/fjgNIPawN1G2/bCdMrlPOCEJEIOqZvzQWgPLRPGjoZx+wjCM/H7h47KVr4GNbo +8MakLJeg6QB3rEEIiPrhQIoq9N7AhcjbEvWRNIxIQrpSqMCE2A5RoKUxAMum9rEo +e/6PBw2GgEsQl5E6suRZw3wavg3aUU+6MMyxiiKEA6fCD5gDqgSo/xjgDUWJIOTT +rmW6RBTcWP9iBUjWnCovQQ9zc6CzDXRgfsz97uXQt9fuQEi++lcGnrsePjC99Pni +rCvmj/C/AgMBAAECggEAM4j6RwpI/4RbH1cvmjoTKbmfORmumfWceIYS7QKfAaMa +jy0Fk5fD2ep0kHTrwU+b6tvexJVsGxTyQ2d/lfkwVu7aHdNjWbXdwUnakAXDffML +C/3LaeHRUHRavfTsFXQNvHrDwaGphu9WuUiCEFJgZb7fIfAAKLiT+9XghXzjaj79 +nGANBB++kfBGacfj2NMedCxsbZ++pD7DbTG4utnHmzgwB3WEEm2kIbt0XpuDSZqe +/tCA1gCOADjV17C31booT6xdoj+s/Se0YxY91JaZFkI5wTSDIhB9scZzh8piy8YQ +QaEaRUUYNbzdI1MyvgewT6wQLONNlytOOc9hYnPgAQKBgQDyzaublY2q+yzqCQ68 +JSKNTCSFRwumJ1kmdfz/0QwzS363l7LFnfIb55h27q3vuiagPfRbi0GK/tVXhOe+ +8mntNMoJJQJzowpy4ANqKfGndojXejE1s1ExpuGyqWV4MyFGiwAnnzyWRENw4laS +roY0OcynNuq++isgrVMPlDLAvwKBgQDp1OTh2wcSfhsaVbMjNVCOGD82OFK057JB +3C62Li2lCybFwcooypCyZthUdskY2Gf/mDdnpLSLvXr/7bO3IyLopA3WpnYqMBt+ +7sP/VQJMoyobLFMnJh+N+CxwkhRNAib64054WKwiJcccJ5fplUvNjOLBgGx3a/3N +OIxx+L7QAQKBgQC+EK3zPuEFJVYFZk24jkE75oz4H6NIz6iD6PzBrH0mckShpwh0 +la1+lo7NGw3hiRDPg3ATcTE/gziyKAHZgZ3V5+r3uZbvuoNlZWKG6oqWkr2QH8EB +znsSqRYoa15Y931m4Uyft5EP+CPq6+LlM+UuYMiJZP3hvaehDszkdxg7tQKBgGjv +vMPLCpJ2+2zHFvxu+ba7FOsdPain7ix2RpRFhwBxT7Yh8Lp7pZIaa20EXd0DiTCA +PCUMGmY345IlN6ixYQIsVXWGALOQIVbGijj1CnIgK05EhxCjoDeTL0ZZmDizBZFE +HzwM9zrf30o4TolqEbmuRzj1jDfPw/esMAMZ0XABAoGBANpjvUN0Qvy9WmLyPe6U +DBzIAUyyIL3mnCuHLCQ+dE6nBf5l20sVjTdIl/eLlvmD7dwT/D9Zsg32/c2NBCdY +DltKKZl2u9nuSJ1g8DWTZUQHkYSTeKPOoAl/5mbCgyTxzmmmtZF6MySKuif0rzwS +X2Rwou9zTpwgsEEmQ/RH4G94 +-----END PRIVATE KEY----- diff --git a/apps/iot/src/iot_api.erl b/apps/iot/src/adapters/control_api/iot_api_client.erl similarity index 64% rename from apps/iot/src/iot_api.erl rename to apps/iot/src/adapters/control_api/iot_api_client.erl index a986f7a..7e8172a 100644 --- a/apps/iot/src/iot_api.erl +++ b/apps/iot/src/adapters/control_api/iot_api_client.erl @@ -6,8 +6,9 @@ %%% @end %%% Created : 24. 12月 2023 15:42 %%%------------------------------------------------------------------- --module(iot_api). +-module(iot_api_client). -author("anlicheng"). +-include("domain_model.hrl"). %% API -export([ai_event/1]). @@ -31,20 +32,30 @@ get_all_hosts() -> [] end. --spec get_host_by_uuid(UUID :: binary()) -> undefined | {ok, HostInfo :: map()}. +-spec get_host_by_uuid(UUID :: binary()) -> undefined | {ok, HostInfo :: #host_info{}}. get_host_by_uuid(UUID) when is_binary(UUID) -> case do_get("/get_host_by_uuid", [{<<"uuid">>, UUID}]) of {ok, HostInfo} -> - {ok, HostInfo}; + case host_info_record(HostInfo) of + {ok, Record} -> + {ok, Record}; + error -> + undefined + end; _ -> undefined end. --spec get_host_by_id(HostId :: integer()) -> undefined | {ok, HostInfo :: map()}. +-spec get_host_by_id(HostId :: integer()) -> undefined | {ok, HostInfo :: #host_info{}}. get_host_by_id(HostId) when is_integer(HostId) -> case do_get("/get_host_by_id", [{<<"host_id">>, integer_to_binary(HostId)}]) of {ok, HostInfo} -> - {ok, HostInfo}; + case host_info_record(HostInfo) of + {ok, Record} -> + {ok, Record}; + error -> + undefined + end; _ -> undefined end. @@ -54,15 +65,27 @@ get_host_by_id(HostId) when is_integer(HostId) -> change_host_status(UUID, NStatus) when is_binary(UUID), is_integer(NStatus) -> do_post("/change_host_status", #{<<"uuid">> => UUID, <<"new_status">> => NStatus}). --spec get_host_devices(HostId :: integer()) -> {ok, Devices :: [map()]} | {error, Reason::any()}. +-spec get_host_devices(HostId :: integer()) -> {ok, Devices :: [#device_info{}]} | {error, Reason::any()}. get_host_devices(HostId) when is_integer(HostId) -> - do_get("/get_host_devices", [{<<"host_id">>, integer_to_binary(HostId)}]). + case do_get("/get_host_devices", [{<<"host_id">>, integer_to_binary(HostId)}]) of + {ok, DeviceInfos} when is_list(DeviceInfos) -> + device_info_records(DeviceInfos); + {ok, _Other} -> + {error, invalid_device_infos}; + Error -> + Error + end. --spec get_device_by_uuid(DeviceUUID :: binary()) -> {ok, DeviceInfo :: map()} | undefined. +-spec get_device_by_uuid(DeviceUUID :: binary()) -> {ok, DeviceInfo :: #device_info{}} | undefined. get_device_by_uuid(DeviceUUID) when is_binary(DeviceUUID) -> case do_get("/get_device_by_uuid", [{<<"device_uuid">>, DeviceUUID}]) of {ok, DeviceInfo} -> - {ok, DeviceInfo}; + case device_info_record(DeviceInfo) of + {ok, Record} -> + {ok, Record}; + error -> + undefined + end; _ -> undefined end. @@ -106,18 +129,18 @@ ai_event(Id) when is_integer(Id) -> <<"token">> => Token, <<"id">> => Id }, - Body = iolist_to_binary(jiffy:encode(ReqData, [force_utf8])), + Body = iolist_to_binary(json:encode(ReqData)), case hackney:request(post, Url, Headers, Body, [{pool, false}]) of {ok, 200, _, ClientRef} -> {ok, RespBody} = hackney:body(ClientRef), - lager:debug("[iot_api] send body: ~p, get error is: ~p", [Body, RespBody]), + logger:debug("[iot_api_client] send body: ~p, get error is: ~p", [Body, RespBody]), hackney:close(ClientRef); {ok, HttpCode, _, ClientRef} -> {ok, RespBody} = hackney:body(ClientRef), hackney:close(ClientRef), - lager:warning("[iot_api] send body: ~p, get error is: ~p", [Body, {HttpCode, RespBody}]); + logger:warning("[iot_api_client] send body: ~p, get error is: ~p", [Body, {HttpCode, RespBody}]); {error, Reason} -> - lager:warning("[iot_api] send body: ~p, get error is: ~p", [Body, Reason]) + logger:warning("[iot_api_client] send body: ~p, get error is: ~p", [Body, Reason]) end. %%%------------------------------------------------------------------- @@ -132,13 +155,13 @@ do_post(Path, Params) when is_list(Path), is_map(Params) -> {<<"Accept">>, <<"application/json">>} ], Url = BaseUrl ++ Path, - Body = iolist_to_binary(jiffy:encode(Params, [force_utf8])), + Body = iolist_to_binary(json:encode(Params)), case hackney:request(post, Url, Headers, Body, [{pool, false}]) of {ok, 200, _, ClientRef} -> {ok, RespBody} = hackney:body(ClientRef), - lager:debug("[iot_api] request url: ~p, send body: ~p, get response is: ~p", [Url, Body, RespBody]), + logger:debug("[iot_api_client] request url: ~p, send body: ~p, get response is: ~p", [Url, Body, RespBody]), hackney:close(ClientRef), - case catch jiffy:decode(RespBody, [return_maps]) of + case catch json:decode(RespBody) of #{<<"result">> := Result} -> {ok, Result}; #{<<"error">> := #{<<"code">> := Code, <<"message">> := Message}} -> @@ -151,10 +174,10 @@ do_post(Path, Params) when is_list(Path), is_map(Params) -> {ok, HttpCode, _, ClientRef} -> {ok, RespBody} = hackney:body(ClientRef), hackney:close(ClientRef), - lager:warning("[iot_api] request url: ~p, send body: ~p, get error is: ~p", [Url, Body, {HttpCode, RespBody}]), + logger:warning("[iot_api_client] request url: ~p, send body: ~p, get error is: ~p", [Url, Body, {HttpCode, RespBody}]), {error, {HttpCode, RespBody}}; {error, Reason} -> - lager:warning("[iot_api] request url: ~p, send body: ~p, get error is: ~p", [Url, Body, Reason]), + logger:warning("[iot_api_client] request url: ~p, send body: ~p, get error is: ~p", [Url, Body, Reason]), {error, Reason} end. @@ -177,8 +200,8 @@ do_get(Path, Params) when is_list(Path), is_list(Params) -> {ok, 200, _, ClientRef} -> {ok, RespBody} = hackney:body(ClientRef), hackney:close(ClientRef), - lager:debug("[iot_api] url: ~p, get response is: ~p", [Url, RespBody]), - case catch jiffy:decode(RespBody, [return_maps]) of + logger:debug("[iot_api_client] url: ~p, get response is: ~p", [Url, RespBody]), + case catch json:decode(RespBody) of #{<<"result">> := Result} -> {ok, Result}; #{<<"error">> := #{<<"code">> := Code, <<"message">> := Message}} -> @@ -191,9 +214,46 @@ do_get(Path, Params) when is_list(Path), is_list(Params) -> {ok, HttpCode, _, ClientRef} -> {ok, RespBody} = hackney:body(ClientRef), hackney:close(ClientRef), - lager:warning("[iot_api] request url: ~p, get error is: ~p", [Url, {HttpCode, RespBody}]), + logger:warning("[iot_api_client] request url: ~p, get error is: ~p", [Url, {HttpCode, RespBody}]), {error, {HttpCode, RespBody}}; {error, Reason} -> - lager:warning("[iot_api] request url: ~p, get error is: ~p", [Url, Reason]), + logger:warning("[iot_api_client] request url: ~p, get error is: ~p", [Url, Reason]), {error, Reason} - end. \ No newline at end of file + end. + +-spec host_info_record(map()) -> {ok, #host_info{}} | error. +host_info_record(#{<<"id">> := Id, <<"uuid">> := UUID, <<"authorize_status">> := AuthorizeStatus, <<"status">> := Status}) + when is_integer(Id), is_binary(UUID), is_integer(AuthorizeStatus), is_integer(Status) -> + {ok, #host_info{ + id = Id, + uuid = UUID, + authorize_status = AuthorizeStatus, + status = Status + }}; +host_info_record(_) -> + error. + +-spec device_info_record(map()) -> {ok, #device_info{}} | error. +device_info_record(#{<<"id">> := Id, <<"host_id">> := HostId, <<"device_uuid">> := DeviceUUID, <<"status">> := Status}) + when is_integer(Id), is_integer(HostId), is_binary(DeviceUUID), is_integer(Status) -> + {ok, #device_info{ + id = Id, + host_id = HostId, + device_uuid = DeviceUUID, + status = Status + }}; +device_info_record(_) -> + error. + +-spec device_info_records([map()]) -> {ok, [#device_info{}]} | {error, invalid_device_info}. +device_info_records(DeviceInfos) -> + lists:foldr(fun(DeviceInfo, Acc) -> + case {device_info_record(DeviceInfo), Acc} of + {{ok, Record}, {ok, Records}} -> + {ok, [Record | Records]}; + {error, _} -> + {error, invalid_device_info}; + {_, Error = {error, _}} -> + Error + end + end, {ok, []}, DeviceInfos). diff --git a/apps/iot/src/influxdb/influx_client.erl b/apps/iot/src/adapters/influxdb/influx_client.erl similarity index 92% rename from apps/iot/src/influxdb/influx_client.erl rename to apps/iot/src/adapters/influxdb/influx_client.erl index 4b950c1..8a42065 100644 --- a/apps/iot/src/influxdb/influx_client.erl +++ b/apps/iot/src/adapters/influxdb/influx_client.erl @@ -57,7 +57,7 @@ get_precision(Timestamp) when is_integer(Timestamp) -> <<"ms">> end. --spec write_data(Measurement :: binary(), Tags :: map(), FieldsList :: list(), Timestamp :: integer()) -> no_return(). +-spec write_data(Measurement :: binary(), Tags :: map(), FieldsList :: list(), Timestamp :: integer()) -> term(). write_data(Measurement, Tags, FieldsList, Timestamp) when is_binary(Measurement), is_map(Tags), is_list(FieldsList), is_integer(Timestamp) -> %% 过来掉没有key的选项 NFieldsList = lists:filter(fun data_filter/1, FieldsList), @@ -76,12 +76,12 @@ write_data(Measurement, Tags, FieldsList, Timestamp) when is_binary(Measurement) ok end. --spec write(Pid :: pid(), Bucket :: binary(), Org :: binary(), Points :: list()) -> no_return(). +-spec write(Pid :: pid(), Bucket :: binary(), Org :: binary(), Points :: list()) -> ok. write(Pid, Bucket, Org, Points) when is_pid(Pid), is_binary(Bucket), is_binary(Org), is_list(Points) -> write(Pid, Bucket, Org, <<"ms">>, Points). %% Precision的值为: ms|ns|s; 表示时间的精度,默认为毫秒(ms) --spec write(Pid :: pid(), Bucket :: binary(), Org :: binary(), Precision :: binary(), Points :: list()) -> no_return(). +-spec write(Pid :: pid(), Bucket :: binary(), Org :: binary(), Precision :: binary(), Points :: list()) -> ok. write(Pid, Bucket, Org, Precision, Points) when is_pid(Pid), is_binary(Bucket), is_binary(Org), is_binary(Precision), is_list(Points) -> gen_server:cast(Pid, {write, Bucket, Org, Precision, Points}). @@ -101,6 +101,7 @@ start_link(Opts) when is_list(Opts) -> {ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} | {stop, Reason :: term()} | ignore). init([InfluxProps]) -> + ok = iot_log:set_metadata(), Token = proplists:get_value(token, InfluxProps), Host = proplists:get_value(host, InfluxProps), Port = proplists:get_value(port, InfluxProps), @@ -151,18 +152,18 @@ handle_cast({write, Bucket, Org, Precision, Points}, State = #state{host = Host, query => Query }), - lager:debug("[influx_client] url is: ~p, headers: ~p, body: ~ts", [Url, Headers, Body]), + logger:debug("[influx_client] url is: ~p, headers: ~p, body: ~ts", [Url, Headers, Body]), case hackney:request(post, Url, Headers, GZipBody, [{pool, false}]) of {ok, StatusCode, _RespHeaders, ClientRef} -> case hackney:body(ClientRef) of {ok, RespBody} -> - lager:debug("[influx_client] status_code: ~p, response body is: ~p", [StatusCode, RespBody]); + logger:debug("[influx_client] status_code: ~p, response body is: ~p", [StatusCode, RespBody]); {error, Error} -> - lager:warning("[influx_client] status_code: ~p, error is: ~p", [Error]) + logger:warning("[influx_client] status_code: ~p, error is: ~p", [Error]) end, hackney:close(ClientRef); {error, Reason} -> - lager:warning("[influx_client] request result is: ~p", [Reason]) + logger:warning("[influx_client] request result is: ~p", [Reason]) end, {noreply, State}. diff --git a/apps/iot/src/influxdb/influx_point.erl b/apps/iot/src/adapters/influxdb/influx_point.erl similarity index 96% rename from apps/iot/src/influxdb/influx_point.erl rename to apps/iot/src/adapters/influxdb/influx_point.erl index d0828dd..9dcd787 100644 --- a/apps/iot/src/influxdb/influx_point.erl +++ b/apps/iot/src/adapters/influxdb/influx_point.erl @@ -45,7 +45,7 @@ field_val(V) when is_float(V) -> field_val(V) when is_binary(V) -> <<$", V/binary, $">>; field_val(V) when is_list(V); is_map(V) -> - S = base64:encode(iolist_to_binary(jiffy:encode(V, [force_utf8]))), + S = base64:encode(iolist_to_binary(json:encode(V))), <<$", "base64:", S/binary, $">>; field_val(true) -> <<"true">>; @@ -56,4 +56,3 @@ as_list(L) when is_list(L) -> L; as_list(L) when is_map(L) -> maps:to_list(L). - diff --git a/apps/iot/src/redis/redis_client.erl b/apps/iot/src/adapters/redis/redis_client.erl similarity index 100% rename from apps/iot/src/redis/redis_client.erl rename to apps/iot/src/adapters/redis/redis_client.erl diff --git a/apps/iot/src/data_format/line_format.erl b/apps/iot/src/codec/line_format.erl similarity index 100% rename from apps/iot/src/data_format/line_format.erl rename to apps/iot/src/codec/line_format.erl diff --git a/apps/iot/src/mocker/endpoint_kafka_test.erl b/apps/iot/src/devtools/endpoint_kafka_test.erl similarity index 86% rename from apps/iot/src/mocker/endpoint_kafka_test.erl rename to apps/iot/src/devtools/endpoint_kafka_test.erl index 7196ed8..0baf85b 100644 --- a/apps/iot/src/mocker/endpoint_kafka_test.erl +++ b/apps/iot/src/devtools/endpoint_kafka_test.erl @@ -8,12 +8,12 @@ %%%------------------------------------------------------------------- -module(endpoint_kafka_test). -author("anlicheng"). --include("endpoint.hrl"). +-include_lib("endpoint/include/endpoint.hrl"). %% API --export([start_test/0, test_consumer/0]). +-export([start_manual/0, test_consumer/0]). -start_test() -> +start_manual() -> Name = endpoint:get_name(100), {ok, Pid} = endpoint_kafka:start_link(Name, #endpoint{ id = 100, @@ -29,10 +29,10 @@ start_test() -> } }), - Json = jiffy:encode(#{ + Json = iolist_to_binary(json:encode(#{ <<"name">> => <<"anlicheng">>, <<"age">> => 30 - }, [force_utf8]), + })), endpoint:forward(Pid, Json), ok. @@ -49,7 +49,7 @@ test_consumer() -> ok = brod:start_client(KafkaBootstrapEndpoints, client1, ClientConfig), SubscriberCallbackFun = fun(_Partition, Msg, ShellPid = CallbackState) -> - lager:debug("call here msg: ~p", [Msg]), + logger:debug("call here msg: ~p", [Msg]), ShellPid ! Msg, {ok, ack, CallbackState} end, Res = brod_topic_subscriber:start_link(client1, Topic, all, @@ -57,4 +57,4 @@ test_consumer() -> _CommittedOffsets=[], message, SubscriberCallbackFun, _CallbackState=self()), - lager:debug("start subscriber res: ~p", [Res]). \ No newline at end of file + logger:debug("start subscriber res: ~p", [Res]). diff --git a/apps/iot/src/mocker/endpoint_mqtt_subscriber.erl b/apps/iot/src/devtools/endpoint_mqtt_subscriber.erl similarity index 83% rename from apps/iot/src/mocker/endpoint_mqtt_subscriber.erl rename to apps/iot/src/devtools/endpoint_mqtt_subscriber.erl index 2bce1ab..006f30f 100644 --- a/apps/iot/src/mocker/endpoint_mqtt_subscriber.erl +++ b/apps/iot/src/devtools/endpoint_mqtt_subscriber.erl @@ -67,23 +67,23 @@ init([]) -> {retry_interval, 5} ], - lager:debug("[opts] is: ~p", [Opts]), + logger:debug("[opts] is: ~p", [Opts]), case emqtt:start_link(Opts) of {ok, ConnPid} -> %% 监听和host相关的全部事件 - lager:debug("[iot_mqtt_subscriber] start conntecting, pid: ~p", [ConnPid]), + logger:debug("[iot_mqtt_subscriber] start conntecting, pid: ~p", [ConnPid]), {ok, _} = emqtt:connect(ConnPid), - lager:debug("[iot_mqtt_subscriber] connect success, pid: ~p", [ConnPid]), + logger:debug("[iot_mqtt_subscriber] connect success, pid: ~p", [ConnPid]), SubscribeResult = emqtt:subscribe(ConnPid, ?Topics), - lager:debug("[iot_mqtt_subscriber] subscribe topics: ~p, result is: ~p", [?Topics, SubscribeResult]), + logger:debug("[iot_mqtt_subscriber] subscribe topics: ~p, result is: ~p", [?Topics, SubscribeResult]), {ok, #state{conn_pid = ConnPid}}; ignore -> - lager:debug("[iot_mqtt_subscriber] connect emqx get ignore"), + logger:debug("[iot_mqtt_subscriber] connect emqx get ignore"), {stop, ignore}; {error, Reason} -> - lager:debug("[iot_mqtt_subscriber] connect emqx get error: ~p", [Reason]), + logger:debug("[iot_mqtt_subscriber] connect emqx get error: ~p", [Reason]), {stop, Reason} end. @@ -116,19 +116,19 @@ handle_cast(_Request, State = #state{}) -> {noreply, NewState :: #state{}, timeout() | hibernate} | {stop, Reason :: term(), NewState :: #state{}}). handle_info({disconnect, ReasonCode, Properties}, State = #state{}) -> - lager:debug("[iot_mqtt_subscriber] Recv a DISONNECT packet - ReasonCode: ~p, Properties: ~p", [ReasonCode, Properties]), + logger:debug("[iot_mqtt_subscriber] Recv a DISONNECT packet - ReasonCode: ~p, Properties: ~p", [ReasonCode, Properties]), {stop, disconnected, State}; %% 必须要做到消息的快速分发,数据的json反序列需要在host进程进行 handle_info({publish, #{packet_id := _PacketId, payload := Payload, qos := Qos, topic := Topic}}, State = #state{conn_pid = _ConnPid}) -> - lager:debug("[iot_mqtt_subscriber] Recv a topic: ~p, publish packet: ~p, qos: ~p", [Topic, Payload, Qos]), + logger:debug("[iot_mqtt_subscriber] Recv a topic: ~p, publish packet: ~p, qos: ~p", [Topic, Payload, Qos]), %% 将消息分发到对应的host进程去处理 {noreply, State}; handle_info({puback, Packet = #{packet_id := _PacketId}}, State = #state{}) -> - lager:debug("[iot_mqtt_subscriber] receive puback packet: ~p", [Packet]), + logger:debug("[iot_mqtt_subscriber] receive puback packet: ~p", [Packet]), {noreply, State}; handle_info(Info, State = #state{}) -> - lager:debug("[iot_mqtt_subscriber] get info: ~p", [Info]), + logger:debug("[iot_mqtt_subscriber] get info: ~p", [Info]), {noreply, State}. %% @private @@ -144,10 +144,10 @@ terminate(Reason, _State = #state{conn_pid = ConnPid}) when is_pid(ConnPid) -> {ok, _Props, _ReasonCode} = emqtt:unsubscribe(ConnPid, #{}, TopicNames), ok = emqtt:disconnect(ConnPid), - lager:debug("[iot_mqtt_subscriber] terminate with reason: ~p", [Reason]), + logger:debug("[iot_mqtt_subscriber] terminate with reason: ~p", [Reason]), ok; terminate(Reason, _State) -> - lager:debug("[iot_mqtt_subscriber] terminate with reason: ~p", [Reason]), + logger:debug("[iot_mqtt_subscriber] terminate with reason: ~p", [Reason]), ok. %% @private diff --git a/apps/iot/src/mocker/eval_test.erl b/apps/iot/src/devtools/eval_test.erl similarity index 55% rename from apps/iot/src/mocker/eval_test.erl rename to apps/iot/src/devtools/eval_test.erl index 0268655..d2ea8ce 100644 --- a/apps/iot/src/mocker/eval_test.erl +++ b/apps/iot/src/devtools/eval_test.erl @@ -13,13 +13,5 @@ -export([test/0]). test() -> - - {ok, Content} = file:read_file("/tmp/test.erl"), - - {ok, Tokens, _} = erl_scan:string(binary_to_list(Content)), - {ok, ExprList} = erl_parse:parse_exprs(Tokens), - - {value, F, _NewBindings} = erl_eval:exprs(ExprList, []), - F(#{name => <<"test">>}). - + {error, disabled}. diff --git a/apps/iot/src/iot_logger.erl b/apps/iot/src/devtools/iot_logger.erl similarity index 100% rename from apps/iot/src/iot_logger.erl rename to apps/iot/src/devtools/iot_logger.erl diff --git a/apps/iot/src/mocker/iot_mock.erl b/apps/iot/src/devtools/iot_mock.erl similarity index 60% rename from apps/iot/src/mocker/iot_mock.erl rename to apps/iot/src/devtools/iot_mock.erl index 1968930..cce8fa2 100644 --- a/apps/iot/src/mocker/iot_mock.erl +++ b/apps/iot/src/devtools/iot_mock.erl @@ -12,7 +12,6 @@ %% API -export([rsa_encode/1]). --export([insert_services/1]). -export([test_influxdb/0]). test_influxdb() -> @@ -29,34 +28,20 @@ test_influxdb() -> end) end, lists:seq(1, 100)). -insert_services(Num) -> - lists:foreach(fun(Id) -> - Res = mysql_pool:insert(mysql_iot, <<"micro_service">>, - #{ - <<"name">> => <<"微服务"/utf8, (integer_to_binary(Id))/binary>>, - <<"code">> => <<"1223423423423423"/utf8>>, - <<"type">> => 1, - <<"version">> => <<"v1.0">>, - <<"url">> => <<"https://www.baidu.com">>, - <<"detail">> => <<"这是一个关于测试的微服务"/utf8>> - }, false), - lager:debug("insert service result is: ~p", [Res]) - end, lists:seq(1, Num)). - rsa_encode(Data) when is_binary(Data) -> %% 读取相关配置 PublicPemFile = "/tmp/keys/public.pem", %% 私钥保存解析后的 {ok, PubBin} = file:read_file(PublicPemFile), - lager:debug("pub bin is: ~p", [PubBin]), + logger:debug("pub bin is: ~p", [PubBin]), [Pub] = public_key:pem_decode(PubBin), - lager:debug("pub pem bin is: ~p", [Pub]), + logger:debug("pub pem bin is: ~p", [Pub]), PubKey = public_key:pem_entry_decode(Pub), - lager:debug("the public key is: ~p", [PubKey]), + logger:debug("the public key is: ~p", [PubKey]), EncData = public_key:encrypt_public(Data, PubKey), - lager:debug("enc data is: ~p", [EncData]), + logger:debug("enc data is: ~p", [EncData]), rsa_decode(EncData), @@ -68,13 +53,13 @@ rsa_decode(EncData) when is_binary(EncData) -> %% 私钥保存解析后的 {ok, PubBin} = file:read_file(PublicPemFile), - lager:debug("pub bin is: ~p", [PubBin]), + logger:debug("pub bin is: ~p", [PubBin]), [Pub] = public_key:pem_decode(PubBin), - lager:debug("pub pem bin is: ~p", [Pub]), + logger:debug("pub pem bin is: ~p", [Pub]), PubKey = public_key:pem_entry_decode(Pub), - lager:debug("the public key is: ~p", [PubKey]), + logger:debug("the public key is: ~p", [PubKey]), PlainData = public_key:decrypt_private(EncData, PubKey), - lager:debug("plain data is: ~p", [PlainData]), + logger:debug("plain data is: ~p", [PlainData]), - ok. \ No newline at end of file + ok. diff --git a/apps/iot/src/iot_observer.erl b/apps/iot/src/devtools/iot_observer.erl similarity index 100% rename from apps/iot/src/iot_observer.erl rename to apps/iot/src/devtools/iot_observer.erl diff --git a/apps/iot/src/docker/docker_container_builder.erl b/apps/iot/src/docker/docker_container_builder.erl new file mode 100644 index 0000000..49689ee --- /dev/null +++ b/apps/iot/src/docker/docker_container_builder.erl @@ -0,0 +1,588 @@ +%%%------------------------------------------------------------------- +%%% @author +%%% @copyright (C) 2026, +%%% @doc +%%% ContainerRequest wire builder helpers. +%%% @end +%%%------------------------------------------------------------------- +-module(docker_container_builder). + +-export([list_request/0, config_request/2, deploy_request/2, start_request/1, stop_request/1, kill_request/1, remove_request/1]). + +-spec list_request() -> map(). +list_request() -> + #{action => list, all => true}. + +-spec config_request(binary(), binary()) -> map(). +config_request(ContainerName, ConfigJson) when is_binary(ContainerName), is_binary(ConfigJson) -> + #{ + action => config, + target => container_ref(ContainerName), + config => ConfigJson + }. + +-spec deploy_request(integer(), map()) -> {ok, map()} | {error, binary()}. +deploy_request(TaskId, Config) when is_integer(TaskId), is_map(Config), TaskId >= 0 -> + try + ensure_supported_deploy_config(Config), + validate_deploy_config(Config), + Params = build_container_deploy_params(Config), + {ok, #{action => deploy, task_id => TaskId, params => Params}} + catch + throw:{error, Reason} -> + {error, Reason} + end; +deploy_request(TaskId, _Config) when is_integer(TaskId), TaskId < 0 -> + {error, <<"task_id must be non-negative">>}; +deploy_request(_TaskId, _Config) -> + {error, <<"invalid deploy request">>}. + +-spec start_request(binary()) -> map(). +start_request(ContainerName) when is_binary(ContainerName) -> + #{action => start, target => container_ref(ContainerName)}. + +-spec stop_request(binary()) -> map(). +stop_request(ContainerName) when is_binary(ContainerName) -> + #{ + action => stop, + target => container_ref(ContainerName), + timeout_seconds => 0 + }. + +-spec kill_request(binary()) -> map(). +kill_request(ContainerName) when is_binary(ContainerName) -> + #{ + action => kill, + target => container_ref(ContainerName), + signal => <<>> + }. + +-spec remove_request(binary()) -> map(). +remove_request(ContainerName) when is_binary(ContainerName) -> + #{ + action => remove, + target => container_ref(ContainerName), + force => false, + remove_volumes => false + }. + +-spec container_ref(binary()) -> map(). +container_ref(ContainerName) when is_binary(ContainerName) -> + #{name => ContainerName, id => <<>>}. + +-spec ensure_supported_deploy_config(map()) -> ok. +ensure_supported_deploy_config(Config) when is_map(Config) -> + UnsupportedKeys = [Key || Key <- [<<"container_dir">>], maps:is_key(Key, Config)], + case UnsupportedKeys of + [] -> + ok; + _ -> + Unsupported = iolist_to_binary(lists:join(<<", ">>, UnsupportedKeys)), + throw({error, <<"unsupported container config keys: ", Unsupported/binary>>}) + end. + +-spec validate_deploy_config(map()) -> ok. +validate_deploy_config(Config) when is_map(Config) -> + Required = [ + {<<"image">>, binary}, + {<<"container_name">>, binary}, + {<<"command">>, {list, binary}}, + {<<"restart">>, binary} + ], + Optional = [ + {<<"privileged">>, boolean}, + {<<"entrypoint">>, {list, binary}}, + {<<"envs">>, {list, binary}}, + {<<"ports">>, {list, binary}}, + {<<"expose">>, {list, binary}}, + {<<"volumes">>, {list, binary}}, + {<<"networks">>, {list, binary}}, + {<<"labels">>, {map, {binary, binary}}}, + {<<"user">>, binary}, + {<<"working_dir">>, binary}, + {<<"hostname">>, binary}, + {<<"network_mode">>, binary}, + {<<"cap_add">>, {list, binary}}, + {<<"cap_drop">>, {list, binary}}, + {<<"devices">>, {list, binary}}, + {<<"mem_limit">>, binary}, + {<<"mem_reservation">>, binary}, + {<<"cpu_shares">>, non_neg_integer}, + {<<"cpus">>, non_neg_number}, + {<<"ulimits">>, {map, {binary, binary}}}, + {<<"sysctls">>, {map, {binary, binary}}}, + {<<"tmpfs">>, {list, binary}}, + {<<"extra_hosts">>, {list, binary}}, + {<<"healthcheck">>, map} + ], + Errors = check_required(Config, Required) ++ check_optional(Config, Optional) ++ check_healthcheck(Config), + case Errors of + [] -> + ok; + _ -> + throw({error, iolist_to_binary(lists:join(<<"|||">>, Errors))}) + end. + +-spec check_required(map(), list()) -> [binary()]. +check_required(Config, Fields) -> + lists:foldl( + fun({Key, Type}, ErrAcc) -> + case maps:get(Key, Config, undefined) of + undefined -> + [iolist_to_binary(io_lib:format("miss requied parameter: ~p", [Key])) | ErrAcc]; + Value -> + case check_type(Value, Type) of + true -> + ErrAcc; + false -> + [iolist_to_binary(io_lib:format("required parameter: ~p, type must be: ~ts", [Key, type_name(Type)])) | ErrAcc] + end + end + end, + [], Fields). + +-spec check_optional(map(), list()) -> [binary()]. +check_optional(Config, Fields) -> + lists:foldl( + fun({Key, Type}, ErrAcc) -> + case maps:get(Key, Config, undefined) of + undefined -> + ErrAcc; + Value -> + case check_type(Value, Type) of + true -> + ErrAcc; + false -> + [iolist_to_binary(io_lib:format("optional parameter: ~p, type must be: ~ts", [Key, type_name(Type)])) | ErrAcc] + end + end + end, + [], Fields). + +-spec type_name(tuple() | atom()) -> binary(). +type_name(binary) -> + <<"string">>; +type_name(integer) -> + <<"integer">>; +type_name(non_neg_integer) -> + <<"non-negative integer">>; +type_name(number) -> + <<"number">>; +type_name(non_neg_number) -> + <<"non-negative number">>; +type_name(duration) -> + <<"duration string or non-negative integer">>; +type_name(list) -> + <<"list">>; +type_name({list, binary}) -> + <<"list of string">>; +type_name({list, number}) -> + <<"list of number">>; +type_name({list, integer}) -> + <<"list of integer">>; +type_name(map) -> + <<"map">>; +type_name({map, {binary, binary}}) -> + <<"map of string:string">>; +type_name({map, {binary, any}}) -> + <<"map of string:any">>; +type_name(boolean) -> + <<"boolean">>. + +-spec check_type(any(), any()) -> boolean(). +check_type(Value, binary) -> + is_binary(Value); +check_type(Value, integer) -> + is_integer(Value); +check_type(Value, non_neg_integer) -> + is_integer(Value) andalso Value >= 0; +check_type(Value, number) -> + is_number(Value); +check_type(Value, non_neg_number) -> + is_number(Value) andalso Value >= 0; +check_type(Value, duration) -> + is_binary(Value) orelse (is_integer(Value) andalso Value >= 0); +check_type(Value, list) when is_list(Value) -> + true; +check_type(Value, {list, binary}) when is_list(Value) -> + lists:all(fun(E) -> is_binary(E) end, Value); +check_type(Value, {list, number}) when is_list(Value) -> + lists:all(fun(E) -> is_number(E) end, Value); +check_type(Value, {list, integer}) when is_list(Value) -> + lists:all(fun(E) -> is_integer(E) end, Value); +check_type(Value, map) when is_map(Value) -> + true; +check_type(Value, {map, {binary, binary}}) when is_map(Value) -> + lists:all(fun({K, V}) -> is_binary(K) andalso is_binary(V) end, maps:to_list(Value)); +check_type(Value, {map, {binary, any}}) when is_map(Value) -> + lists:all(fun({K, _}) -> is_binary(K) end, maps:to_list(Value)); +check_type(Value, boolean) -> + is_boolean(Value); +check_type(_, _) -> + false. + +-spec check_healthcheck(map()) -> [binary()]. +check_healthcheck(Config) -> + case maps:get(<<"healthcheck">>, Config, undefined) of + undefined -> + []; + Healthcheck when is_map(Healthcheck) -> + Fields = [ + {<<"test">>, {list, binary}}, + {<<"interval">>, duration}, + {<<"timeout">>, duration}, + {<<"retries">>, non_neg_integer} + ], + KeyErrors = [<<"optional parameter: <<\"healthcheck\">>, type must be: map of string:any">> || + {Key, _Value} <- maps:to_list(Healthcheck), not is_binary(Key)], + KeyErrors ++ check_optional(Healthcheck, Fields); + _ -> + [] + end. + +-spec build_container_deploy_params(map()) -> map(). +build_container_deploy_params(Config) when is_map(Config) -> + #{ + container_name => maps:get(<<"container_name">>, Config), + create => build_docker_create_options(Config) + }. + +-spec build_docker_create_options(map()) -> map(). +build_docker_create_options(Config) when is_map(Config) -> + #{ + config => build_docker_container_config(Config), + host_config => build_docker_host_config(Config), + networking_config => build_docker_networking_config(Config) + }. + +-spec build_docker_container_config(map()) -> map(). +build_docker_container_config(Config) when is_map(Config) -> + #{ + image => maps:get(<<"image">>, Config), + cmd => maps:get(<<"command">>, Config), + entrypoint => maps:get(<<"entrypoint">>, Config, []), + env => maps:get(<<"envs">>, Config, []), + labels => maps:get(<<"labels">>, Config, #{}), + volumes => build_container_volumes(maps:get(<<"volumes">>, Config, [])), + user => maps:get(<<"user">>, Config, <<>>), + working_dir => maps:get(<<"working_dir">>, Config, <<>>), + hostname => maps:get(<<"hostname">>, Config, <<>>), + exposed_ports => build_exposed_ports(Config), + healthcheck => build_healthcheck(maps:get(<<"healthcheck">>, Config, undefined)) + }. + +-spec build_docker_host_config(map()) -> map(). +build_docker_host_config(Config) when is_map(Config) -> + #{ + binds => build_host_binds(maps:get(<<"volumes">>, Config, [])), + network_mode => maps:get(<<"network_mode">>, Config, <<>>), + restart_policy => build_restart_policy(maps:get(<<"restart">>, Config)), + privileged => maps:get(<<"privileged">>, Config, false), + cap_add => maps:get(<<"cap_add">>, Config, []), + cap_drop => maps:get(<<"cap_drop">>, Config, []), + devices => build_device_mappings(maps:get(<<"devices">>, Config, [])), + memory => default_uint64(parse_optional_size_bytes(maps:get(<<"mem_limit">>, Config, undefined), <<"mem_limit">>)), + memory_reservation => default_uint64(parse_optional_size_bytes(maps:get(<<"mem_reservation">>, Config, undefined), <<"mem_reservation">>)), + nano_cpus => default_uint64(parse_optional_nano_cpus(maps:get(<<"cpus">>, Config, undefined))), + cpu_shares => default_uint64(maps:get(<<"cpu_shares">>, Config, undefined)), + port_bindings => build_port_bindings(maps:get(<<"ports">>, Config, [])), + ulimits => build_ulimits(maps:get(<<"ulimits">>, Config, #{})), + tmpfs => maps:from_list(build_tmpfs_options(maps:get(<<"tmpfs">>, Config, []))), + sysctls => maps:get(<<"sysctls">>, Config, #{}), + extra_hosts => maps:get(<<"extra_hosts">>, Config, []) + }. + +-spec build_docker_networking_config(map()) -> map(). +build_docker_networking_config(Config) when is_map(Config) -> + Networks = maps:get(<<"networks">>, Config, []), + #{endpoints => [#{name => Network} || Network <- Networks]}. + +-spec build_restart_policy(binary()) -> map(). +build_restart_policy(Restart0) when is_binary(Restart0) -> + case binary:split(Restart0, <<":">>) of + [Name, RetryCountBin] -> + #{name => Name, maximum_retry_count => parse_uint32(RetryCountBin, <<"restart">>)}; + [Name] -> + #{name => Name, maximum_retry_count => 0} + end. + +-spec build_healthcheck(undefined | map()) -> undefined | map(). +build_healthcheck(undefined) -> + undefined; +build_healthcheck(Healthcheck) when is_map(Healthcheck) -> + #{ + test => maps:get(<<"test">>, Healthcheck, []), + interval_ns => parse_duration_ns(maps:get(<<"interval">>, Healthcheck, <<"0s">>), <<"healthcheck.interval">>), + timeout_ns => parse_duration_ns(maps:get(<<"timeout">>, Healthcheck, <<"0s">>), <<"healthcheck.timeout">>), + retries => maps:get(<<"retries">>, Healthcheck, 0) + }. + +-spec default_uint64(undefined | non_neg_integer()) -> non_neg_integer(). +default_uint64(undefined) -> + 0; +default_uint64(Value) when is_integer(Value), Value >= 0 -> + Value. + +-spec parse_optional_nano_cpus(undefined | number()) -> undefined | non_neg_integer(). +parse_optional_nano_cpus(undefined) -> + undefined; +parse_optional_nano_cpus(Cpus) when is_integer(Cpus), Cpus >= 0 -> + Cpus * 1000000000; +parse_optional_nano_cpus(Cpus) when is_float(Cpus), Cpus >= 0 -> + trunc(Cpus * 1000000000). + +-spec build_container_volumes([binary()]) -> [binary()]. +build_container_volumes(VolumeSpecs) when is_list(VolumeSpecs) -> + [ContainerPath || VolumeSpec <- VolumeSpecs, {_HostPath, ContainerPath, _ReadOnly} <- [parse_volume_spec(VolumeSpec)]]. + +-spec build_host_binds([binary()]) -> [binary()]. +build_host_binds(VolumeSpecs) when is_list(VolumeSpecs) -> + [volume_bind(HostPath, ContainerPath, ReadOnly) || + VolumeSpec <- VolumeSpecs, + {HostPath, ContainerPath, ReadOnly} <- [parse_volume_spec(VolumeSpec)]]. + +-spec parse_volume_spec(binary()) -> {binary(), binary(), boolean()}. +parse_volume_spec(VolumeSpec) when is_binary(VolumeSpec) -> + case binary:split(VolumeSpec, <<":">>, [global]) of + [HostPath, ContainerPath] when HostPath =/= <<>>, ContainerPath =/= <<>> -> + {HostPath, ContainerPath, false}; + [HostPath, ContainerPath | Modes] when HostPath =/= <<>>, ContainerPath =/= <<>> -> + {HostPath, ContainerPath, lists:member(<<"ro">>, Modes)}; + _ -> + throw({error, <<"invalid volume binding">>}) + end. + +-spec volume_bind(binary(), binary(), boolean()) -> binary(). +volume_bind(HostPath, ContainerPath, true) when is_binary(HostPath), is_binary(ContainerPath) -> + <>; +volume_bind(HostPath, ContainerPath, false) when is_binary(HostPath), is_binary(ContainerPath) -> + <>. + +-spec build_exposed_ports(map()) -> [map()]. +build_exposed_ports(Config) when is_map(Config) -> + ExposePorts = [build_exposed_port(ExposeSpec) || ExposeSpec <- maps:get(<<"expose">>, Config, [])], + BoundPorts = [ + #{container_port => ContainerPort, protocol => Protocol} || + #{container_port := ContainerPort, protocol := Protocol} <- build_port_bindings(maps:get(<<"ports">>, Config, [])) + ], + unique_ports(ExposePorts ++ BoundPorts). + +-spec build_exposed_port(binary()) -> map(). +build_exposed_port(ExposeSpec) when is_binary(ExposeSpec) -> + case binary:split(ExposeSpec, <<"/">>) of + [PortBin] -> + #{container_port => parse_uint32(PortBin, <<"expose">>), protocol => <<"tcp">>}; + [PortBin, Protocol] -> + #{container_port => parse_uint32(PortBin, <<"expose">>), protocol => Protocol} + end. + +-spec build_port_bindings([binary()]) -> [map()]. +build_port_bindings(PortSpecs) when is_list(PortSpecs) -> + [build_port_binding(PortSpec) || PortSpec <- PortSpecs]. + +-spec build_port_binding(binary()) -> map(). +build_port_binding(PortSpec) when is_binary(PortSpec) -> + case binary:split(PortSpec, <<":">>, [global]) of + [HostPortBin, ContainerPortSpec] when HostPortBin =/= <<>>, ContainerPortSpec =/= <<>> -> + {ContainerPort, Protocol} = parse_container_port_spec(ContainerPortSpec, <<"ports">>), + #{ + host_ip => <<>>, + host_port => parse_tcp_port(HostPortBin, <<"ports.host_port">>), + container_port => ContainerPort, + protocol => Protocol + }; + _ -> + throw({error, <<"invalid port binding">>}) + end. + +-spec parse_container_port_spec(binary(), binary()) -> {non_neg_integer(), binary()}. +parse_container_port_spec(PortSpec, Field) when is_binary(PortSpec), is_binary(Field) -> + case binary:split(PortSpec, <<"/">>) of + [PortBin] -> + {parse_tcp_port(PortBin, Field), <<"tcp">>}; + [PortBin, Protocol] when Protocol =/= <<>> -> + {parse_tcp_port(PortBin, Field), Protocol}; + _ -> + throw({error, <<"invalid port binding">>}) + end. + +-spec unique_ports([map()]) -> [map()]. +unique_ports(Ports) when is_list(Ports) -> + {_Seen, Result} = lists:foldl( + fun(Port = #{container_port := ContainerPort, protocol := Protocol}, {Seen, Acc}) -> + Key = {ContainerPort, Protocol}, + case maps:is_key(Key, Seen) of + true -> + {Seen, Acc}; + false -> + {Seen#{Key => true}, [Port | Acc]} + end + end, + {#{}, []}, + Ports), + lists:reverse(Result). + +-spec build_device_mappings([binary()]) -> [map()]. +build_device_mappings(DeviceSpecs) when is_list(DeviceSpecs) -> + [build_device_mapping(DeviceSpec) || DeviceSpec <- DeviceSpecs]. + +-spec build_device_mapping(binary()) -> map(). +build_device_mapping(DeviceSpec) when is_binary(DeviceSpec) -> + case binary:split(DeviceSpec, <<":">>, [global]) of + [HostPath, ContainerPath] when HostPath =/= <<>>, ContainerPath =/= <<>> -> + #{path_on_host => HostPath, path_in_container => ContainerPath, cgroup_permissions => <<"rwm">>}; + [HostPath, ContainerPath, Permissions] when HostPath =/= <<>>, ContainerPath =/= <<>>, Permissions =/= <<>> -> + #{path_on_host => HostPath, path_in_container => ContainerPath, cgroup_permissions => Permissions}; + _ -> + throw({error, <<"invalid device mapping">>}) + end. + +-spec build_ulimits(map()) -> [map()]. +build_ulimits(Ulimits) when is_map(Ulimits) -> + [build_ulimit(Name, Value) || {Name, Value} <- maps:to_list(Ulimits)]. + +-spec build_ulimit(binary(), binary()) -> map(). +build_ulimit(Name, Value) when is_binary(Name), is_binary(Value) -> + case binary:split(Value, <<":">>) of + [SoftBin, HardBin] -> + #{name => Name, soft => parse_uint64(SoftBin, <<"ulimits.soft">>), hard => parse_uint64(HardBin, <<"ulimits.hard">>)}; + [LimitBin] -> + Limit = parse_uint64(LimitBin, <<"ulimits.limit">>), + #{name => Name, soft => Limit, hard => Limit} + end. + +-spec build_tmpfs_options([binary()]) -> [{binary(), binary()}]. +build_tmpfs_options(TmpfsSpecs) when is_list(TmpfsSpecs) -> + [build_tmpfs_option(TmpfsSpec) || TmpfsSpec <- TmpfsSpecs]. + +-spec build_tmpfs_option(binary()) -> {binary(), binary()}. +build_tmpfs_option(TmpfsSpec) when is_binary(TmpfsSpec) -> + case binary:split(TmpfsSpec, <<":">>) of + [Path] when Path =/= <<>> -> + {Path, <<>>}; + [Path, Options] when Path =/= <<>> -> + {Path, Options}; + _ -> + throw({error, <<"invalid tmpfs mount">>}) + end. + +-spec parse_optional_size_bytes(undefined | binary(), binary()) -> undefined | non_neg_integer(). +parse_optional_size_bytes(undefined, _Field) -> + undefined; +parse_optional_size_bytes(Value, Field) when is_binary(Value) -> + parse_size_bytes(Value, Field). + +-spec parse_duration_ns(binary() | integer(), binary()) -> non_neg_integer(). +parse_duration_ns(Value, _Field) when is_integer(Value), Value >= 0 -> + Value; +parse_duration_ns(Value, Field) when is_binary(Value) -> + parse_scaled_uint64(Value, Field, #{ + <<"ns">> => 1, + <<"us">> => 1000, + <<"ms">> => 1000000, + <<"s">> => 1000000000, + <<"m">> => 60000000000, + <<"h">> => 3600000000000, + <<>> => 1000000000 + }). + +-spec parse_size_bytes(binary(), binary()) -> non_neg_integer(). +parse_size_bytes(Value, Field) when is_binary(Value) -> + parse_scaled_uint64(Value, Field, #{ + <<"b">> => 1, + <<"k">> => 1024, + <<"kb">> => 1024, + <<"ki">> => 1024, + <<"kib">> => 1024, + <<"m">> => 1048576, + <<"mb">> => 1048576, + <<"mi">> => 1048576, + <<"mib">> => 1048576, + <<"g">> => 1073741824, + <<"gb">> => 1073741824, + <<"gi">> => 1073741824, + <<"gib">> => 1073741824, + <<"t">> => 1099511627776, + <<"tb">> => 1099511627776, + <<"ti">> => 1099511627776, + <<"tib">> => 1099511627776, + <<>> => 1 + }). + +-spec parse_scaled_uint64(binary(), binary(), map()) -> non_neg_integer(). +parse_scaled_uint64(Value0, Field, Multipliers) when is_binary(Value0), is_binary(Field), is_map(Multipliers) -> + Value = trim_binary(Value0), + LowerValue = lower_binary(Value), + {NumberBin, Unit} = split_numeric_suffix(LowerValue), + case maps:get(Unit, Multipliers, undefined) of + undefined -> + throw({error, <<"invalid value for ", Field/binary, ": ", Value0/binary>>}); + Multiplier -> + trunc(parse_decimal(NumberBin, Field) * Multiplier) + end. + +-spec parse_uint32(binary(), binary()) -> non_neg_integer(). +parse_uint32(Value, Field) when is_binary(Value), is_binary(Field) -> + Parsed = parse_uint64(Value, Field), + case Parsed =< 16#FFFFFFFF of + true -> + Parsed; + false -> + throw({error, <<"value overflow for ", Field/binary>>}) + end. + +-spec parse_tcp_port(binary(), binary()) -> non_neg_integer(). +parse_tcp_port(Value, Field) when is_binary(Value), is_binary(Field) -> + Parsed = parse_uint32(Value, Field), + case Parsed =< 65535 of + true -> + Parsed; + false -> + throw({error, <<"port out of range for ", Field/binary>>}) + end. + +-spec parse_uint64(binary(), binary()) -> non_neg_integer(). +parse_uint64(Value0, Field) when is_binary(Value0), is_binary(Field) -> + Value = trim_binary(Value0), + case catch binary_to_integer(Value) of + Parsed when is_integer(Parsed), Parsed >= 0 -> + Parsed; + _ -> + throw({error, <<"invalid unsigned integer for ", Field/binary, ": ", Value0/binary>>}) + end. + +-spec parse_decimal(binary(), binary()) -> float(). +parse_decimal(Value, Field) when is_binary(Value), is_binary(Field) -> + case catch binary_to_integer(Value) of + ParsedInt when is_integer(ParsedInt), ParsedInt >= 0 -> + float(ParsedInt); + _ -> + case catch binary_to_float(Value) of + ParsedFloat when is_float(ParsedFloat), ParsedFloat >= 0 -> + ParsedFloat; + _ -> + throw({error, <<"invalid number for ", Field/binary, ": ", Value/binary>>}) + end + end. + +-spec split_numeric_suffix(binary()) -> {binary(), binary()}. +split_numeric_suffix(Value) when is_binary(Value) -> + split_numeric_suffix(Value, <<>>). + +-spec split_numeric_suffix(binary(), binary()) -> {binary(), binary()}. +split_numeric_suffix(<>, Acc) + when (Char >= $0 andalso Char =< $9) orelse Char =:= $. -> + split_numeric_suffix(Rest, <>); +split_numeric_suffix(Rest, <<>>) -> + throw({error, <<"invalid numeric value: ", Rest/binary>>}); +split_numeric_suffix(Rest, Acc) -> + {Acc, Rest}. + +-spec trim_binary(binary()) -> binary(). +trim_binary(Value) when is_binary(Value) -> + Trimmed = string:trim(binary_to_list(Value)), + list_to_binary(Trimmed). + +-spec lower_binary(binary()) -> binary(). +lower_binary(Value) when is_binary(Value) -> + list_to_binary(string:lowercase(binary_to_list(Value))). diff --git a/apps/iot/src/endpoint/endpoint_buffer.erl b/apps/iot/src/endpoint/endpoint_buffer.erl deleted file mode 100644 index 4368a53..0000000 --- a/apps/iot/src/endpoint/endpoint_buffer.erl +++ /dev/null @@ -1,105 +0,0 @@ - -%%%------------------------------------------------------------------- -%%% @author aresei -%%% @copyright (C) 2023, -%%% @doc -%%% -%%% @end -%%% Created : 06. 7月 2023 12:02 -%%%------------------------------------------------------------------- --module(endpoint_buffer). - --include("endpoint.hrl"). - -%% 消息重发间隔 --define(RETRY_INTERVAL, 5000). - --export([new/2, append/2, trigger_next/1, trigger_n/1, cleanup/1, ack/2, stat/1]). --export_type([buffer/0]). - --record(buffer, { - endpoint :: #endpoint{}, - next_id = 1 :: integer(), - %% 当前数据所在的游标 - cursor = 0 :: integer(), - %% ets存储的引用 - tid :: ets:tid(), - %% 定时器 - timer_pid :: pid(), - %% 窗口大小,允许最大的未确认消息数 - window_size = 10, - %% 未确认的消息数 - flight_num = 0, - %% 记录成功处理的消息数 - acc_num = 0 -}). - --record(north_data, { - id :: integer(), - tuple :: any() -}). - --type buffer() :: #buffer{}. - --spec new(Endpoint :: #endpoint{}, WindowSize :: integer()) -> Buffer :: #buffer{}. -new(Endpoint = #endpoint{id = Id}, WindowSize) when is_integer(WindowSize), WindowSize > 0 -> - %% 初始化存储 - EtsName = list_to_atom("endpoint_buffer_ets:" ++ integer_to_list(Id)), - Tid = ets:new(EtsName, [ordered_set, private, {keypos, 2}]), - %% 定义重发器 - {ok, TimerPid} = endpoint_timer:start_link(?RETRY_INTERVAL), - - #buffer{cursor = 0, tid = Tid, timer_pid = TimerPid, endpoint = Endpoint, window_size = WindowSize}. - --spec append(tuple(), Buffer :: #buffer{}) -> NBuffer :: #buffer{}. -append(Tuple, Buffer = #buffer{tid = Tid, next_id = NextId, window_size = WindowSize, flight_num = FlightNum}) -> - NorthData = #north_data{id = NextId, tuple = Tuple}, - true = ets:insert(Tid, NorthData), - NBuffer = Buffer#buffer{next_id = NextId + 1}, - case FlightNum < WindowSize of - true -> - trigger_next(NBuffer); - false -> - NBuffer - end. - --spec trigger_n(Buffer :: #buffer{}) -> NBuffer :: #buffer{}. -trigger_n(Buffer = #buffer{window_size = WindowSize}) -> - %% 最多允许window_size - lists:foldl(fun(_, Buffer0) -> trigger_next(Buffer0) end, Buffer, lists:seq(1, WindowSize)). - -%% 触发读取下一条数据 --spec trigger_next(Buffer :: #buffer{}) -> NBuffer :: #buffer{}. -trigger_next(Buffer = #buffer{tid = Tid, cursor = Cursor, timer_pid = TimerPid, flight_num = FlightNum}) -> - case ets:first(Tid) of - '$end_of_table' -> - Buffer; - Key -> - [#north_data{id = Id, tuple = Tuple}|_] = ets:take(Tid, Key), - ReceiverPid = self(), - ReceiverPid ! {next_data, Id, Tuple}, - endpoint_timer:task(TimerPid, Id, fun() -> ReceiverPid ! {next_data, Id, Tuple} end), - Buffer#buffer{flight_num = FlightNum + 1, cursor = Cursor + 1} - end. - --spec ack(Id :: integer(), Buffer :: #buffer{}) -> NBuffer :: #buffer{}. -ack(Id, Buffer = #buffer{timer_pid = TimerPid, acc_num = AccNum, flight_num = FlightNum}) when is_integer(Id) -> - endpoint_timer:ack(TimerPid, Id), - trigger_next(Buffer#buffer{acc_num = AccNum + 1, flight_num = FlightNum - 1}). - -%% 获取当前统计信息 --spec stat(Buffer :: #buffer{}) -> map(). -stat(#buffer{acc_num = AccNum, tid = Tid}) -> - #{ - <<"acc_num">> => AccNum, - <<"queue_num">> => ets:info(Tid, size) - }. - --spec cleanup(Buffer :: #buffer{}) -> ok. -cleanup(#buffer{timer_pid = TimerPid}) -> - endpoint_timer:cleanup(TimerPid), - ok. - -%%%=================================================================== -%%% Internal functions -%%%=================================================================== \ No newline at end of file diff --git a/apps/iot/src/endpoint/endpoint_kafka.erl b/apps/iot/src/endpoint/endpoint_kafka.erl deleted file mode 100644 index 5f451fe..0000000 --- a/apps/iot/src/endpoint/endpoint_kafka.erl +++ /dev/null @@ -1,184 +0,0 @@ - -%%%------------------------------------------------------------------- -%%% @author aresei -%%% @copyright (C) 2023, -%%% @doc -%%% -%%% @end -%%% Created : 06. 7月 2023 12:02 -%%%------------------------------------------------------------------- --module(endpoint_kafka). - --include("endpoint.hrl"). --behaviour(gen_server). - -%% API --export([start_link/2]). - -%% gen_server callbacks --export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). - -%% 消息重发间隔 --define(RETRY_INTERVAL, 5000). - --define(DISCONNECTED, disconnected). --define(CONNECTED, connected). - --record(state, { - endpoint :: #endpoint{}, - buffer :: endpoint_buffer:buffer(), - client_id :: atom(), - client_pid :: undefined | pid(), - status = ?DISCONNECTED -}). - -%%%=================================================================== -%%% API -%%%=================================================================== - -%% @doc Creates a gen_statem process which calls Module:init/1 to -%% initialize. To ensure a synchronized start-up procedure, this -%% function does not return until Module:init/1 has returned. -start_link(LocalName, Endpoint = #endpoint{}) when is_atom(LocalName) -> - gen_server:start_link({local, LocalName}, ?MODULE, [Endpoint], []). - -%%%=================================================================== -%%% gen_statem callbacks -%%%=================================================================== - -%% @private -%% @doc Whenever a gen_statem is started using gen_statem:start/[3,4] or -%% gen_statem:start_link/[3,4], this function is called by the new -%% process to initialize. -init([Endpoint = #endpoint{id = Id, matcher = Matcher}]) -> - endpoint_subscription:subscribe(Matcher, self()), - - erlang:process_flag(trap_exit, true), - %% 创建转发器, 避免阻塞当前进程的创建,因此采用了延时初始化的机制 - erlang:start_timer(0, self(), connect), - %% 初始化存储 - Buffer = endpoint_buffer:new(Endpoint, 10), - ClientId = list_to_atom("brod_client:" ++ integer_to_list(Id)), - - {ok, #state{endpoint = Endpoint, buffer = Buffer, status = ?DISCONNECTED, client_id = ClientId}}. - -%% @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{}}). -handle_call(get_stat, _From, State = #state{buffer = Buffer}) -> - Stat = endpoint_buffer:stat(Buffer), - {reply, {ok, Stat}, 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({forward, Metric}, State = #state{buffer = Buffer}) -> - NBuffer = endpoint_buffer:append(Metric, Buffer), - {noreply, State#state{buffer = NBuffer}}. - -%% @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({timeout, _, connect}, State = #state{buffer = Buffer, status = ?DISCONNECTED, client_id = ClientId, - endpoint = #endpoint{title = Title, config = #kafka_endpoint{sasl_config = SaslConfig, bootstrap_servers = BootstrapServers, topic = Topic}}}) -> - lager:debug("[endpoint_kafka] endpoint: ~p, create postman", [Title]), - - BaseConfig = [ - {reconnect_cool_down_seconds, 5}, - {socket_options, [{keepalive, true}]} - ], - - ClientConfig = case SaslConfig of - {Mechanism, Username, Password} -> - [{sasl, {Mechanism, Username, Password}}|BaseConfig]; - undefined -> - BaseConfig - end, - - case catch brod:start_link_client(BootstrapServers, ClientId, ClientConfig) of - {ok, ClientPid} -> - case brod:start_producer(ClientId, Topic, _ProducerConfig = []) of - ok -> - NBuffer = endpoint_buffer:trigger_next(Buffer), - {noreply, State#state{buffer = NBuffer, client_pid = ClientPid, status = ?CONNECTED}}; - {error, Reason} -> - lager:debug("[endpoint_kafka] start_producer: ~p, get error: ~p", [ClientId, Reason]), - brod:stop_client(ClientId), - retry_connect(), - {noreply, State#state{status = ?DISCONNECTED, client_pid = undefined}} - end; - Error -> - lager:debug("[endpoint_kafka] start_client: ~p, get error: ~p", [ClientId, Error]), - retry_connect(), - {noreply, State#state{status = ?DISCONNECTED, client_pid = undefined}} - end; - -%% 离线时,忽略数据发送逻辑 -handle_info({next_data, _Id, _Tuple}, State = #state{status = ?DISCONNECTED}) -> - {noreply, State}; -%% 发送数据到mqtt服务器 -handle_info({next_data, Id, Metric}, State = #state{status = ?CONNECTED, client_pid = ClientPid, - endpoint = #endpoint{config = #kafka_endpoint{topic = Topic}}}) -> - - ReceiverPid = self(), - AckCb = fun(Partition, BaseOffset) -> - lager:debug("[endpoint_kafka] ack partion: ~p, offset: ~p", [Partition, BaseOffset]), - ReceiverPid ! {ack, Id} - end, - _ = brod:produce_cb(ClientPid, Topic, random, <<>>, Metric, AckCb), - - {noreply, State}; - -handle_info({ack, Id}, State = #state{buffer = Buffer}) -> - NBuffer = endpoint_buffer:ack(Id, Buffer), - {noreply, State#state{buffer = NBuffer}}; - -%% postman进程挂掉时,重新建立新的 -handle_info({'EXIT', ClientPid, Reason}, State = #state{client_pid = ClientPid, endpoint = #endpoint{title = Title}}) -> - lager:warning("[endpoint_kafka] endpoint: ~p, conn pid exit with reason: ~p", [Title, Reason]), - retry_connect(), - {noreply, State#state{client_pid = undefined, status = ?DISCONNECTED}}; - -handle_info(Info, State = #state{status = Status}) -> - lager:warning("[endpoint_kafka] unknown message: ~p, status: ~p", [Info, Status]), - {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{endpoint = #endpoint{title = Title}, buffer = Buffer}) -> - lager:debug("[endpoint_kafka] endpoint: ~p, terminate with reason: ~p", [Title, Reason]), - endpoint_buffer:cleanup(Buffer), - 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 -%%%=================================================================== - -retry_connect() -> - erlang:start_timer(?RETRY_INTERVAL, self(), connect). \ No newline at end of file diff --git a/apps/iot/src/endpoint/endpoint_mqtt.erl b/apps/iot/src/endpoint/endpoint_mqtt.erl deleted file mode 100644 index 727b1ab..0000000 --- a/apps/iot/src/endpoint/endpoint_mqtt.erl +++ /dev/null @@ -1,203 +0,0 @@ - -%%%------------------------------------------------------------------- -%%% @author aresei -%%% @copyright (C) 2023, -%%% @doc -%%% -%%% @end -%%% Created : 06. 7月 2023 12:02 -%%%------------------------------------------------------------------- --module(endpoint_mqtt). - --include("endpoint.hrl"). --behaviour(gen_server). - -%% API --export([start_link/2]). - -%% gen_server callbacks --export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). - -%% 消息重发间隔 --define(RETRY_INTERVAL, 15000). - --define(DISCONNECTED, disconnected). --define(CONNECTED, connected). - --record(state, { - endpoint :: #endpoint{}, - buffer :: endpoint_buffer:buffer(), - conn_pid :: undefined | pid(), - %% 待确认的数据, #{PacketId :: integer() => Id :: integer()} - inflight = #{}, - - status = disconnected -}). - -%%%=================================================================== -%%% API -%%%=================================================================== - -%% @doc Creates a gen_statem process which calls Module:init/1 to -%% initialize. To ensure a synchronized start-up procedure, this -%% function does not return until Module:init/1 has returned. -start_link(LocalName, Endpoint = #endpoint{}) when is_atom(LocalName) -> - gen_server:start_link({local, LocalName}, ?MODULE, [Endpoint], []). - -%%%=================================================================== -%%% gen_statem callbacks -%%%=================================================================== - -%% @private -%% @doc Whenever a gen_statem is started using gen_statem:start/[3,4] or -%% gen_statem:start_link/[3,4], this function is called by the new -%% process to initialize. -init([Endpoint = #endpoint{matcher = Matcher}]) -> - % erlang:process_flag(trap_exit, true), - endpoint_subscription:subscribe(Matcher, self()), - - %% 创建转发器, 避免阻塞当前进程的创建,因此采用了延时初始化的机制 - erlang:start_timer(0, self(), create_postman), - %% 初始化存储 - Buffer = endpoint_buffer:new(Endpoint, 10), - - {ok, #state{endpoint = Endpoint, buffer = Buffer, status = ?DISCONNECTED}}. - -%% @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{}}). -handle_call(get_stat, _From, State = #state{buffer = Buffer}) -> - Stat = endpoint_buffer:stat(Buffer), - {reply, {ok, Stat}, 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({forward, Metric}, State = #state{buffer = Buffer}) -> - NBuffer = endpoint_buffer:append(Metric, Buffer), - {noreply, State#state{buffer = NBuffer}}. - -%% @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({timeout, _, create_postman}, State = #state{buffer = Buffer, status = ?DISCONNECTED, - endpoint = #endpoint{title = Title, config = #mqtt_endpoint{host = Host, port = Port, username = Username, password = Password, client_id = ClientId}}}) -> - lager:debug("[endpoint_mqtt] endpoint: ~ts, create postman", [Title]), - Opts = [ - {owner, self()}, - {clientid, ClientId}, - {host, binary_to_list(Host)}, - {port, Port}, - {tcp_opts, []}, - {username, binary_to_list(Username)}, - {password, binary_to_list(Password)}, - {keepalive, 86400}, - {auto_ack, true}, - {connect_timeout, 5000}, - {proto_ver, v5}, - {retry_interval, 5000} - ], - - try - {ok, ConnPid} = emqtt:start_link(Opts), - lager:debug("[endpoint_mqtt] start connect, options: ~p", [Opts]), - case emqtt:connect(ConnPid, 5000) of - {ok, _} -> - lager:debug("[endpoint_mqtt] connect success, pid: ~p", [ConnPid]), - NBuffer = endpoint_buffer:trigger_n(Buffer), - {noreply, State#state{conn_pid = ConnPid, buffer = NBuffer, status = ?CONNECTED}}; - {error, Reason} -> - lager:warning("[endpoint_mqtt] connect get error: ~p", [Reason]), - erlang:start_timer(?RETRY_INTERVAL, self(), create_postman), - {noreply, State} - end - catch _:Error-> - lager:warning("[endpoint_mqtt] connect get error: ~p", [Error]), - erlang:start_timer(?RETRY_INTERVAL, self(), create_postman), - {noreply, State} - end; - -%% 离线时,忽略数据发送逻辑 -handle_info({next_data, _Id, _Tuple}, State = #state{status = ?DISCONNECTED}) -> - {keep_state, State}; -%% 发送数据到mqtt服务器 -handle_info({next_data, Id, Metric}, State = #state{status = ?CONNECTED, conn_pid = ConnPid, buffer = Buffer, inflight = InFlight, - endpoint = #endpoint{config = #mqtt_endpoint{topic = Topic, qos = Qos}}}) -> - - lager:debug("[endpoint_mqtt] will publish topic: ~p, metric: ~p, qos: ~p", [Topic, Metric, Qos]), - case emqtt:publish(ConnPid, Topic, #{}, Metric, [{qos, Qos}, {retain, true}]) of - ok -> - NBuffer = endpoint_buffer:ack(Id, Buffer), - {noreply, State#state{buffer = NBuffer}}; - {ok, PacketId} -> - {noreply, State#state{inflight = maps:put(PacketId, Id, InFlight)}}; - {error, Reason} -> - lager:warning("[endpoint_mqtt] send message to topic: ~p, get error: ~p", [Topic, Reason]), - {stop, Reason, State} - end; - -handle_info({disconnected, ReasonCode, Properties}, State = #state{status = ?CONNECTED}) -> - lager:debug("[endpoint_mqtt] Recv a DISONNECT packet - ReasonCode: ~p, Properties: ~p", [ReasonCode, Properties]), - erlang:start_timer(?RETRY_INTERVAL, self(), create_postman), - {noreply, State#state{conn_pid = undefined, status = ?DISCONNECTED}}; - -handle_info({publish, Message = #{packet_id := _PacketId, payload := Payload}}, State = #state{status = ?CONNECTED}) -> - lager:debug("[endpoint_mqtt] Recv a publish packet: ~p, payload: ~p", [Message, Payload]), - {noreply, State}; - -%% 收到确认的消息 -handle_info({puback, #{packet_id := PacketId}}, State = #state{status = ?CONNECTED, inflight = Inflight, buffer = Buffer}) -> - case maps:take(PacketId, Inflight) of - {Id, RestInflight} -> - NBuffer = endpoint_buffer:ack(Id, Buffer), - {noreply, State#state{buffer = NBuffer, inflight = RestInflight}}; - error -> - {noreply, State} - end; - -%% postman进程挂掉时,重新建立新的 -handle_info({'EXIT', ConnPid, Reason}, State = #state{endpoint = #endpoint{title = Title}, conn_pid = ConnPid}) -> - lager:warning("[endpoint_mqtt] endpoint: ~p, conn pid exit with reason: ~p", [Title, Reason]), - erlang:start_timer(?RETRY_INTERVAL, self(), create_postman), - {noreply, State#state{conn_pid = undefined, status = ?DISCONNECTED}}; - -handle_info(Info, State = #state{status = Status}) -> - lager:warning("[endpoint_mqtt] unknown message: ~p, status: ~p", [Info, Status]), - {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{endpoint = #endpoint{title = Title}, buffer = Buffer}) -> - lager:debug("[endpoint_mqtt] endpoint: ~p, terminate with reason: ~p", [Title, Reason]), - endpoint_buffer:cleanup(Buffer), - 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 -%%%=================================================================== \ No newline at end of file diff --git a/apps/iot/src/endpoint/endpoint_subscription.erl b/apps/iot/src/endpoint/endpoint_subscription.erl deleted file mode 100644 index 4bcd702..0000000 --- a/apps/iot/src/endpoint/endpoint_subscription.erl +++ /dev/null @@ -1,206 +0,0 @@ -%%%------------------------------------------------------------------- -%%% @author anlicheng -%%% @copyright (C) 2025, -%%% @doc -%%% -%%% @end -%%% Created : 07. 11月 2025 16:27 -%%%------------------------------------------------------------------- --module(endpoint_subscription). --author("anlicheng"). - --behaviour(gen_server). - -%% API --export([start_link/0]). --export([subscribe/2, publish/2, get_subscribers/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 = [] -}). - -%%%=================================================================== -%%% 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 get_subscribers() -> {ok, Subscribers :: map()}. -get_subscribers() -> - gen_server:call(?SERVER, get_subscribers). - --spec publish(RouteKey :: binary(), Content :: binary()) -> no_return(). -publish(RouteKey, Content) when is_binary(RouteKey), is_binary(Content) -> - gen_server:cast(?SERVER, {publish, RouteKey, Content}). - -%% @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(get_subscribers, _From, State = #state{subscribers = Subscribers}) -> - {reply, {ok, Subscribers}, State}; -handle_call({subscribe, Topic, SubscriberPid}, _From, State = #state{subscribers = Subscribers}) -> - Components = of_components(Topic), - case is_valid_components(Components) of - true -> - Sub = #subscriber{topic = Topic, subscriber_pid = SubscriberPid, components = Components, order = order_num(Components)}, - %% 建立到SubscriberPid的monitor,进程退出需要清理订阅 - erlang:monitor(process, SubscriberPid), - - {reply, ok, State#state{subscribers = Subscribers ++ [Sub]}}; - false -> - {reply, {error, <<"invalid topic name">>}, State} - end. - -%% @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, RouteKey, Metric}, State = #state{subscribers = Subscribers}) -> - MatchedSubscribers = match_subscribers(Subscribers, RouteKey), - lists:foreach(fun(#subscriber{subscriber_pid = SubscriberPid}) -> - endpoint:forward(SubscriberPid, Metric) - end, MatchedSubscribers), - lager:debug("[efka_subscription] route_key: ~p, metric: ~p, match subscribers: ~p", [RouteKey, Metric, MatchedSubscribers]), - {noreply, State}. - -%% @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}) -> - lager:debug("[efka_subscription] subscriber: ~p, down with reason: ~p", [SubscriberPid, Reason]), - NSubscribers = lists:filter(fun(#subscriber{subscriber_pid = Pid0}) -> SubscriberPid /= Pid0 end, Subscribers), - {noreply, State#state{subscribers = NSubscribers}}; - -handle_info(Info, State = #state{}) -> - lager: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. - -%% 开始对比订阅的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]). - -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). \ No newline at end of file diff --git a/apps/iot/src/endpoint/endpoint_timer.erl b/apps/iot/src/endpoint/endpoint_timer.erl deleted file mode 100644 index 1244500..0000000 --- a/apps/iot/src/endpoint/endpoint_timer.erl +++ /dev/null @@ -1,128 +0,0 @@ -%%%------------------------------------------------------------------- -%%% @author anlicheng -%%% @copyright (C) 2024, -%%% @doc -%%% -%%% @end -%%% Created : 07. 5月 2024 10:30 -%%%------------------------------------------------------------------- --module(endpoint_timer). --author("anlicheng"). --include("endpoint.hrl"). - --behaviour(gen_server). - -%% API --export([start_link/1]). --export([task/3, ack/2, cleanup/1]). - -%% gen_server callbacks --export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). - --record(state, { - retry_interval = 0, - %% 定时器 - timer_map = #{} -}). - -%%%=================================================================== -%%% API -%%%=================================================================== - -task(Pid, Id, Task) when is_pid(Pid), is_integer(Id), is_function(Task, 0) -> - gen_server:cast(Pid, {task, Id, Task}). - -ack(Pid, Id) when is_pid(Pid), is_integer(Id) -> - gen_server:cast(Pid, {ack, Id}). - -cleanup(Pid) when is_pid(Pid) -> - gen_server:cast(Pid, cleanup). - -%% @doc Spawns the server and registers the local name (unique) --spec(start_link(RetryInterval :: integer()) -> - {ok, Pid :: pid()} | ignore | {error, Reason :: term()}). -start_link(RetryInterval) when is_integer(RetryInterval) -> - gen_server:start_link(?MODULE, [RetryInterval], []). - -%%%=================================================================== -%%% 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([RetryInterval]) -> - {ok, #state{retry_interval = RetryInterval}}. - -%% @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{}}). -handle_call(_Request, _From, State = #state{}) -> - {reply, ok, 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({task, Id, Task}, State = #state{retry_interval = RetryInterval, timer_map = TimerMap}) -> - TimerRef = erlang:start_timer(RetryInterval, self(), {repost_ticker, {Id, Task}}), - {noreply, State#state{timer_map = maps:put(Id, TimerRef, TimerMap)}}; - -%% 取消 -handle_cast({ack, Id}, State = #state{timer_map = TimerMap}) -> - case maps:take(Id, TimerMap) of - error -> - {noreply, State}; - {TimerRef, NTimerMap} -> - is_reference(TimerRef) andalso erlang:cancel_timer(TimerRef), - {noreply, State#state{timer_map = NTimerMap}} - end; - -handle_cast(cleanup, State = #state{timer_map = TimerMap}) -> - lists:foreach(fun({_, TimerRef}) -> catch erlang:cancel_timer(TimerRef) end, maps:to_list(TimerMap)), - {noreply, State#state{timer_map = #{}}}. - -%% @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({timeout, _, {repost_ticker, {Id, Task}}}, State = #state{retry_interval = RetryInterval, timer_map = TimerMap}) -> - Task(), - TimerRef = erlang:start_timer(RetryInterval, self(), {repost_ticker, {Id, Task}}), - - {noreply, State#state{timer_map = maps:put(Id, TimerRef, TimerMap)}}. - -%% @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 -%%%=================================================================== diff --git a/apps/iot/src/host/iot_container_task.erl b/apps/iot/src/host/iot_container_task.erl new file mode 100644 index 0000000..269834d --- /dev/null +++ b/apps/iot/src/host/iot_container_task.erl @@ -0,0 +1,147 @@ +%%%------------------------------------------------------------------- +%%% @doc Owns one container deployment task stream. +%%% @end +%%%------------------------------------------------------------------- +-module(iot_container_task). + +-behaviour(gen_server). + +%% API +-export([start_link/2]). +-export([get_pid/2, subscribe/3, stream/3, close/2]). + +%% gen_server callbacks +-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). + +-define(MAX_EVENTS, 200). +-define(STOP_AFTER_CLOSE, 300000). + +-record(state, { + uuid :: binary(), + task_id :: integer(), + status = pending :: pending | running | success | fail, + events = [] :: [{binary(), binary()}], + listeners = #{} :: #{pid() => reference()}, + close_reason = undefined :: undefined | binary(), + stop_ref = undefined :: undefined | reference() +}). + +%%%=================================================================== +%%% API +%%%=================================================================== + +-spec start_link(binary(), integer()) -> {ok, pid()} | ignore | {error, term()}. +start_link(UUID, TaskId) when is_binary(UUID), is_integer(TaskId) -> + gen_server:start_link({via, gproc, {n, l, name(UUID, TaskId)}}, ?MODULE, [UUID, TaskId], []). + +-spec get_pid(binary(), integer()) -> undefined | pid(). +get_pid(UUID, TaskId) when is_binary(UUID), is_integer(TaskId) -> + gproc:whereis_name({n, l, name(UUID, TaskId)}). + +-spec subscribe(binary(), integer(), pid()) -> ok | {error, term()}. +subscribe(UUID, TaskId, ListenerPid) when is_binary(UUID), is_integer(TaskId), is_pid(ListenerPid) -> + case iot_container_task_sup:ensure_started(UUID, TaskId) of + {ok, Pid} -> + gen_server:call(Pid, {subscribe, ListenerPid}); + {error, Reason} -> + {error, Reason} + end. + +-spec stream(pid(), binary(), binary()) -> ok. +stream(Pid, Type, Stream) when is_pid(Pid), is_binary(Type), is_binary(Stream) -> + gen_server:cast(Pid, {stream, Type, Stream}). + +-spec close(pid(), binary()) -> ok. +close(Pid, Reason) when is_pid(Pid), is_binary(Reason) -> + gen_server:cast(Pid, {close, Reason}). + +%%%=================================================================== +%%% gen_server callbacks +%%%=================================================================== + +-spec init([binary() | integer()]) -> {ok, #state{}}. +init([UUID, TaskId]) -> + ok = iot_log:set_metadata(), + {ok, #state{uuid = UUID, task_id = TaskId}}. + +-spec handle_call(term(), {pid(), term()}, #state{}) -> + {reply, term(), #state{}}. +handle_call({subscribe, ListenerPid}, _From, State0 = #state{task_id = TaskId, events = Events, listeners = Listeners, close_reason = CloseReason}) -> + MonRef = erlang:monitor(process, ListenerPid), + maps:get(ListenerPid, Listeners, undefined) =/= undefined andalso erlang:demonitor(maps:get(ListenerPid, Listeners), [flush]), + lists:foreach(fun({Type, Stream}) -> + ListenerPid ! {stream_data, TaskId, Type, Stream} + end, Events), + case CloseReason of + undefined -> + ok; + Reason -> + ListenerPid ! {stream_close, TaskId, Reason} + end, + {reply, ok, State0#state{listeners = maps:put(ListenerPid, MonRef, Listeners)}}; +handle_call(_Request, _From, State) -> + {reply, ok, State}. + +-spec handle_cast(term(), #state{}) -> {noreply, #state{}}. +handle_cast({stream, Type, Stream}, State0 = #state{task_id = TaskId, events = Events0, listeners = Listeners}) -> + Event = {Type, Stream}, + Events = trim_events(Events0 ++ [Event]), + maps:foreach(fun(ListenerPid, _MonRef) -> + ListenerPid ! {stream_data, TaskId, Type, Stream} + end, Listeners), + {noreply, State0#state{status = running, events = Events}}; +handle_cast({close, Reason}, State0 = #state{task_id = TaskId, listeners = Listeners}) -> + maps:foreach(fun(ListenerPid, _MonRef) -> + ListenerPid ! {stream_close, TaskId, Reason} + end, Listeners), + Status = close_status(Reason), + {noreply, ensure_stop_timer(State0#state{status = Status, close_reason = Reason})}; +handle_cast(_Request, State) -> + {noreply, State}. + +-spec handle_info(term(), #state{}) -> {noreply, #state{}} | {stop, normal, #state{}}. +handle_info({'DOWN', _Ref, process, ListenerPid, _Reason}, State = #state{listeners = Listeners}) -> + {noreply, State#state{listeners = maps:remove(ListenerPid, Listeners)}}; +handle_info(stop_after_close, State) -> + {stop, normal, State}; +handle_info(_Info, State) -> + {noreply, State}. + +-spec terminate(term(), #state{}) -> ok. +terminate(_Reason, _State) -> + ok. + +-spec code_change(term(), #state{}, term()) -> {ok, #state{}}. +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +-spec name(binary(), integer()) -> term(). +name(UUID, TaskId) -> + {iot_container_task, UUID, TaskId}. + +-spec trim_events([{binary(), binary()}]) -> [{binary(), binary()}]. +trim_events(Events) -> + Len = length(Events), + case Len > ?MAX_EVENTS of + true -> + lists:nthtail(Len - ?MAX_EVENTS, Events); + false -> + Events + end. + +-spec close_status(binary()) -> success | fail. +close_status(<<"success">>) -> + success; +close_status(_) -> + fail. + +-spec ensure_stop_timer(#state{}) -> #state{}. +ensure_stop_timer(State = #state{stop_ref = undefined}) -> + Ref = erlang:send_after(?STOP_AFTER_CLOSE, self(), stop_after_close), + State#state{stop_ref = Ref}; +ensure_stop_timer(State) -> + State. diff --git a/apps/iot/src/host/iot_container_task_sup.erl b/apps/iot/src/host/iot_container_task_sup.erl new file mode 100644 index 0000000..406217d --- /dev/null +++ b/apps/iot/src/host/iot_container_task_sup.erl @@ -0,0 +1,100 @@ +%%%------------------------------------------------------------------- +%%% @doc Dynamic supervisor for container deployment tasks. +%%% @end +%%%------------------------------------------------------------------- +-module(iot_container_task_sup). + +-behaviour(supervisor). + +%% API +-export([start_link/0]). +-export([ensure_started/2, stream/4, close/3, fail/3]). + +%% supervisor callbacks +-export([init/1]). + +-define(SERVER, ?MODULE). + +%%%=================================================================== +%%% API +%%%=================================================================== + +-spec start_link() -> {ok, pid()} | ignore | {error, term()}. +start_link() -> + supervisor:start_link({local, ?SERVER}, ?MODULE, []). + +-spec ensure_started(binary(), integer()) -> {ok, pid()} | {error, term()}. +ensure_started(UUID, TaskId) when is_binary(UUID), is_integer(TaskId) -> + case iot_container_task:get_pid(UUID, TaskId) of + undefined -> + case supervisor:start_child(?SERVER, [UUID, TaskId]) of + {ok, Pid} -> + {ok, Pid}; + {ok, Pid, _Info} -> + {ok, Pid}; + {error, {already_started, Pid}} -> + {ok, Pid}; + {error, {shutdown, {failed_to_start_child, _Id, {already_started, Pid}}}} -> + {ok, Pid}; + {error, Reason} -> + {error, Reason} + end; + Pid when is_pid(Pid) -> + {ok, Pid} + end. + +-spec stream(binary(), integer(), binary(), binary()) -> ok. +stream(UUID, TaskId, Type, Stream) + when is_binary(UUID), is_integer(TaskId), is_binary(Type), is_binary(Stream) -> + case ensure_started(UUID, TaskId) of + {ok, Pid} -> + iot_container_task:stream(Pid, Type, Stream); + {error, Reason} -> + logger:warning("[iot_container_task_sup] start task failed, uuid: ~p, task_id: ~p, reason: ~p", [UUID, TaskId, Reason]), + ok + end. + +-spec close(binary(), integer(), binary()) -> ok. +close(UUID, TaskId, Reason) when is_binary(UUID), is_integer(TaskId), is_binary(Reason) -> + case ensure_started(UUID, TaskId) of + {ok, Pid} -> + iot_container_task:close(Pid, Reason); + {error, StartReason} -> + logger:warning("[iot_container_task_sup] start task before close failed, uuid: ~p, task_id: ~p, reason: ~p", [UUID, TaskId, StartReason]), + ok + end. + +-spec fail(binary(), integer(), term()) -> ok. +fail(UUID, TaskId, Reason) when is_binary(UUID), is_integer(TaskId) -> + ReasonBin = reason_to_binary(Reason), + ok = stream(UUID, TaskId, <<"error">>, ReasonBin), + close(UUID, TaskId, <<"fail">>). + +%%%=================================================================== +%%% supervisor callbacks +%%%=================================================================== + +-spec init([]) -> {ok, {{simple_one_for_one, non_neg_integer(), pos_integer()}, [supervisor:child_spec()]}}. +init([]) -> + SupFlags = {simple_one_for_one, 10, 60}, + ChildSpec = #{ + id => iot_container_task, + start => {iot_container_task, start_link, []}, + restart => temporary, + shutdown => 5000, + type => worker, + modules => [iot_container_task] + }, + {ok, {SupFlags, [ChildSpec]}}. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +-spec reason_to_binary(term()) -> binary(). +reason_to_binary(Reason) when is_binary(Reason) -> + Reason; +reason_to_binary(timeout) -> + <<"timeout">>; +reason_to_binary(Reason) -> + unicode:characters_to_binary(io_lib:format("~p", [Reason])). diff --git a/apps/iot/src/host/iot_host.erl b/apps/iot/src/host/iot_host.erl new file mode 100644 index 0000000..93fc72e --- /dev/null +++ b/apps/iot/src/host/iot_host.erl @@ -0,0 +1,458 @@ +%%%------------------------------------------------------------------- +%%% @author +%%% @copyright (C) 2023, +%%% @doc +%%% +%%% @end +%%% Created : 22. 9月 2023 16:38 +%%%------------------------------------------------------------------- +-module(iot_host). +-author("aresei"). +-include("iot.hrl"). +-include("domain_model.hrl"). + +-behaviour(gen_statem). + +%% 心跳包检测时间间隔, 15分钟检测一次 +-define(HEARTBEAT_INTERVAL, 900 * 1000). + +%% 状态 +-define(STATE_DENIED, denied). +-define(STATE_ACTIVATED, activated). + +%% API +-export([start_link/2, get_name/1, get_alias_name/1, get_pid/1, handle/2, activate/2]). +-export([lookup_pid/1]). +-export([get_metric/1, get_status/1, kill/1]). +%% 通讯相关 +-export([pub/4, attach_channel/2]). +-export([deploy_container/3, start_container/2, stop_container/2, remove_container/2, kill_container/2, config_container/3, get_containers/1, await_reply/3]). +-export([heartbeat/1]). + +%% gen_statem callbacks +-export([init/1, handle_event/4, terminate/3, code_change/4, callback_mode/0]). + +-record(state, { + host_id :: integer(), + %% 从数据库里面读取到的数据 + uuid :: binary(), + %% 主机状态 + host_status :: integer(), + + has_session = false :: boolean(), + %% 心跳计数器 + heartbeat_counter = 0 :: integer(), + %% websocket相关 + channel_pid :: undefined | pid(), + %% 主机的相关信息 + metrics = #{} :: map() +}). + +%%%=================================================================== +%%% API +%%%=================================================================== +-spec get_pid(UUID :: binary()) -> undefined | pid(). +get_pid(UUID) when is_binary(UUID) -> + Name = get_name(UUID), + gproc:whereis_name({n, l, Name}). + +-spec lookup_pid(UUID :: binary()) -> {ok, pid()} | {error, Reason :: any()}. +lookup_pid(UUID) when is_binary(UUID) -> + case get_pid(UUID) of + undefined -> + {error, <<"host not found">>}; + HostPid -> + {ok, HostPid} + end. + +-spec get_name(UUID :: binary()) -> term(). +get_name(UUID) when is_binary(UUID) -> + {iot_host, UUID}. + +-spec get_alias_name(HostId :: integer()) -> term(). +get_alias_name(HostId0) when is_integer(HostId0) -> + {iot_host_id, HostId0}. + +-spec kill(UUID :: binary()) -> ok | true. +kill(UUID) when is_binary(UUID) -> + case get_pid(UUID) of + undefined -> + ok; + Pid -> + exit(Pid, kill) + end. + +%% 处理消息 +-spec handle(Pid :: pid(), Packet :: tuple()) -> ok. +handle(Pid, Packet) when is_pid(Pid) -> + gen_statem:cast(Pid, {handle, Packet}). + +-spec get_status(Pid :: pid()) -> {ok, Status :: map()}. +get_status(Pid) when is_pid(Pid) -> + gen_statem:call(Pid, get_status). + +%% 激活主机, true 表示激活; false表示关闭激活 +-spec activate(Pid :: pid(), Auth :: boolean()) -> ok | {error, term()}. +activate(Pid, Auth) when is_pid(Pid), is_boolean(Auth) -> + gen_statem:call(Pid, {activate, Auth}). + +-spec get_metric(Pid :: pid()) -> {ok, MetricInfo :: map()}. +get_metric(Pid) when is_pid(Pid) -> + gen_statem:call(Pid, get_metric). + +-spec attach_channel(pid(), pid()) -> ok | {error, Reason :: binary()}. +attach_channel(Pid, ChannelPid) when is_pid(Pid), is_pid(ChannelPid) -> + gen_statem:call(Pid, {attach_channel, ChannelPid}). + +-type request_ref() :: binary(). + +-spec get_containers(Pid :: pid()) -> {ok, Ref :: request_ref()} | {error, Reason :: any()}. +get_containers(Pid) when is_pid(Pid) -> + container_call(Pid, docker_container_builder:list_request()). + +-spec config_container(Pid :: pid(), ContainerName :: binary(), ConfigJson :: binary()) -> {ok, Ref :: request_ref()} | {error, Reason :: any()}. +config_container(Pid, ContainerName, ConfigJson) when is_pid(Pid), is_binary(ContainerName), is_binary(ConfigJson) -> + container_call(Pid, docker_container_builder:config_request(ContainerName, ConfigJson)). + +-spec deploy_container(Pid :: pid(), TaskId :: integer(), Config :: map()) -> {ok, Ref :: request_ref()} | {error, Reason :: any()}. +deploy_container(Pid, TaskId, Config) when is_pid(Pid), is_integer(TaskId), is_map(Config) -> + case docker_container_builder:deploy_request(TaskId, Config) of + {ok, Request} -> + container_call(Pid, Request); + {error, Reason} -> + {error, Reason} + end. + +-spec start_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: request_ref()} | {error, Reason :: any()}. +start_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) -> + container_call(Pid, docker_container_builder:start_request(ContainerName)). + +-spec stop_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: request_ref()} | {error, Reason :: any()}. +stop_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) -> + container_call(Pid, docker_container_builder:stop_request(ContainerName)). + +-spec kill_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: request_ref()} | {error, Reason :: any()}. +kill_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) -> + container_call(Pid, docker_container_builder:kill_request(ContainerName)). + +-spec remove_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: request_ref()} | {error, Reason :: any()}. +remove_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) -> + container_call(Pid, docker_container_builder:remove_request(ContainerName)). + +-spec await_reply(Pid :: pid(), Ref :: request_ref(), Timeout :: integer()) -> + ok | {ok, Result :: term()} | {error, Reason :: term()}. +await_reply(Pid, Ref, Timeout) when is_pid(Pid), is_binary(Ref), is_integer(Timeout) -> + receive + {command_reply, Ref, ok} -> + ok; + {command_reply, Ref, {ok, Result}} -> + {ok, Result}; + {command_reply, Ref, {error, Reason}} -> + {error, Reason} + after Timeout -> + ok = gen_statem:call(Pid, {cancel_command_call, Ref}), + flush_reply(Ref), + {error, timeout} + end. + +-spec pub(Pid :: pid(), Topic :: binary(), Qos :: integer(), Content :: binary()) -> ok | {error, Reason :: any()}. +pub(Pid, Topic, Qos, Content) when is_pid(Pid), is_binary(Topic), is_integer(Qos), is_binary(Content) -> + gen_statem:call(Pid, {pub, Topic, Qos, Content}). + +-spec heartbeat(Pid :: undefined | pid()) -> ok. +heartbeat(undefined) -> + ok; +heartbeat(Pid) when is_pid(Pid) -> + gen_statem:cast(Pid, heartbeat). + +%% @doc Creates a gen_statem process which calls Module:init/1 to +%% initialize. To ensure a synchronized start-up procedure, this +%% function does not return until Module:init/1 has returned. +start_link(Name, UUID) when is_binary(UUID) -> + gen_statem:start_link({via, gproc, {n, l, Name}}, ?MODULE, [UUID], []). + +%%%=================================================================== +%%% gen_statem callbacks +%%%=================================================================== + +%% @private +%% @doc Whenever a gen_statem is started using gen_statem:start/[3,4] or +%% gen_statem:start_link/[3,4], this function is called by the new +%% process to initialize. +init([UUID]) -> + ok = iot_log:set_metadata(), + case iot_api_client:get_host_by_uuid(UUID) of + {ok, #host_info{id = HostId, authorize_status = AuthorizeStatus, status = HostStatus}} -> + %% 通过host_id注册别名, 可以避免通过查询数据库获取HostPid + AliasName = get_alias_name(HostId), + global:register_name(AliasName, self()), + + %% 心跳检测机制 + erlang:start_timer(?HEARTBEAT_INTERVAL, self(), heartbeat_ticker), + + StateName = case AuthorizeStatus =:= ?HOST_AUTHORIZED of + true -> ?STATE_ACTIVATED; + false -> ?STATE_DENIED + end, + {ok, StateName, #state{host_id = HostId, uuid = UUID, host_status = HostStatus, has_session = false}}; + undefined -> + logger:warning("[iot_host] host uuid: ~p, load host info failed", [UUID]), + ignore + end. + +%% @private +%% @doc This function is called by a gen_statem when it needs to find out +%% the callback mode of the callback module. +callback_mode() -> + handle_event_function. + +%% @private +%% @doc If callback_mode is handle_event_function, then whenever a +%% gen_statem receives an event from call/2, cast/2, or as a normal +%% process message, this function is called. + +handle_event({call, From}, get_metric, _, State = #state{metrics = Metrics}) -> + {keep_state, State, [{reply, From, {ok, Metrics}}]}; + +%% 获取主机的状态 +handle_event({call, From}, get_status, _, State = #state{channel_pid = ChannelPid, heartbeat_counter = HeartbeatCounter, metrics = Metrics, has_session = HasSession, host_status = HostStatus}) -> + HasChannel = (ChannelPid /= undefined), + Reply = #{ + <<"has_channel">> => HasChannel, + <<"has_session">> => HasSession, + <<"host_status">> => HostStatus, + <<"heartbeat_counter">> => HeartbeatCounter, + <<"metrics">> => Metrics + }, + {keep_state, State, [{reply, From, {ok, Reply}}]}; + +handle_event({call, From}, {container_call, ReceiverPid, Request}, _, State = #state{uuid = UUID, channel_pid = ChannelPid, has_session = HasSession}) -> + case HasSession andalso is_pid(ChannelPid) of + true -> + Ref = request_ref(), + ok = ssl_channel:container_call(ChannelPid, ReceiverPid, Ref, Request), + {keep_state, State, [{reply, From, {ok, Ref}}]}; + false -> + logger:debug("[iot_host] uuid: ~p, invalid state: ~p", [UUID, state_map(State)]), + {keep_state, State, [{reply, From, {error, <<"主机离线,发送命令失败"/utf8>>}}]} + end; + +handle_event({call, From}, {cancel_command_call, Ref}, _, State = #state{channel_pid = ChannelPid}) -> + case is_pid(ChannelPid) of + true -> + ok = ssl_channel:cancel_command_call(ChannelPid, Ref), + {keep_state, State, [{reply, From, ok}]}; + false -> + {keep_state, State, [{reply, From, ok}]} + end; + +%% 发送指令时, pub/sub +handle_event({call, From}, {pub, Topic, Qos, Content}, StateName, State = #state{uuid = UUID, channel_pid = ChannelPid, has_session = HasSession}) -> + case StateName of + ?STATE_ACTIVATED -> + case HasSession andalso is_pid(ChannelPid) of + true -> + logger:debug("[iot_host] host: ~p, publish to topic: ~p, content: ~p", [UUID, Topic, Content]), + %% 通过websocket发送消息 + ssl_channel:pub(ChannelPid, Topic, Qos, Content), + + {keep_state, State, [{reply, From, ok}]}; + false -> + logger:debug("[iot_host] uuid: ~p, publish to topic: ~p, content: ~p, invalid state: ~p", [UUID, Topic, Content, state_map(State)]), + {keep_state, State, [{reply, From, {error, <<"主机离线,发送失败"/utf8>>}}]} + end; + ?STATE_DENIED -> + {keep_state, State, [{reply, From, {error, <<"主机未激活,发送失败"/utf8>>}}]} + end; + +%% 激活/关闭授权只修改 iot 本地授权状态;efka 连接保持在线,数据是否处理由 host 状态决定。 +handle_event({call, From}, {activate, Auth}, _, State = #state{uuid = UUID}) -> + NStateName = case Auth of + true -> + ?STATE_ACTIVATED; + false -> + ?STATE_DENIED + end, + logger:debug("[iot_host] uuid: ~p, state_name change to ~p", [UUID, NStateName]), + {next_state, NStateName, State, [{reply, From, ok}]}; + +%% 绑定channel +handle_event({call, From}, {attach_channel, ChannelPid}, StateName, State = #state{uuid = UUID, channel_pid = OldChannelPid, host_status = HostStatus}) -> + case OldChannelPid == undefined orelse OldChannelPid =:= ChannelPid of + true -> + case StateName of + ?STATE_ACTIVATED -> + erlang:monitor(process, ChannelPid), + %% 更新主机为在线状态 + NState = mark_host_online(UUID, HostStatus, State#state{channel_pid = ChannelPid, has_session = true}), + {keep_state, NState, [{reply, From, ok}]}; + %% 主机未激活 + ?STATE_DENIED -> + logger:notice("[iot_host] attach_channel host_id uuid: ~p, channel: ~p, host denied locally", [UUID, ChannelPid]), + erlang:monitor(process, ChannelPid), + NState = mark_host_online(UUID, HostStatus, State#state{channel_pid = ChannelPid, has_session = true}), + {keep_state, NState, [{reply, From, ok}]} + end; + false -> + logger:notice("[iot_host] attach_channel host_id uuid: ~p, old channel exists: ~p", [UUID, OldChannelPid]), + {keep_state, State, [{reply, From, {error, <<"channel existed">>}}]} + end; + +%% 数据分发 +handle_event(cast, {handle, {data, RouteKey, MetricBin}}, ?STATE_ACTIVATED, + State = #state{uuid = UUID, has_session = true}) -> + logger:debug("[iot_host] metric_data host: ~p, route_key: ~p, metric: ~p", [UUID, RouteKey, MetricBin]), + endpoint_subscription:publish(get_route_key(RouteKey), MetricBin), + {keep_state, State}; + +handle_event(cast, {handle, {data, RouteKey, MetricBin}}, StateName, + State = #state{uuid = UUID, has_session = true}) -> + logger:notice("[iot_host] host_uuid: ~p, state_name: ~p, metric_data route_key: ~p, metric: ~p, discard", [UUID, StateName, RouteKey, MetricBin]), + {keep_state, State}; + +%% ping的数据是通过aes加密后的,因此需要在有会话的情况下才行 +handle_event(cast, {handle, {ping, Metrics}}, ?STATE_ACTIVATED, State = #state{uuid = UUID, has_session = true}) -> + logger:debug("[iot_host] ping host_id uuid: ~p, get ping: ~p", [UUID, Metrics]), + {keep_state, State#state{metrics = Metrics}}; + +%% 心跳机制 +handle_event(cast, heartbeat, _, State = #state{uuid = UUID, heartbeat_counter = HeartbeatCounter, host_status = HostStatus}) -> + NState = mark_host_online(UUID, HostStatus, State#state{heartbeat_counter = HeartbeatCounter + 1}), + {keep_state, NState}; + +%% UDP 心跳丢失但 SSL channel 仍在时,不能把 host 标记为离线。 +handle_event(info, {timeout, _, heartbeat_ticker}, _, + State = #state{uuid = UUID, heartbeat_counter = 0, host_status = HostStatus, channel_pid = ChannelPid}) when is_pid(ChannelPid) -> + + erlang:start_timer(?HEARTBEAT_INTERVAL, self(), heartbeat_ticker), + logger:warning("[iot_host] uuid: ~p, udp heartbeat lost but ssl channel is alive: ~p", [UUID, ChannelPid]), + NState = mark_host_online(UUID, HostStatus, State#state{heartbeat_counter = 0}), + {keep_state, NState}; + +%% 没有收到 UDP 心跳且没有 SSL channel,主机下线, 设备状态不变 +handle_event(info, {timeout, _, heartbeat_ticker}, _, State = #state{uuid = UUID, heartbeat_counter = 0, host_status = HostStatus}) -> + logger:warning("[iot_host] uuid: ~p, heartbeat lost, devices will unknown", [UUID]), + erlang:start_timer(?HEARTBEAT_INTERVAL, self(), heartbeat_ticker), + NState = mark_host_offline(UUID, HostStatus, State#state{channel_pid = undefined, has_session = false, heartbeat_counter = 0}), + {keep_state, NState}; + +%% 其他情况下需要重置系统计数器 +handle_event(info, {timeout, _, heartbeat_ticker}, _, State = #state{}) -> + erlang:start_timer(?HEARTBEAT_INTERVAL, self(), heartbeat_ticker), + {keep_state, State#state{heartbeat_counter = 0}}; + +%% 当websocket断开的时候,主机的状态不一定改变;主机的状态改变通过心跳机制,会话状态需要改变 +handle_event(info, {'DOWN', _Ref, process, ChannelPid, Reason}, _, State = #state{uuid = UUID, channel_pid = ChannelPid, has_session = HasSession}) -> + logger:warning("[iot_host] uuid: ~p, channel: ~p, down with reason: ~p, has_session: ~p, state: ~p", [UUID, ChannelPid, Reason, HasSession, State]), + {keep_state, State#state{channel_pid = undefined, has_session = false}}; + +handle_event(info, {'DOWN', _Ref, process, Pid, Reason}, _, State = #state{uuid = UUID}) -> + logger:debug("[iot_host] uuid: ~p, process_pid: ~p, down with reason: ~p, state: ~p", [UUID, Pid, Reason, State]), + {keep_state, State}; + +handle_event(Event, Info, StateName, State = #state{uuid = UUID}) -> + logger:warning("[iot_host] host: ~p, event: ~p, unknown message: ~p, state_name: ~p, state: ~p", [UUID, Event, Info, StateName, state_map(State)]), + {keep_state, State}. + +%% @private +%% @doc This function is called by a gen_statem 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_statem terminates with +%% Reason. The return value is ignored. +terminate(Reason, _StateName, _State = #state{uuid = UUID, has_session = HasSession}) -> + logger:debug("[iot_host] host: ~p, terminate with reason: ~p, has_session: ~p", [UUID, Reason, HasSession]), + ok. + +%% @private +%% @doc Convert process state when code is changed +code_change(_OldVsn, StateName, State = #state{}, _Extra) -> + {ok, StateName, State}. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +-spec container_call(Pid :: pid(), Request :: term()) -> + {ok, Ref :: request_ref()} | {error, Reason :: any()}. +container_call(Pid, Request) when is_pid(Pid) -> + gen_statem:call(Pid, {container_call, self(), Request}). + +-spec get_route_key(binary()) -> binary(). +get_route_key(<<"">>) -> + <<"/">>; +get_route_key(RouteKey) when is_binary(RouteKey) -> + RouteKey. + +%% 将当前的state转换成map +state_map(#state{host_id = HostId, uuid = UUID, host_status = HostStatus, has_session = HasSession, heartbeat_counter = HeartbeatCounter, channel_pid = ChannelPid, metrics = Metrics}) -> + #{ + host_id => HostId, + uuid => UUID, + host_status => HostStatus, + has_session => HasSession, + heartbeat_counter => HeartbeatCounter, + channel_pid => ChannelPid, + metrics => Metrics + }. + +flush_reply(Ref) -> + receive + {command_reply, Ref, _Reply} -> + ok + after 0 -> + ok + end. + +-spec request_ref() -> request_ref(). +request_ref() -> + crypto:strong_rand_bytes(16). + +-spec mark_host_online(binary(), integer(), #state{}) -> #state{}. +mark_host_online(UUID, HostStatus, State) -> + case maybe_mark_host_online(UUID, HostStatus) of + keep_status -> + State; + {next_status, NStatus} -> + State#state{host_status = NStatus} + end. + +-spec mark_host_offline(binary(), integer(), #state{}) -> #state{}. +mark_host_offline(UUID, HostStatus, State) -> + case maybe_mark_host_offline(UUID, HostStatus) of + keep_status -> + State; + {next_status, NStatus} -> + State#state{host_status = NStatus} + end. + +-spec maybe_mark_host_online(binary(), integer()) -> {next_status, NStatus :: integer()} | keep_status. +maybe_mark_host_online(UUID, HostStatus) -> + case HostStatus of + ?HOST_OFFLINE -> + case iot_api_client:change_host_status(UUID, ?HOST_ONLINE) of + {ok, Result} -> + logger:debug("[iot_host] uuid: ~p, change host status online success: ~p", [UUID, Result]), + {next_status, ?HOST_ONLINE}; + {error, Reason} -> + logger:warning("[iot_host] uuid: ~p, change host status online failed: ~p", [UUID, Reason]), + keep_status + end; + _ -> + keep_status + end. + +-spec maybe_mark_host_offline(binary(), integer()) -> {next_status, NStatus :: integer()} | keep_status. +maybe_mark_host_offline(UUID, HostStatus) -> + case HostStatus of + ?HOST_ONLINE -> + case iot_api_client:change_host_status(UUID, ?HOST_OFFLINE) of + {ok, Result} -> + logger:debug("[iot_host] uuid: ~p, change host status offline success: ~p", [UUID, Result]), + {next_status, ?HOST_OFFLINE}; + {error, Reason} -> + logger:warning("[iot_host] uuid: ~p, change host status offline failed: ~p", [UUID, Reason]), + keep_status + end; + _ -> + keep_status + end. diff --git a/apps/iot/src/host/iot_host_sup.erl b/apps/iot/src/host/iot_host_sup.erl new file mode 100644 index 0000000..1c81363 --- /dev/null +++ b/apps/iot/src/host/iot_host_sup.erl @@ -0,0 +1,85 @@ +%%%------------------------------------------------------------------- +%%% @author aresei +%%% @copyright (C) 2023, +%%% @doc +%%% @end +%%%------------------------------------------------------------------- +-module(iot_host_sup). +-include("iot.hrl"). + +-behaviour(supervisor). + +-export([start_link/0, init/1, delete_host/1, ensured_host_started/1]). + +start_link() -> + supervisor:start_link({local, ?MODULE}, ?MODULE, []). + +init([]) -> + Specs = lists:map(fun child_spec/1, iot_api_client:get_all_hosts()), + + {ok, {#{strategy => one_for_one, intensity => 1000, period => 3600}, Specs}}. + +-spec ensured_host_started(UUID :: binary()) -> {ok, Pid :: pid()} | {error, Reason :: any()}. +ensured_host_started(UUID) when is_binary(UUID) -> + case iot_host:get_pid(UUID) of + undefined -> + %% 尝试删除下host对应的信息 + case delete_host(UUID) of + ok -> + case supervisor:start_child(?MODULE, child_spec(UUID)) of + {ok, Pid} when is_pid(Pid) -> + {ok, Pid}; + {error, {'already_started', Pid}} when is_pid(Pid) -> + {ok, Pid}; + {error, Error} -> + {error, Error} + end; + {error, Reason} -> + {error, Reason} + end; + Pid when is_pid(Pid) -> + {ok, Pid} + end. + +delete_host(UUID) when is_binary(UUID) -> + Id = iot_host:get_name(UUID), + case supervisor:terminate_child(?MODULE, Id) of + ok -> + delete_host_child(Id, UUID); + {error, not_found} -> + delete_host_child(Id, UUID); + {error, Reason} -> + logger:warning("[iot_host_sup] terminate host: ~p failed: ~p", [UUID, Reason]), + {error, Reason} + end. + +delete_host_child(Id, UUID) -> + case supervisor:delete_child(?MODULE, Id) of + ok -> + ok; + {error, not_found} -> + ok; + {error, running} -> + %% ensure killed then delete again + iot_host:kill(UUID), + case supervisor:delete_child(?MODULE, Id) of + ok -> + ok; + {error, not_found} -> + ok; + {error, Reason} -> + {error, Reason} + end; + {error, Reason} -> + {error, Reason} + end. + +-spec child_spec(UUID :: binary()) -> map(). +child_spec(UUID) when is_binary(UUID) -> + Id = iot_host:get_name(UUID), + #{id => Id, + start => {iot_host, start_link, [Id, UUID]}, + restart => permanent, + shutdown => 2000, + type => worker, + modules => ['iot_host']}. diff --git a/apps/iot/src/http/container_handler.erl b/apps/iot/src/http/container_handler.erl new file mode 100644 index 0000000..88186d2 --- /dev/null +++ b/apps/iot/src/http/container_handler.erl @@ -0,0 +1,239 @@ +%%%------------------------------------------------------------------- +%%% @author licheng5 +%%% @copyright (C) 2020, +%%% @doc +%%% +%%% @end +%%% Created : 26. 4月 2020 3:36 下午 +%%%------------------------------------------------------------------- +-module(container_handler). +-author("licheng5"). +-include("iot.hrl"). + +-define(REQ_TIMEOUT, 10000). + +%% API +-export([handle_request/4]). + +handle_request("GET", "/container/get_all", #{<<"uuid">> := UUID}, _) when is_binary(UUID) -> + %% 检查ConfigJson是否是合法的json字符串 + case iot_host:get_pid(UUID) of + undefined -> + {ok, 200, iot_util:json_error(-1, <<"host not found">>)}; + Pid when is_pid(Pid) -> + case iot_host:get_containers(Pid) of + {ok, Ref} -> + case iot_host:await_reply(Pid, Ref, ?REQ_TIMEOUT) of + ok -> + {ok, 200, request_success_response(<<"ok">>)}; + {ok, Result} -> + {ok, 200, request_success_response(Result)}; + {error, Reason} -> + request_error_http_response(Reason) + end; + {error, Reason} when is_binary(Reason) -> + {ok, 200, iot_util:json_error(-1, Reason)} + end + end; + +%% 下发config.json, 微服务接受后,保存服务配置 +handle_request("POST", "/container/push_config", _, + #{<<"uuid">> := UUID, <<"container_name">> := ContainerName, <<"config">> := Config, <<"timeout">> := Timeout0}) + when is_binary(UUID), is_binary(ContainerName), is_binary(Config), is_integer(Timeout0) -> + + %% 检查ConfigJson是否是合法的json字符串 + true = iot_util:is_json(Config), + case iot_host:get_pid(UUID) of + undefined -> + {ok, 200, iot_util:json_error(-1, <<"host not found">>)}; + Pid when is_pid(Pid) -> + Timeout = Timeout0 * 1000, + case iot_host:config_container(Pid, ContainerName, Config) of + {ok, Ref} -> + case iot_host:await_reply(Pid, Ref, Timeout) of + ok -> + {ok, 200, request_success_response(<<"ok">>)}; + {ok, Result} -> + {ok, 200, request_success_response(Result)}; + {error, Reason} -> + request_error_http_response(Reason) + end; + {error, Reason} when is_binary(Reason) -> + {ok, 200, iot_util:json_error(-1, Reason)} + end + end; + +%% 部署微服务 +handle_request("POST", "/container/deploy", _, #{<<"uuid">> := UUID, <<"task_id">> := TaskId, <<"config">> := Config}) + when is_binary(UUID), is_integer(TaskId), is_map(Config) -> + case iot_host:get_pid(UUID) of + undefined -> + {ok, 200, iot_util:json_error(404, <<"host not found">>)}; + Pid when is_pid(Pid) -> + case iot_container_task_sup:ensure_started(UUID, TaskId) of + {ok, _TaskPid} -> + handle_deploy_container(Pid, UUID, TaskId, Config); + {error, Reason} -> + {ok, 500, iot_util:json_error(500, reason_to_binary(Reason))} + end + end; + +%% 启动服务 +handle_request("POST", "/container/start", _, #{<<"uuid">> := UUID, <<"container_name">> := ContainerName}) when is_binary(UUID), is_binary(ContainerName) -> + case iot_host:get_pid(UUID) of + undefined -> + {ok, 200, iot_util:json_error(404, <<"host not found">>)}; + Pid when is_pid(Pid) -> + case iot_host:start_container(Pid, ContainerName) of + {ok, Ref} -> + case iot_host:await_reply(Pid, Ref, ?REQ_TIMEOUT) of + ok -> + {ok, 200, request_success_response(<<"ok">>)}; + {ok, Result} -> + {ok, 200, request_success_response(Result)}; + {error, Reason} -> + request_error_http_response(Reason) + end; + {error, Reason} when is_binary(Reason) -> + {ok, 200, iot_util:json_error(400, Reason)} + end + end; + +%% 停止服务 +handle_request("POST", "/container/stop", _, #{<<"uuid">> := UUID, <<"container_name">> := ContainerName}) when is_binary(UUID), is_binary(ContainerName) -> + case iot_host:get_pid(UUID) of + undefined -> + {ok, 200, iot_util:json_error(404, <<"host not found">>)}; + Pid when is_pid(Pid) -> + case iot_host:stop_container(Pid, ContainerName) of + {ok, Ref} -> + case iot_host:await_reply(Pid, Ref, ?REQ_TIMEOUT) of + ok -> + {ok, 200, request_success_response(<<"ok">>)}; + {ok, Result} -> + {ok, 200, request_success_response(Result)}; + {error, Reason} -> + request_error_http_response(Reason) + end; + {error, Reason} when is_binary(Reason) -> + {ok, 200, iot_util:json_error(400, Reason)} + end + end; + +handle_request("POST", "/container/kill", _, #{<<"uuid">> := UUID, <<"container_name">> := ContainerName}) when is_binary(UUID), is_binary(ContainerName) -> + case iot_host:get_pid(UUID) of + undefined -> + {ok, 200, iot_util:json_error(404, <<"host not found">>)}; + Pid when is_pid(Pid) -> + case iot_host:kill_container(Pid, ContainerName) of + {ok, Ref} -> + case iot_host:await_reply(Pid, Ref, ?REQ_TIMEOUT) of + ok -> + {ok, 200, request_success_response(<<"ok">>)}; + {ok, Result} -> + {ok, 200, request_success_response(Result)}; + {error, Reason} -> + request_error_http_response(Reason) + end; + {error, Reason} when is_binary(Reason) -> + {ok, 200, iot_util:json_error(400, Reason)} + end + end; + +%% 删除容器 +handle_request("POST", "/container/remove", _, #{<<"uuid">> := UUID, <<"container_name">> := ContainerName}) when is_binary(UUID), is_binary(ContainerName) -> + case iot_host:get_pid(UUID) of + undefined -> + {ok, 200, iot_util:json_error(404, <<"host not found">>)}; + Pid when is_pid(Pid) -> + case iot_host:remove_container(Pid, ContainerName) of + {ok, Ref} -> + case iot_host:await_reply(Pid, Ref, ?REQ_TIMEOUT) of + ok -> + {ok, 200, request_success_response(<<"ok">>)}; + {ok, Result} -> + {ok, 200, request_success_response(Result)}; + {error, Reason} -> + request_error_http_response(Reason) + end; + {error, Reason} when is_binary(Reason) -> + {ok, 200, iot_util:json_error(400, Reason)} + end + end; + +handle_request(_, Path, _, _) -> + Path1 = list_to_binary(Path), + {ok, 200, iot_util:json_error(-1, <<"url: ", Path1/binary, " not found">>)}. + +request_success_response(Result) when is_binary(Result) -> + case decode_json_bytes(Result) of + {ok, Data} -> + iot_util:json_data(Data); + error -> + iot_util:json_data(Result) + end; +request_success_response(Result) -> + iot_util:json_data(Result). + +request_error_response(Code, Reason) when is_integer(Code), is_binary(Reason) -> + case decode_json_bytes(Reason) of + {ok, #{<<"message">> := Message}} when is_binary(Message) -> + iot_util:json_error(Code, Message); + {ok, Message} when is_binary(Message) -> + iot_util:json_error(Code, Message); + _ -> + iot_util:json_error(Code, Reason) + end. + +-spec request_error_http_response(Reason :: term()) -> + {ok, HttpStatus :: 400 | 504, Body :: binary()}. +request_error_http_response(Reason) -> + HttpStatus = request_error_status(Reason), + {ok, HttpStatus, request_error_response(HttpStatus, reason_to_binary(Reason))}. + +-spec request_error_status(Reason :: term()) -> integer(). +request_error_status(timeout) -> + 504; +request_error_status(<<"timeout">>) -> + 504; +request_error_status(_) -> + 400. + +-spec reason_to_binary(term()) -> binary(). +reason_to_binary(Reason) when is_binary(Reason) -> + Reason; +reason_to_binary(timeout) -> + <<"timeout">>; +reason_to_binary(invalid_response) -> + <<"invalid response">>; +reason_to_binary(Reason) -> + unicode:characters_to_binary(io_lib:format("~p", [Reason])). + +decode_json_bytes(Data) when is_binary(Data) -> + case catch json:decode(Data) of + {'EXIT', _} -> + error; + {error, _} -> + error; + Decoded -> + {ok, Decoded} + end. + +-spec handle_deploy_container(pid(), binary(), integer(), map()) -> + {ok, 200 | 400 | 504, binary()}. +handle_deploy_container(Pid, UUID, TaskId, Config) -> + case iot_host:deploy_container(Pid, TaskId, Config) of + {ok, Ref} -> + case iot_host:await_reply(Pid, Ref, ?REQ_TIMEOUT) of + ok -> + {ok, 200, request_success_response(<<"ok">>)}; + {ok, Result} -> + {ok, 200, request_success_response(Result)}; + {error, Reason} -> + ok = iot_container_task_sup:fail(UUID, TaskId, Reason), + request_error_http_response(Reason) + end; + {error, Reason} when is_binary(Reason) -> + ok = iot_container_task_sup:fail(UUID, TaskId, Reason), + {ok, 200, iot_util:json_error(400, Reason)} + end. diff --git a/apps/iot/src/http_handlers/endpoint_handler.erl b/apps/iot/src/http/endpoint_handler.erl similarity index 51% rename from apps/iot/src/http_handlers/endpoint_handler.erl rename to apps/iot/src/http/endpoint_handler.erl index b390b62..bfe6dc9 100644 --- a/apps/iot/src/http_handlers/endpoint_handler.erl +++ b/apps/iot/src/http/endpoint_handler.erl @@ -8,7 +8,7 @@ %%%------------------------------------------------------------------- -module(endpoint_handler). -author("licheng5"). --include("endpoint.hrl"). +-include_lib("endpoint/include/endpoint.hrl"). %% API -export([handle_request/4]). @@ -30,17 +30,17 @@ handle_request("POST", "/endpoint/run_statuses", _, Ids) when is_list(Ids) -> {ok, 200, iot_util:json_data(Statuses)}; handle_request("POST", "/endpoint/start", _, #{<<"id">> := Id}) when is_integer(Id) -> - case iot_api:get_endpoint(Id) of + case iot_api_client:get_endpoint(Id) of undefined -> {ok, 200, iot_util:json_error(404, <<"endpoint not found">>)}; {ok, EndpointInfo} -> case endpoint:endpoint_record(EndpointInfo) of {ok, Endpoint = #endpoint{title = Title}} -> - case endpoint_sup:ensured_endpoint_started(Endpoint) of + case endpoint_adapter_sup:ensured_endpoint_started(Endpoint) of {ok, Pid} when is_pid(Pid) -> {ok, 200, iot_util:json_data(<<"success">>)}; {error, Reason} -> - lager:warning("[endpoint_handler] start endpoint: ~p, get error: ~p", [Title, Reason]), + logger:warning("[endpoint_handler] start endpoint: ~p, get error: ~p", [Title, Reason]), {ok, 200, iot_util:json_error(404, <<"start endpoint error">>)} end; error -> @@ -49,21 +49,21 @@ handle_request("POST", "/endpoint/start", _, #{<<"id">> := Id}) when is_integer( end; handle_request("POST", "/endpoint/stop", _, #{<<"id">> := Id}) when is_integer(Id) -> - case iot_api:get_endpoint(Id) of + case iot_api_client:get_endpoint(Id) of undefined -> {ok, 200, iot_util:json_error(404, <<"endpoint not found">>)}; {ok, _} -> - case endpoint_sup:delete_endpoint(Id) of + case endpoint_adapter_sup:delete_endpoint(Id) of ok -> {ok, 200, iot_util:json_data(<<"success">>)}; {error, Reason} -> - lager:warning("[endpoint_handler] stop endpoint id: ~p, get error: ~p", [Id, Reason]), + logger:warning("[endpoint_handler] stop endpoint id: ~p, get error: ~p", [Id, Reason]), {ok, 200, iot_util:json_error(404, <<"stop endpoint error">>)} end end; handle_request("POST", "/endpoint/restart", _, #{<<"id">> := Id}) when is_integer(Id) -> - case iot_api:get_endpoint(Id) of + case iot_api_client:get_endpoint(Id) of undefined -> {ok, 200, iot_util:json_error(404, <<"endpoint not found">>)}; {ok, EndpointInfo} -> @@ -71,25 +71,25 @@ handle_request("POST", "/endpoint/restart", _, #{<<"id">> := Id}) when is_intege {ok, Endpoint = #endpoint{title = Title}} -> case endpoint:get_pid(Id) of undefined -> - case endpoint_sup:ensured_endpoint_started(Endpoint) of + case endpoint_adapter_sup:ensured_endpoint_started(Endpoint) of {ok, Pid} when is_pid(Pid) -> {ok, 200, iot_util:json_data(<<"success">>)}; {error, Reason} -> - lager:warning("[endpoint_handler] start endpoint: ~p, get error: ~p", [Title, Reason]), + logger:warning("[endpoint_handler] start endpoint: ~p, get error: ~p", [Title, Reason]), {ok, 200, iot_util:json_error(404, <<"restart endpoint error">>)} end; Pid when is_pid(Pid) -> - case endpoint_sup:delete_endpoint(Id) of + case endpoint_adapter_sup:delete_endpoint(Id) of ok -> - case endpoint_sup:ensured_endpoint_started(Endpoint) of + case endpoint_adapter_sup:ensured_endpoint_started(Endpoint) of {ok, Pid0} when is_pid(Pid0) -> {ok, 200, iot_util:json_data(<<"success">>)}; {error, Reason} -> - lager:warning("[endpoint_handler] start endpoint: ~p, get error: ~p", [Title, Reason]), + logger:warning("[endpoint_handler] start endpoint: ~p, get error: ~p", [Title, Reason]), {ok, 200, iot_util:json_error(404, <<"restart endpoint error">>)} end; {error, Reason} -> - lager:warning("[endpoint_handler] start endpoint: ~p, get error: ~p", [Title, Reason]), + logger:warning("[endpoint_handler] start endpoint: ~p, get error: ~p", [Title, Reason]), {ok, 200, iot_util:json_error(404, <<"stop endpoint error">>)} end end; @@ -100,57 +100,23 @@ handle_request("POST", "/endpoint/restart", _, #{<<"id">> := Id}) when is_intege %% http接口的测试 handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"http">>, <<"config">> := #{<<"url">> := Url, <<"pool_size">> := PoolSize}}) when is_integer(PoolSize), PoolSize > 0 -> - Body = <<"">>, - ContentType = "application/json", - case httpc:request(post, {Url, [], ContentType, Body}, [], []) of - {ok, _} -> + case endpoint_tester:test(#http_endpoint{url = Url, pool_size = PoolSize}) of + ok -> {ok, 200, iot_util:json_data(<<"ok">>)}; {error, Reason} -> - lager:debug("[endpint_handler] test http: ~p, error: ~p", [Url, Reason]), + logger:debug("[endpint_handler] test http: ~p, error: ~p", [Url, Reason]), {ok, 200, iot_util:json_error(-1, <<"url failed">>)} end; %% 测试mqtt handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"mqtt">>, <<"config">> := Config}) -> case endpoint:parse_config(<<"mqtt">>, Config) of - {ok, #mqtt_endpoint{host = Host, port = Port, username = Username, password = Password}} -> - %% 用户测试的client - ClientId = "mqtt_client_test:" ++ iot_util:rand_bytes(16), - Opts = [ - {owner, self()}, - {clientid, ClientId}, - {host, binary_to_list(Host)}, - {port, Port}, - {tcp_opts, []}, - {username, binary_to_list(Username)}, - {password, binary_to_list(Password)}, - {keepalive, 86400}, - {auto_ack, true}, - {connect_timeout, 5000}, - {proto_ver, v5}, - {retry_interval, 5000} - ], - - case emqtt:start_link(Opts) of - {ok, ConnPid} -> - lager:debug("[endpint_handler] start connect, options: ~p", [Opts]), - case catch emqtt:connect(ConnPid, 5000) of - {ok, _} -> - lager:debug("[endpint_handler] connect success, pid: ~p", [ConnPid]), - emqtt:stop(ConnPid), - {ok, 200, iot_util:json_data(<<"ok">>)}; - {error, Reason} -> - lager:warning("[endpint_handler] connect get error: ~p", [Reason]), - emqtt:stop(ConnPid), - {ok, 200, iot_util:json_error(-1, <<"connect mqtt server failed">>)}; - Error -> - lager:warning("[endpint_handler] connect get error: ~p", [Error]), - emqtt:stop(ConnPid), - {ok, 200, iot_util:json_error(-1, <<"connect mqtt server failed">>)} - end; - Other -> - lager:warning("[endpint_handler] test connect mqtt with options: ~p, get error: ~p", [Opts, Other]), - {ok, 200, iot_util:json_error(-1, <<"connect mqtt server failed">>)} + {ok, MqttEndpoint = #mqtt_endpoint{}} -> + case endpoint_tester:test(MqttEndpoint) of + ok -> + {ok, 200, iot_util:json_data(<<"ok">>)}; + {error, Reason} -> + {ok, 200, iot_util:json_error(-1, Reason)} end; {error, Errors} -> {ok, 200, iot_util:json_error(-1, Errors)} @@ -159,33 +125,12 @@ handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"mqtt">>, <<"c %% 测试kafka handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"kafka">>, <<"config">> := Config}) -> case endpoint:parse_config(<<"kafka">>, Config) of - {ok, #kafka_endpoint{sasl_config = SaslConfig, bootstrap_servers = BootstrapServers, topic = Topic}} -> - BaseConfig = [ - {reconnect_cool_down_seconds, 5}, - {socket_options, [{keepalive, true}]} - ], - - ClientConfig = case SaslConfig of - {Mechanism, Username, Password} -> - [{sasl, {Mechanism, Username, Password}}|BaseConfig]; - undefined -> - BaseConfig - end, - ClientId = list_to_atom("brod_client_test:" ++ iot_util:rand_bytes(16)), - - case catch brod:start_link_client(BootstrapServers, ClientId, ClientConfig) of - {ok, _ClientPid} -> - case brod:start_producer(ClientId, Topic, _ProducerConfig = []) of - ok -> - ok = brod:stop_client(ClientId), - {ok, 200, iot_util:json_data(<<"ok">>)}; - {error, Reason} -> - lager:debug("[endpint_handler] start_producer: ~p, get error: ~p", [ClientId, Reason]), - {ok, 200, iot_util:json_error(-1, <<"config kafka server failed">>)} - end; - Error -> - lager:debug("[endpint_handler] start_client: ~p, get error: ~p", [ClientId, Error]), - {ok, 200, iot_util:json_error(-1, <<"config kafka server failed">>)} + {ok, KafkaEndpoint = #kafka_endpoint{}} -> + case endpoint_tester:test(KafkaEndpoint) of + ok -> + {ok, 200, iot_util:json_data(<<"ok">>)}; + {error, Reason} -> + {ok, 200, iot_util:json_error(-1, Reason)} end; {error, Errors} -> {ok, 200, iot_util:json_error(-1, Errors)} @@ -195,7 +140,7 @@ handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"kafka">>, <<" handle_request("POST", "/endpoint/publish_metric", _, #{<<"route_key">> := RouteKey, <<"metric">> := Metric0}) when is_binary(RouteKey) -> if is_map(Metric0) orelse is_list(Metric0) -> - Metric = jiffy:encode(Metric0, [force_utf8]), + Metric = iolist_to_binary(json:encode(Metric0)), endpoint_subscription:publish(RouteKey, Metric), {ok, 200, iot_util:json_data(<<"ok">>)}; is_binary(Metric0) -> @@ -207,4 +152,4 @@ handle_request("POST", "/endpoint/publish_metric", _, #{<<"route_key">> := Route handle_request(_, Path, _, _) -> Path1 = list_to_binary(Path), - {ok, 200, iot_util:json_error(-1, <<"url: ", Path1/binary, " not found">>)}. \ No newline at end of file + {ok, 200, iot_util:json_error(-1, <<"url: ", Path1/binary, " not found">>)}. diff --git a/apps/iot/src/http/event_stream_handler.erl b/apps/iot/src/http/event_stream_handler.erl new file mode 100644 index 0000000..993986e --- /dev/null +++ b/apps/iot/src/http/event_stream_handler.erl @@ -0,0 +1,73 @@ +%%%------------------------------------------------------------------- +%%% @author anlicheng +%%% @copyright (C) 2025, +%%% @doc +%%% +%%% @end +%%% Created : 08. 5月 2025 13:00 +%%%------------------------------------------------------------------- +-module(event_stream_handler). +-author("anlicheng"). + +%% API +-export([init/2]). + +init(Req0, Opts) -> + ok = iot_log:set_metadata(), + Method = binary_to_list(cowboy_req:method(Req0)), + Path = binary_to_list(cowboy_req:path(Req0)), + GetParams0 = cowboy_req:parse_qs(Req0), + GetParams = maps:from_list(GetParams0), + + logger:debug("method: ~p, path: ~p, get: ~p", [Method, Path, GetParams]), + case parse_stream_params(GetParams) of + {ok, UUID, TaskId} -> + Req1 = cowboy_req:stream_reply(200, #{ + <<"Content-Type">> => <<"text/event-stream">>, + <<"Cache-Control">> => <<"no-cache">>, + <<"Connection">> => <<"keep-alive">> + }, Req0), + + ok = iot_container_task:subscribe(UUID, TaskId, self()), + receiver_events(TaskId, Req1), + + {ok, Req1, Opts}; + {error, Reason} -> + Req1 = cowboy_req:reply(400, #{ + <<"Content-Type">> => <<"application/json">> + }, iot_util:json_error(400, Reason), Req0), + + {ok, Req1, Opts} + end. + +receiver_events(TaskId, Req) -> + receive + {stream_data, TaskId, Type, Stream} -> + Data = iolist_to_binary(json:encode(#{<<"type">> => Type, <<"stream">> => Stream})), + Body = iolist_to_binary([<<"event: message\n">>, <<"data: ", Data/binary, "\n">>, <<"\n">>]), + ok = cowboy_req:stream_body(Body, nofin, Req), + + receiver_events(TaskId, Req); + {stream_close, TaskId, Reason} -> + CloseFrame = iolist_to_binary([<<"event: close\n">>, <<"data: ", Reason/binary, "\n">>, <<"\n">>]), + ok = cowboy_req:stream_body(CloseFrame, fin, Req) + after 30000 -> + ok = cowboy_req:stream_body(<<": heartbeat\n\n">>, nofin, Req), + receiver_events(TaskId, Req) + end. + +-spec parse_stream_params(map()) -> {ok, binary(), integer()} | {error, binary()}. +parse_stream_params(#{<<"uuid">> := UUID, <<"task_id">> := TaskId0}) when is_binary(UUID), is_binary(TaskId0) -> + try binary_to_integer(TaskId0) of + TaskId when TaskId >= 0 -> + {ok, UUID, TaskId}; + _ -> + {error, <<"task_id must be non-negative">>} + catch + error:badarg -> + {error, <<"task_id must be integer">>} + end; +parse_stream_params(#{<<"task_id">> := _TaskId0}) -> + {error, <<"uuid required">>}; +parse_stream_params(_) -> + {error, <<"uuid and task_id required">>}. diff --git a/apps/iot/src/http_handlers/host_handler.erl b/apps/iot/src/http/host_handler.erl similarity index 66% rename from apps/iot/src/http_handlers/host_handler.erl rename to apps/iot/src/http/host_handler.erl index 5c67102..42a6a6c 100644 --- a/apps/iot/src/http_handlers/host_handler.erl +++ b/apps/iot/src/http/host_handler.erl @@ -18,7 +18,7 @@ %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% handle_request("GET", "/host/metric", #{<<"uuid">> := UUID}, _) -> - lager:debug("[host_handler] get host metric uuid is: ~p", [UUID]), + logger:debug("[host_handler] get host metric uuid is: ~p", [UUID]), case iot_host:get_pid(UUID) of undefined -> {ok, 200, iot_util:json_error(404, <<"host not found">>)}; @@ -56,26 +56,22 @@ handle_request("POST", "/host/delete", _, #{<<"uuid">> := UUID}) when is_binary( handle_request("POST", "/host/activate", _, #{<<"uuid">> := UUID, <<"auth">> := true}) when is_binary(UUID) -> case iot_host_sup:ensured_host_started(UUID) of {error, Reason} -> - lager:debug("[host_handler] activate host_id: ~p, failed with reason: ~p", [UUID, Reason]), + logger:debug("[host_handler] activate host_id: ~p, failed with reason: ~p", [UUID, Reason]), {ok, 200, iot_util:json_error(400, <<"host not found">>)}; {ok, Pid} when is_pid(Pid) -> - lager:debug("[host_handler] activate host_id: ~p, start", [UUID]), - ok = iot_host:activate(Pid, true), - - {ok, 200, iot_util:json_data(<<"success">>)} + logger:debug("[host_handler] activate host_id: ~p, start", [UUID]), + auth_response(iot_host:activate(Pid, true)) end; %% 处理主机的授权的 取消激活 handle_request("POST", "/host/activate", _, #{<<"uuid">> := UUID, <<"auth">> := false}) when is_binary(UUID) -> case iot_host_sup:ensured_host_started(UUID) of {error, Reason} -> - lager:debug("[host_handler] activate host_id: ~p, failed with reason: ~p", [UUID, Reason]), + logger:debug("[host_handler] activate host_id: ~p, failed with reason: ~p", [UUID, Reason]), {ok, 200, iot_util:json_error(400, <<"host not found">>)}; {ok, Pid} when is_pid(Pid) -> - lager:debug("[host_handler] activate host_id: ~p, start", [UUID]), - ok = iot_host:activate(Pid, false), - - {ok, 200, iot_util:json_data(<<"success">>)} + logger:debug("[host_handler] activate host_id: ~p, start", [UUID]), + auth_response(iot_host:activate(Pid, false)) end; %% 主机事件发布 @@ -85,11 +81,15 @@ handle_request("POST", "/host/pub", _, #{<<"uuid">> := UUID, <<"topic">> := Topi Qos = case Qos0 > 0 of true -> 1; false -> 0 end, case iot_host_sup:ensured_host_started(UUID) of {error, Reason} -> - lager:debug("[host_handler] pub host_id: ~p, topic: ~p, failed with reason: ~p", [UUID, Topic, Reason]), + logger:debug("[host_handler] pub host_id: ~p, topic: ~p, failed with reason: ~p", [UUID, Topic, Reason]), {ok, 200, iot_util:json_error(400, <<"host not found">>)}; {ok, Pid} when is_pid(Pid) -> - ok = iot_host:pub(Pid, Topic, Qos, Content), - {ok, 200, iot_util:json_data(<<"success">>)} + case iot_host:pub(Pid, Topic, Qos, Content) of + ok -> + {ok, 200, iot_util:json_data(<<"success">>)}; + {error, Reason} -> + {ok, 200, iot_util:json_error(400, Reason)} + end end; handle_request(_, Path, _, _) -> @@ -98,4 +98,29 @@ handle_request(_, Path, _, _) -> %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %% helper methods -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% \ No newline at end of file +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +auth_response(ok) -> + {ok, 200, iot_util:json_data(<<"success">>)}; +auth_response({error, Reason}) -> + {ok, auth_error_status(Reason), iot_util:json_error(auth_error_status(Reason), reason_to_binary(Reason))}. + +auth_error_status(timeout) -> + 504; +auth_error_status(<<"timeout">>) -> + 504; +auth_error_status(_) -> + 400. + +reason_to_binary(Reason) when is_binary(Reason) -> + Reason; +reason_to_binary(timeout) -> + <<"timeout">>; +reason_to_binary(invalid_response) -> + <<"invalid response">>; +reason_to_binary({failed, Reason}) -> + reason_to_binary(Reason); +reason_to_binary({channel_closed, Reason}) -> + <<"channel closed: ", (reason_to_binary(Reason))/binary>>; +reason_to_binary(Reason) -> + unicode:characters_to_binary(io_lib:format("~p", [Reason])). diff --git a/apps/iot/src/http_handlers/http_protocol.erl b/apps/iot/src/http/http_protocol.erl similarity index 53% rename from apps/iot/src/http_handlers/http_protocol.erl rename to apps/iot/src/http/http_protocol.erl index f8cb7b9..d0729c8 100644 --- a/apps/iot/src/http_handlers/http_protocol.erl +++ b/apps/iot/src/http/http_protocol.erl @@ -12,16 +12,28 @@ %% API -export([init/2]). +-define(MAX_BODY_BYTES, 10 * 1024 * 1024). + init(Req0, Opts = [Mod|_]) -> + ok = iot_log:set_metadata(), Method = binary_to_list(cowboy_req:method(Req0)), Path = binary_to_list(cowboy_req:path(Req0)), GetParams0 = cowboy_req:parse_qs(Req0), GetParams = maps:from_list(GetParams0), - {ok, PostParams, Req1} = parse_body(Req0), + case parse_body(Req0) of + {ok, PostParams, Req1} -> + handle_request(Mod, Method, Path, GetParams, PostParams, Req1, Opts); + {error, StatusCode, Resp, Req1} -> + Req2 = cowboy_req:reply(StatusCode, #{ + <<"Content-Type">> => <<"application/json;charset=utf-8">> + }, Resp, Req1), + {ok, Req2, Opts} + end. +handle_request(Mod, Method, Path, GetParams, PostParams, Req1, Opts) -> try Mod:handle_request(Method, Path, GetParams, PostParams) of {ok, StatusCode, Resp} -> - lager:debug("[http_protocol] request path: ~p, get_params: ~p, post_params: ~p, response: ~ts", + logger:debug("[http_protocol] request path: ~p, get_params: ~p, post_params: ~p, response: ~ts", [Path, GetParams, PostParams, Resp]), AcceptEncoding = cowboy_req:header(<<"accept-encoding">>, Req1, <<>>), Req2 = case iolist_size(Resp) >= 1024 andalso supported_gzip(AcceptEncoding) of @@ -45,7 +57,7 @@ init(Req0, Opts = [Mod|_]) -> }, ErrResp, Req1), {ok, Req2, Opts}; _:Error:Stack -> - lager:warning("[http_handler] get error: ~p, stack: ~p", [Error, Stack]), + logger:warning("[http_handler] get error: ~p, stack: ~p", [Error, Stack]), Req2 = cowboy_req:reply(500, #{ <<"Content-Type">> => <<"text/html;charset=utf-8">> }, <<"Internal Server Error">>, Req1), @@ -60,17 +72,18 @@ parse_body(Req0) -> ContentType = cowboy_req:header(<<"content-type">>, Req0), case ContentType of <<"application/json", _/binary>> -> - {ok, Body, Req1} = read_body(Req0), - case Body =/= <<"">> of - true -> - {ok, catch jiffy:decode(Body, [return_maps]), Req1}; - false -> - {ok, #{}, Req1} + case read_body(Req0) of + {ok, Body, Req1} -> + decode_json_body(Body, Req1); + {error, payload_too_large, Req1} -> + {error, 413, iot_util:json_error(413, <<"payload too large">>), Req1} end; <<"application/x-www-form-urlencoded">> -> - {ok, PostParams0, Req1} = cowboy_req:read_urlencoded_body(Req0), - PostParams = maps:from_list(PostParams0), - {ok, PostParams, Req1}; + case cowboy_req:read_urlencoded_body(Req0) of + {ok, PostParams0, Req1} -> + PostParams = maps:from_list(PostParams0), + {ok, PostParams, Req1} + end; _ -> {ok, #{}, Req0} end. @@ -81,7 +94,33 @@ read_body(Req) -> read_body(Req, AccData) -> case cowboy_req:read_body(Req) of {ok, Data, Req1} -> - {ok, <>, Req1}; + NAccData = <>, + case byte_size(NAccData) =< ?MAX_BODY_BYTES of + true -> + {ok, NAccData, Req1}; + false -> + {error, payload_too_large, Req1} + end; {more, Data, Req1} -> - read_body(Req1, <>) - end. \ No newline at end of file + NAccData = <>, + case byte_size(NAccData) =< ?MAX_BODY_BYTES of + true -> + read_body(Req1, NAccData); + false -> + {error, payload_too_large, Req1} + end + end. + +decode_json_body(<<"">>, Req) -> + {ok, #{}, Req}; +decode_json_body(Body, Req) -> + case catch json:decode(Body) of + {'EXIT', _} -> + {error, 400, iot_util:json_error(400, <<"invalid json">>), Req}; + {error, _} -> + {error, 400, iot_util:json_error(400, <<"invalid json">>), Req}; + Decoded when is_map(Decoded); is_list(Decoded) -> + {ok, Decoded, Req}; + _ -> + {error, 400, iot_util:json_error(400, <<"invalid json">>), Req} + end. diff --git a/apps/iot/src/http_handlers/container_handler.erl b/apps/iot/src/http_handlers/container_handler.erl deleted file mode 100644 index 13e3e79..0000000 --- a/apps/iot/src/http_handlers/container_handler.erl +++ /dev/null @@ -1,309 +0,0 @@ -%%%------------------------------------------------------------------- -%%% @author licheng5 -%%% @copyright (C) 2020, -%%% @doc -%%% -%%% @end -%%% Created : 26. 4月 2020 3:36 下午 -%%%------------------------------------------------------------------- --module(container_handler). --author("licheng5"). --include("iot.hrl"). - --define(REQ_TIMEOUT, 10000). - -%% API --export([handle_request/4]). - -handle_request("GET", "/container/get_all", #{<<"uuid">> := UUID}, _) when is_binary(UUID) -> - %% 检查ConfigJson是否是合法的json字符串 - case iot_host:get_pid(UUID) of - undefined -> - {ok, 200, iot_util:json_error(-1, <<"host not found">>)}; - Pid when is_pid(Pid) -> - case iot_host:get_containers(Pid) of - {ok, Ref} -> - case iot_host:await_reply(Ref, ?REQ_TIMEOUT) of - {ok, Result} -> - {ok, 200, iot_util:json_data(Result)}; - {error, Reason} -> - {ok, 200, iot_util:json_error(-1, Reason)} - end; - {error, Reason} when is_binary(Reason) -> - {ok, 200, iot_util:json_error(-1, Reason)} - end - end; - -%% 下发config.json, 微服务接受后,保存服务配置 -handle_request("POST", "/container/push_config", _, - #{<<"uuid">> := UUID, <<"container_name">> := ContainerName, <<"config">> := Config, <<"timeout">> := Timeout0}) - when is_binary(UUID), is_binary(ContainerName), is_binary(Config), is_integer(Timeout0) -> - - %% 检查ConfigJson是否是合法的json字符串 - true = iot_util:is_json(Config), - case iot_host:get_pid(UUID) of - undefined -> - {ok, 200, iot_util:json_error(-1, <<"host not found">>)}; - Pid when is_pid(Pid) -> - Timeout = Timeout0 * 1000, - case iot_host:config_container(Pid, ContainerName, Config) of - {ok, Ref} -> - case iot_host:await_reply(Ref, Timeout) of - {ok, Result} -> - {ok, 200, iot_util:json_data(Result)}; - {error, Reason} -> - {ok, 200, iot_util:json_error(-1, Reason)} - end; - {error, Reason} when is_binary(Reason) -> - {ok, 200, iot_util:json_error(-1, Reason)} - end - end; - -%% 部署微服务 -handle_request("POST", "/container/deploy", _, #{<<"uuid">> := UUID, <<"task_id">> := TaskId, <<"config">> := Config}) - when is_binary(UUID), is_integer(TaskId), is_map(Config) -> - - case validate_config(Config) of - ok -> - case iot_host:get_pid(UUID) of - undefined -> - {ok, 200, iot_util:json_error(404, <<"host not found">>)}; - Pid when is_pid(Pid) -> - case iot_host:deploy_container(Pid, TaskId, Config) of - {ok, Ref} -> - case iot_host:await_reply(Ref, ?REQ_TIMEOUT) of - {ok, Result} -> - {ok, 200, iot_util:json_data(Result)}; - {error, Reason} -> - {ok, 200, iot_util:json_error(400, Reason)} - end; - {error, Reason} when is_binary(Reason) -> - {ok, 200, iot_util:json_error(400, Reason)} - end - end; - {error, Errors} -> - Reason = iolist_to_binary(lists:join(<<"|||">>, Errors)), - {ok, 200, iot_util:json_error(400, Reason)} - end; - -%% 启动服务 -handle_request("POST", "/container/start", _, #{<<"uuid">> := UUID, <<"container_name">> := ContainerName}) when is_binary(UUID), is_binary(ContainerName) -> - case iot_host:get_pid(UUID) of - undefined -> - {ok, 200, iot_util:json_error(404, <<"host not found">>)}; - Pid when is_pid(Pid) -> - case iot_host:start_container(Pid, ContainerName) of - {ok, Ref} -> - case iot_host:await_reply(Ref, ?REQ_TIMEOUT) of - {ok, Result} -> - {ok, 200, iot_util:json_data(Result)}; - {error, Reason} -> - {ok, 200, iot_util:json_error(400, Reason)} - end; - {error, Reason} when is_binary(Reason) -> - {ok, 200, iot_util:json_error(400, Reason)} - end - end; - -%% 停止服务 -handle_request("POST", "/container/stop", _, #{<<"uuid">> := UUID, <<"container_name">> := ContainerName}) when is_binary(UUID), is_binary(ContainerName) -> - case iot_host:get_pid(UUID) of - undefined -> - {ok, 200, iot_util:json_error(404, <<"host not found">>)}; - Pid when is_pid(Pid) -> - case iot_host:stop_container(Pid, ContainerName) of - {ok, Ref} -> - case iot_host:await_reply(Ref, ?REQ_TIMEOUT) of - {ok, Result} -> - {ok, 200, iot_util:json_data(Result)}; - {error, Reason} -> - {ok, 200, iot_util:json_error(400, Reason)} - end; - {error, Reason} when is_binary(Reason) -> - {ok, 200, iot_util:json_error(400, Reason)} - end - end; - -handle_request("POST", "/container/kill", _, #{<<"uuid">> := UUID, <<"container_name">> := ContainerName}) when is_binary(UUID), is_binary(ContainerName) -> - case iot_host:get_pid(UUID) of - undefined -> - {ok, 200, iot_util:json_error(404, <<"host not found">>)}; - Pid when is_pid(Pid) -> - case iot_host:kill_container(Pid, ContainerName) of - {ok, Ref} -> - case iot_host:await_reply(Ref, ?REQ_TIMEOUT) of - {ok, Result} -> - {ok, 200, iot_util:json_data(Result)}; - {error, Reason} -> - {ok, 200, iot_util:json_error(400, Reason)} - end; - {error, Reason} when is_binary(Reason) -> - {ok, 200, iot_util:json_error(400, Reason)} - end - end; - -%% 删除容器 -handle_request("POST", "/container/remove", _, #{<<"uuid">> := UUID, <<"container_name">> := ContainerName}) when is_binary(UUID), is_binary(ContainerName) -> - case iot_host:get_pid(UUID) of - undefined -> - {ok, 200, iot_util:json_error(404, <<"host not found">>)}; - Pid when is_pid(Pid) -> - case iot_host:remove_container(Pid, ContainerName) of - {ok, Ref} -> - case iot_host:await_reply(Ref, ?REQ_TIMEOUT) of - {ok, Result} -> - {ok, 200, iot_util:json_data(Result)}; - {error, Reason} -> - {ok, 200, iot_util:json_error(400, Reason)} - end; - {error, Reason} when is_binary(Reason) -> - {ok, 200, iot_util:json_error(400, Reason)} - end - end; - -handle_request(_, Path, _, _) -> - Path1 = list_to_binary(Path), - {ok, 200, iot_util:json_error(-1, <<"url: ", Path1/binary, " not found">>)}. - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%% helper methods -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -validate_config(Config) when is_map(Config) -> - %% 必选参数 - Required = [ - {<<"image">>, binary}, - {<<"container_name">>, binary}, - {<<"command">>, {list, binary}}, - {<<"restart">>, binary} - ], - - %% 可选参数(附带默认值) - Optional = [ - {<<"privileged">>, boolean}, - {<<"envs">>, {list, binary}}, - {<<"ports">>, {list, binary}}, - {<<"expose">>, {list, binary}}, - {<<"volumes">>, {list, binary}}, - {<<"networks">>, {list, binary}}, - {<<"labels">>, {map, {binary, binary}}}, - {<<"user">>, binary}, - {<<"working_dir">>, binary}, - {<<"hostname">>, binary}, - {<<"cap_add">>, {list, binary}}, - {<<"cap_drop">>, {list, binary}}, - {<<"devices">>, {list, binary}}, - {<<"mem_limit">>, binary}, - {<<"mem_reservation">>, binary}, - {<<"cpu_shares">>, integer}, - {<<"cpus">>, number}, - {<<"ulimits">>, {map, {binary, binary}}}, - {<<"sysctls">>, {map, {binary, binary}}}, - {<<"tmpfs">>, {list, binary}}, - {<<"extra_hosts">>, {list, binary}}, - {<<"healthcheck">>, {map, {binary, any}}} - ], - - Errors1 = check_required(Config, Required), - Errors2 = check_optional(Config, Optional), - - Errors = Errors1 ++ Errors2, - case Errors of - [] -> - ok; - _ -> - {error, lists:map(fun erlang:iolist_to_binary/1, Errors)} - end. - -%%------------------------------------------------------------------------------ -%% 校验必选项 -%%------------------------------------------------------------------------------ -check_required(Config, Fields) -> - lists:foldl( - fun({Key, Type}, ErrAcc) -> - case maps:get(Key, Config, undefined) of - undefined -> - [io_lib:format("miss requied parameter: ~p", [Key]) | ErrAcc]; - Value -> - case check_type(Value, Type) of - true -> - ErrAcc; - false -> - [io_lib:format("required parameter: ~p, type must be: ~p", [Key, type_name(Type)]) | ErrAcc] - end - end - end, - [], Fields). - -%%------------------------------------------------------------------------------ -%% 校验可选项(支持默认值填充) -%%------------------------------------------------------------------------------ -check_optional(Config, Fields) -> - lists:foldl( - fun({Key, Type}, ErrAcc) -> - case maps:get(Key, Config, undefined) of - undefined -> - ErrAcc; - Value -> - case check_type(Value, Type) of - true -> - ErrAcc; - false -> - [io_lib:format("optional parameter: ~p, type must be: ~p", [Key, type_name(Type)]) | ErrAcc] - end - end - end, - [], Fields). - -%%------------------------------------------------------------------------------ -%% 类型检查辅助函数(binary版) -%%------------------------------------------------------------------------------ --spec type_name(tuple() | atom()) -> binary(). -type_name(binary) -> - <<"string">>; -type_name(integer) -> - <<"integer">>; -type_name(number) -> - <<"number">>; -type_name(list) -> - <<"list">>; -type_name({list, binary}) -> - <<"list of string">>; -type_name({list, number}) -> - <<"list of number">>; -type_name({list, integer}) -> - <<"list of integer">>; -type_name(map) -> - <<"map">>; -type_name({map, {binary, binary}}) -> - <<"map of string:string">>; -type_name({map, {binary, any}}) -> - <<"map of string:any">>; -type_name(boolean) -> - <<"boolean">>. - --spec check_type(Value :: any(), any()) -> boolean(). -check_type(Value, binary) -> - is_binary(Value); -check_type(Value, integer) -> - is_integer(Value); -check_type(Value, number) -> - is_number(Value); -check_type(Value, list) when is_list(Value) -> - true; -check_type(Value, {list, binary}) when is_list(Value) -> - lists:all(fun(E) -> is_binary(E) end, Value); -check_type(Value, {list, number}) when is_list(Value) -> - lists:all(fun(E) -> is_number(E) end, Value); -check_type(Value, {list, integer}) when is_list(Value) -> - lists:all(fun(E) -> is_integer(E) end, Value); -check_type(Value, map) when is_map(Value) -> - true; -check_type(Value, {map, {binary, binary}}) when is_map(Value) -> - lists:all(fun({K, V}) -> is_binary(K) andalso is_binary(V) end, maps:to_list(Value)); -check_type(Value, {map, {binary, any}}) when is_map(Value) -> - lists:all(fun({K, _}) -> is_binary(K) end, maps:to_list(Value)); -check_type(Value, boolean) -> - is_boolean(Value); -check_type(_, _) -> - false. \ No newline at end of file diff --git a/apps/iot/src/http_handlers/event_stream_handler.erl b/apps/iot/src/http_handlers/event_stream_handler.erl deleted file mode 100644 index 048d3ad..0000000 --- a/apps/iot/src/http_handlers/event_stream_handler.erl +++ /dev/null @@ -1,47 +0,0 @@ -%%%------------------------------------------------------------------- -%%% @author anlicheng -%%% @copyright (C) 2025, -%%% @doc -%%% -%%% @end -%%% Created : 08. 5月 2025 13:00 -%%%------------------------------------------------------------------- --module(event_stream_handler). --author("anlicheng"). - -%% API --export([init/2]). - -init(Req0, Opts) -> - Method = binary_to_list(cowboy_req:method(Req0)), - Path = binary_to_list(cowboy_req:path(Req0)), - GetParams0 = cowboy_req:parse_qs(Req0), - GetParams = maps:from_list(GetParams0), - - #{<<"task_id">> := TaskId0} = GetParams, - TaskId = binary_to_integer(TaskId0), - - lager:debug("method: ~p, path: ~p, get: ~p", [Method, Path, GetParams]), - Req1 = cowboy_req:stream_reply(200, #{ - <<"Content-Type">> => <<"text/event-stream">>, - <<"Cache-Control">> => <<"no-cache">>, - <<"Connection">> => <<"keep-alive">> - }, Req0), - - ok = iot_event_stream_observer:add_listener(self(), TaskId), - receiver_events(TaskId, Req1), - - {ok, Req1, Opts}. - -receiver_events(TaskId, Req) -> - receive - {stream_data, TaskId, Type, Stream} -> - Data = jiffy:encode(#{<<"type">> => Type, <<"stream">> => Stream}, [force_utf8]), - Body = iolist_to_binary([<<"event: message\n">>, <<"data: ", Data/binary, "\n">>, <<"\n">>]), - ok = cowboy_req:stream_body(Body, nofin, Req), - - receiver_events(TaskId, Req); - {stream_close, TaskId, Reason} -> - CloseFrame = iolist_to_binary([<<"event: close\n">>, <<"data: ", Reason/binary, "\n">>, <<"\n">>]), - ok = cowboy_req:stream_body(CloseFrame, fin, Req) - end. \ No newline at end of file diff --git a/apps/iot/src/iot.app.src b/apps/iot/src/iot.app.src index 09b7e59..f99b31a 100644 --- a/apps/iot/src/iot.app.src +++ b/apps/iot/src/iot.app.src @@ -6,17 +6,14 @@ {applications, [ sync, + endpoint, emqtt, eredis, ranch, cowboy, - lager, - jiffy, brod, - parse_trans, hackney, poolboy, - mysql, gproc, % gpb, mnesia, diff --git a/apps/iot/src/iot_app.erl b/apps/iot/src/iot_app.erl index 02c7f93..b361baa 100644 --- a/apps/iot/src/iot_app.erl +++ b/apps/iot/src/iot_app.erl @@ -9,34 +9,41 @@ -export([start/2, stop/1]). start(_StartType, _StartArgs) -> + ok = iot_log:set_metadata(), io:setopts([{encoding, unicode}]), %% 加速内存的回收 erlang:system_flag(fullsweep_after, 16), - %% 启动mnesia数据库 - start_mnesia(), - %% 启动http服务 + iot_mnesia:safe_start(), + + %% 启动http服务。当前 supervisor 初始化依赖本机 simulator API,因此保留此顺序。 start_http_server(), - %% 启动tcp服务 - start_tcp_server(), + %% 启动endpoints的相关依赖 + start_endpoints(), + + %% 启动ssl服务 + start_ssl_server(), iot_sup:start_link(). stop(_State) -> + stop_started_services(), ok. %% internal functions -%% 启动内存数据库 -start_mnesia() -> - ok = ensure_mnesia_schema(), - %% 启动数据库 - ok = mnesia:start(), - Tables = mnesia:system_info(tables), - lager:debug("[iot_app] tables: ~p", [Tables]), - %% 创建数据库表 - ok. +start_endpoints() -> + EndpointInfos = iot_api_client:get_all_endpoints(), + Endpoints = lists:filtermap(fun(EndpointInfo) -> + case endpoint:endpoint_record(EndpointInfo) of + error -> + false; + {ok, Endpoint} -> + {true, Endpoint} + end + end, EndpointInfos), + ok = endpoint_adapter_sup:start_endpoints(Endpoints). start_http_server() -> {ok, Props} = application:get_env(iot, http_server), @@ -50,6 +57,7 @@ start_http_server() -> {"/host/[...]", http_protocol, [host_handler]}, {"/container/[...]", http_protocol, [container_handler]}, {"/endpoint/[...]", http_protocol, [endpoint_handler]}, + {"/simulator/[...]", http_protocol, [simulator_api_handler]}, {"/event_stream", event_stream_handler, []} ]} ]), @@ -65,41 +73,36 @@ start_http_server() -> }, {ok, Pid} = cowboy:start_clear(http_listener, TransOpts, #{env => #{dispatch => Dispatcher}}), - lager:debug("[http_server] the http server start at: ~p, pid is: ~p", [Port, Pid]). + logger:debug("[http_server] the http server start at: ~p, pid is: ~p", [Port, Pid]). -%% 启动tcp服务 -start_tcp_server() -> - {ok, Props} = application:get_env(iot, tcp_server), +%% 启动ssl服务 +start_ssl_server() -> + {ok, Props} = application:get_env(iot, ssl_server), Acceptors = proplists:get_value(acceptors, Props, 50), MaxConnections = proplists:get_value(max_connections, Props, 10240), Backlog = proplists:get_value(backlog, Props, 1024), Port = proplists:get_value(port, Props), + PrivDir = code:priv_dir(iot), + CertFile = filename:join([PrivDir, "ssl", "server.crt"]), + KeyFile = filename:join([PrivDir, "ssl", "server.key"]), TransOpts = #{ max_connections => MaxConnections, num_acceptors => Acceptors, shutdown => brutal_kill, socket_opts => [ - {nodelay, false}, + {nodelay, true}, {backlog, Backlog}, - {port, Port} + {port, Port}, + {certfile, CertFile}, + {keyfile, KeyFile} ] }, - {ok, _} = ranch:start_listener(tcp_server, ranch_tcp, TransOpts, tcp_channel, []), - lager:debug("[iot_app] the tcp server start at: ~p", [Port]). + {ok, _} = ranch:start_listener(ssl_server, ranch_ssl, TransOpts, ssl_channel, []), + logger:debug("[iot_app] the ssl server start at: ~p", [Port]). --spec ensure_mnesia_schema() -> any(). -ensure_mnesia_schema() -> - case mnesia:system_info(use_dir) of - true -> - ok; - false -> - mnesia:stop(), - case mnesia:create_schema([node()]) of - ok -> ok; - {error, {_, {already_exists, _}}} -> ok; - Error -> - lager:debug("[iot_app] create mnesia schema failed with error: ~p", [Error]), - throw({init_schema, Error}) - end - end. \ No newline at end of file +stop_started_services() -> + _ = cowboy:stop_listener(http_listener), + _ = ranch:stop_listener(ssl_server), + _ = iot_mnesia:stop(), + ok. diff --git a/apps/iot/src/iot_auth.erl b/apps/iot/src/iot_auth.erl deleted file mode 100644 index b0b6305..0000000 --- a/apps/iot/src/iot_auth.erl +++ /dev/null @@ -1,33 +0,0 @@ -%%%------------------------------------------------------------------- -%%% @author aresei -%%% @copyright (C) 2023, -%%% @doc -%%% 授权校验模块 -%%% @end -%%% Created : 27. 6月 2023 09:48 -%%%------------------------------------------------------------------- --module(iot_auth). --author("aresei"). - -%% API --export([check/5]). - -%% 检测token是否是合法值 --spec check(Username :: binary(), Token :: binary(), UUID :: binary(), Salt :: binary(), Timestamp :: integer()) -> boolean(). -check(Username, Token, UUID, Salt, Timestamp) when is_binary(Username), is_binary(Token), is_binary(UUID), is_binary(Salt), is_integer(Timestamp) -> - true; -check(Username, Token, UUID, Salt, Timestamp) when is_binary(Username), is_binary(Token), is_binary(UUID), is_binary(Salt), is_integer(Timestamp) -> - BinTimestamp = integer_to_binary(Timestamp), - %% 1分钟内有效 - case iot_util:current_time() - Timestamp =< 60 of - true -> - {ok, PreTokens} = application:get_env(iot, pre_tokens), - case proplists:get_value(Username, PreTokens) of - undefined -> - false; - PreToken when is_binary(PreToken) -> - iot_util:md5(<>) =:= Token - end; - false -> - false - end. diff --git a/apps/iot/src/iot_host.erl b/apps/iot/src/iot_host.erl deleted file mode 100644 index ebd6f14..0000000 --- a/apps/iot/src/iot_host.erl +++ /dev/null @@ -1,390 +0,0 @@ -%%%------------------------------------------------------------------- -%%% @author -%%% @copyright (C) 2023, -%%% @doc -%%% -%%% @end -%%% Created : 22. 9月 2023 16:38 -%%%------------------------------------------------------------------- --module(iot_host). --author("aresei"). --include("iot.hrl"). --include("message.hrl"). - --behaviour(gen_statem). - -%% 心跳包检测时间间隔, 15分钟检测一次 --define(HEARTBEAT_INTERVAL, 900 * 1000). - -%% 状态 --define(STATE_DENIED, denied). --define(STATE_ACTIVATED, activated). - -%% API --export([start_link/2, get_name/1, get_alias_name/1, get_pid/1, handle/2, activate/2]). --export([get_metric/1, get_status/1, kill/1]). -%% 通讯相关 --export([pub/4, attach_channel/2, command/3]). --export([deploy_container/3, start_container/2, stop_container/2, remove_container/2, kill_container/2, config_container/3, get_containers/1, await_reply/2]). --export([heartbeat/1]). - -%% gen_statem callbacks --export([init/1, handle_event/4, terminate/3, code_change/4, callback_mode/0]). - --record(state, { - host_id :: integer(), - %% 从数据库里面读取到的数据 - uuid :: binary(), - has_session = false :: boolean(), - %% 心跳计数器 - heartbeat_counter = 0 :: integer(), - %% websocket相关 - channel_pid :: undefined | pid(), - %% 主机的相关信息 - metrics = #{} :: map() -}). - -%%%=================================================================== -%%% API -%%%=================================================================== --spec get_pid(UUID :: binary()) -> undefined | pid(). -get_pid(UUID) when is_binary(UUID) -> - Name = get_name(UUID), - whereis(Name). - --spec get_name(UUID :: binary()) -> atom(). -get_name(UUID) when is_binary(UUID) -> - binary_to_atom(<<"iot_host:", UUID/binary>>). - --spec get_alias_name(HostId :: integer()) -> atom(). -get_alias_name(HostId0) when is_integer(HostId0) -> - HostId = integer_to_binary(HostId0), - binary_to_atom(<<"iot_host_id:", HostId/binary>>). - --spec kill(UUID :: binary()) -> no_return(). -kill(UUID) when is_binary(UUID) -> - case whereis(get_name(UUID)) of - undefined -> - ok; - Pid -> - exit(Pid, kill) - end. - -%% 处理消息 --spec handle(Pid :: pid(), Packet :: {atom(), any()}) -> no_return(). -handle(Pid, Packet) when is_pid(Pid) -> - gen_statem:cast(Pid, {handle, Packet}). - --spec get_status(Pid :: pid()) -> {ok, Status :: map()}. -get_status(Pid) when is_pid(Pid) -> - gen_statem:call(Pid, get_status). - -%% 激活主机, true 表示激活; false表示关闭激活 --spec activate(Pid :: pid(), Auth :: boolean()) -> ok. -activate(Pid, Auth) when is_pid(Pid), is_boolean(Auth) -> - gen_statem:call(Pid, {activate, Auth}). - --spec get_metric(Pid :: pid()) -> {ok, MetricInfo :: map()}. -get_metric(Pid) when is_pid(Pid) -> - gen_statem:call(Pid, get_metric). - --spec attach_channel(pid(), pid()) -> ok | {error, Reason :: binary()} | {denied, Reason :: binary()}. -attach_channel(Pid, ChannelPid) when is_pid(Pid), is_pid(ChannelPid) -> - gen_statem:call(Pid, {attach_channel, ChannelPid}). - --spec get_containers(Pid :: pid()) -> {ok, Ref :: reference()} | {error, Reason :: any()}. -get_containers(Pid) when is_pid(Pid) -> - Request = #jsonrpc_request{method = <<"get_containers">>, params = #{}}, - EncConfigBin = message_codec:encode(?MESSAGE_JSONRPC_REQUEST, Request), - gen_statem:call(Pid, {jsonrpc_call, self(), EncConfigBin}). - --spec config_container(Pid :: pid(), ContainerName :: binary(), ConfigJson :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}. -config_container(Pid, ContainerName, ConfigJson) when is_pid(Pid), is_binary(ContainerName), is_binary(ConfigJson) -> - Request = #jsonrpc_request{method = <<"config_container">>, params = #{<<"container_name">> => ContainerName, <<"config">> => ConfigJson}}, - EncConfigBin = message_codec:encode(?MESSAGE_JSONRPC_REQUEST, Request), - gen_statem:call(Pid, {jsonrpc_call, self(), EncConfigBin}). - --spec deploy_container(Pid :: pid(), TaskId :: integer(), Config :: map()) -> {ok, Ref :: reference()} | {error, Reason :: any()}. -deploy_container(Pid, TaskId, Config) when is_pid(Pid), is_integer(TaskId), is_map(Config) -> - Request = #jsonrpc_request{method = <<"deploy">>, params = #{<<"task_id">> => TaskId, <<"config">> => Config}}, - EncDeployBin = message_codec:encode(?MESSAGE_JSONRPC_REQUEST, Request), - gen_statem:call(Pid, {jsonrpc_call, self(), EncDeployBin}). - --spec start_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}. -start_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) -> - Request = #jsonrpc_request{method = <<"start_container">>, params = #{<<"container_name">> => ContainerName}}, - EncCallBin = message_codec:encode(?MESSAGE_JSONRPC_REQUEST, Request), - gen_statem:call(Pid, {jsonrpc_call, self(), EncCallBin}). - --spec stop_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}. -stop_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) -> - Request = #jsonrpc_request{method = <<"stop_container">>, params = #{<<"container_name">> => ContainerName}}, - EncCallBin = message_codec:encode(?MESSAGE_JSONRPC_REQUEST, Request), - gen_statem:call(Pid, {jsonrpc_call, self(), EncCallBin}). - --spec kill_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}. -kill_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) -> - Request = #jsonrpc_request{method = <<"kill_container">>, params = #{<<"container_name">> => ContainerName}}, - EncCallBin = message_codec:encode(?MESSAGE_JSONRPC_REQUEST, Request), - gen_statem:call(Pid, {jsonrpc_call, self(), EncCallBin}). - --spec remove_container(Pid :: pid(), ContainerName :: binary()) -> {ok, Ref :: reference()} | {error, Reason :: any()}. -remove_container(Pid, ContainerName) when is_pid(Pid), is_binary(ContainerName) -> - Request = #jsonrpc_request{method = <<"remove_container">>, params = #{<<"container_name">> => ContainerName}}, - EncCallBin = message_codec:encode(?MESSAGE_JSONRPC_REQUEST, Request), - gen_statem:call(Pid, {jsonrpc_call, self(), EncCallBin}). - --spec await_reply(Ref :: reference(), Timeout :: integer()) -> {ok, Result :: binary()} | {error, Reason :: binary()}. -await_reply(Ref, Timeout) when is_reference(Ref), is_integer(Timeout) -> - receive - {jsonrpc_reply, Ref, #jsonrpc_reply{result = Result, error = undefined}} -> - {ok, Result}; - {jsonrpc_reply, Ref, #jsonrpc_reply{result = undefined, error = #{<<"message">> := Message}}} -> - {error, Message} - after Timeout -> - {error, <<"timeout">>} - end. - --spec pub(Pid :: pid(), Topic :: binary(), Qos :: integer(), Content :: binary()) -> ok | {error, Reason :: any()}. -pub(Pid, Topic, Qos, Content) when is_pid(Pid), is_binary(Topic), is_integer(Qos), is_binary(Content) -> - gen_statem:call(Pid, {pub, Topic, Qos, Content}). - --spec command(Pid :: pid(), CommandType :: integer(), Command :: binary()) -> ok | {error, Reason :: any()}. -command(Pid, CommandType, Command) when is_pid(Pid), is_integer(CommandType), is_binary(Command) -> - gen_statem:call(Pid, {command, CommandType, Command}). - --spec heartbeat(Pid :: pid()) -> no_return(). -heartbeat(undefined) -> - ok; -heartbeat(Pid) when is_pid(Pid) -> - gen_statem:cast(Pid, heartbeat). - -%% @doc Creates a gen_statem process which calls Module:init/1 to -%% initialize. To ensure a synchronized start-up procedure, this -%% function does not return until Module:init/1 has returned. -start_link(Name, UUID) when is_atom(Name), is_binary(UUID) -> - gen_statem:start_link({local, Name}, ?MODULE, [UUID], []). - -%%%=================================================================== -%%% gen_statem callbacks -%%%=================================================================== - -%% @private -%% @doc Whenever a gen_statem is started using gen_statem:start/[3,4] or -%% gen_statem:start_link/[3,4], this function is called by the new -%% process to initialize. -init([UUID]) -> - case iot_api:get_host_by_uuid(UUID) of - {ok, #{<<"id">> := HostId, <<"authorize_status">> := AuthorizeStatus}} -> - %% 通过host_id注册别名, 可以避免通过查询数据库获取HostPid - AliasName = get_alias_name(HostId), - global:register_name(AliasName, self()), - - %% 心跳检测机制 - erlang:start_timer(?HEARTBEAT_INTERVAL, self(), heartbeat_ticker), - - StateName = case AuthorizeStatus =:= 1 of - true -> ?STATE_ACTIVATED; - false -> ?STATE_DENIED - end, - {ok, StateName, #state{host_id = HostId, uuid = UUID, has_session = false}}; - undefined -> - lager:warning("[iot_host] host uuid: ~p, loaded from mysql failed", [UUID]), - ignore - end. - -%% @private -%% @doc This function is called by a gen_statem when it needs to find out -%% the callback mode of the callback module. -callback_mode() -> - handle_event_function. - -%% @private -%% @doc If callback_mode is handle_event_function, then whenever a -%% gen_statem receives an event from call/2, cast/2, or as a normal -%% process message, this function is called. - -handle_event({call, From}, get_metric, _, State = #state{metrics = Metrics}) -> - {keep_state, State, [{reply, From, {ok, Metrics}}]}; - -%% 获取主机的状态 -handle_event({call, From}, get_status, _, State = #state{channel_pid = ChannelPid, heartbeat_counter = HeartbeatCounter, metrics = Metrics, has_session = HasSession}) -> - HasChannel = (ChannelPid /= undefined), - Reply = #{ - <<"has_channel">> => HasChannel, - <<"has_session">> => HasSession, - <<"heartbeat_counter">> => HeartbeatCounter, - <<"metrics">> => Metrics - }, - {keep_state, State, [{reply, From, {ok, Reply}}]}; - -%% 只要channel存在,就负责将消息推送到边缘端主机 -handle_event({call, From}, {jsonrpc_call, ReceiverPid, RpcCall}, _, State = #state{uuid = UUID, channel_pid = ChannelPid, has_session = HasSession}) -> - case HasSession andalso is_pid(ChannelPid) of - true -> - %% 通过websocket发送请求 - Ref = tcp_channel:jsonrpc_call(ChannelPid, ReceiverPid, RpcCall), - {keep_state, State, [{reply, From, {ok, Ref}}]}; - false -> - lager:debug("[iot_host] uuid: ~p, invalid state: ~p", [UUID, state_map(State)]), - {keep_state, State, [{reply, From, {error, <<"主机离线,发送请求失败"/utf8>>}}]} - end; - -%% 发送指令时, pub/sub -handle_event({call, From}, {pub, Topic, Qos, Content}, ?STATE_ACTIVATED, State = #state{uuid = UUID, channel_pid = ChannelPid, has_session = HasSession}) -> - case HasSession andalso is_pid(ChannelPid) of - true -> - lager:debug("[iot_host] host: ~p, publish to topic: ~p, content: ~p", [UUID, Topic, Content]), - %% 通过websocket发送请求 - tcp_channel:pub(ChannelPid, Topic, Qos, Content), - - {keep_state, State, [{reply, From, ok}]}; - false -> - lager:debug("[iot_host] uuid: ~p, publish to topic: ~p, content: ~p, invalid state: ~p", [UUID, Topic, Content, state_map(State)]), - {keep_state, State, [{reply, From, {error, <<"主机离线,发送失败"/utf8>>}}]} - end; - -%% 发送指令时 -handle_event({call, From}, {command, CommandType, Command}, ?STATE_ACTIVATED, State = #state{uuid = UUID, channel_pid = ChannelPid, has_session = HasSession}) -> - case HasSession andalso is_pid(ChannelPid) of - true -> - lager:debug("[iot_host] host: ~p, command_type: ~p, command: ~p", [UUID, CommandType, Command]), - %% 通过websocket发送请求 - tcp_channel:command(ChannelPid, CommandType, Command), - {keep_state, State, [{reply, From, ok}]}; - false -> - lager:debug("[iot_host] host: ~p, command_type: ~p, command: ~p, invalid state: ~p", [UUID, CommandType, Command, state_map(State)]), - {keep_state, State, [{reply, From, {error, <<"主机离线,发送指令失败"/utf8>>}}]} - end; - -%% 激活主机 -handle_event({call, From}, {activate, true}, _, State = #state{uuid = UUID, channel_pid = ChannelPid}) -> - case is_pid(ChannelPid) of - true -> - lager:debug("[iot_host] uuid: ~p, activate: true", [UUID]), - tcp_channel:command(ChannelPid, ?COMMAND_AUTH, <<1:8>>); - false -> - lager:debug("[iot_host] uuid: ~p, activate: true, no channel", [UUID]) - end, - {next_state, ?STATE_ACTIVATED, State, [{reply, From, ok}]}; - -%% 关闭授权 -handle_event({call, From}, {activate, false}, _, State = #state{uuid = UUID, channel_pid = ChannelPid}) -> - case is_pid(ChannelPid) of - true -> - tcp_channel:command(ChannelPid, ?COMMAND_AUTH, <<0:8>>), - lager:debug("[iot_host] uuid: ~p, activate: false", [UUID]), - tcp_channel:stop(ChannelPid, closed); - false -> - lager:debug("[iot_host] uuid: ~p, activate: false, no channel", [UUID]) - end, - {next_state, ?STATE_DENIED, State#state{channel_pid = undefined, has_session = false}, [{reply, From, ok}]}; - -%% 绑定channel -handle_event({call, From}, {attach_channel, ChannelPid}, StateName, State = #state{uuid = UUID, channel_pid = undefined}) -> - case StateName of - ?STATE_ACTIVATED -> - erlang:monitor(process, ChannelPid), - %% 更新主机为在线状态 - ChangeResult = iot_api:change_host_status(UUID, ?HOST_ONLINE), - lager:debug("[iot_host] host_id(attach_channel) uuid: ~p, will change status, result: ~p", [UUID, ChangeResult]), - {keep_state, State#state{channel_pid = ChannelPid, has_session = true}, [{reply, From, ok}]}; - %% 主机未激活 - ?STATE_DENIED -> - lager:notice("[iot_host] attach_channel host_id uuid: ~p, channel: ~p, host inactivated", [UUID, ChannelPid]), - erlang:monitor(process, ChannelPid), - {keep_state, State#state{channel_pid = ChannelPid}, [{reply, From, {denied, <<"host inactivated">>}}]} - end; -%% 已经绑定了channel -handle_event({call, From}, {attach_channel, _}, _, State = #state{uuid = UUID, channel_pid = OldChannelPid}) -> - lager:notice("[iot_host] attach_channel host_id uuid: ~p, old channel exists: ~p", [UUID, OldChannelPid]), - {keep_state, State, [{reply, From, {error, <<"channel existed">>}}]}; - -%% 数据分发 -handle_event(cast, {handle, {data, #data{route_key = RouteKey0, metric = Metric}}}, ?STATE_ACTIVATED, - State = #state{uuid = UUID, has_session = true}) -> - lager:debug("[iot_host] metric_data host: ~p, route_key: ~p, metric: ~p", [UUID, RouteKey0, Metric]), - RouteKey = get_route_key(RouteKey0), - endpoint_subscription:publish(RouteKey, Metric), - {keep_state, State}; - -%% ping的数据是通过aes加密后的,因此需要在有会话的情况下才行 -handle_event(cast, {handle, {ping, Metrics}}, ?STATE_ACTIVATED, State = #state{uuid = UUID, has_session = true}) -> - lager:debug("[iot_host] ping host_id uuid: ~p, get ping: ~p", [UUID, Metrics]), - {keep_state, State#state{metrics = Metrics}}; - -%% 心跳机制 -handle_event(cast, heartbeat, _, State = #state{heartbeat_counter = HeartbeatCounter}) -> - {keep_state, State#state{heartbeat_counter = HeartbeatCounter + 1}}; - -%% 没有收到心跳包,主机下线, 设备状态不变 -handle_event(info, {timeout, _, heartbeat_ticker}, _, State = #state{uuid = UUID, heartbeat_counter = 0, channel_pid = ChannelPid}) -> - lager:warning("[iot_host] uuid: ~p, heartbeat lost, devices will unknown", [UUID]), - {ok, #{<<"status">> := Status}} = iot_api:get_host_by_uuid(UUID), - case Status of - ?HOST_NOT_JOINED -> - lager:debug("[iot_host] host: ~p, host_maybe_offline, host not joined, can not change to offline", [UUID]); - ?HOST_OFFLINE -> - lager:debug("[iot_host] host: ~p, host_maybe_offline, host now is offline, do nothing", [UUID]); - ?HOST_ONLINE -> - iot_api:change_host_status(UUID, ?HOST_OFFLINE) - end, - - %% 关闭channel,主机需要重新连接,才能保存状态的一致 - is_pid(ChannelPid) andalso tcp_channel:stop(ChannelPid, closed), - erlang:start_timer(?HEARTBEAT_INTERVAL, self(), heartbeat_ticker), - - {keep_state, State#state{channel_pid = undefined, has_session = false, heartbeat_counter = 0}}; - -%% 其他情况下需要重置系统计数器 -handle_event(info, {timeout, _, heartbeat_ticker}, _, State = #state{}) -> - erlang:start_timer(?HEARTBEAT_INTERVAL, self(), heartbeat_ticker), - {keep_state, State#state{heartbeat_counter = 0}}; - -%% 当websocket断开的时候,主机的状态不一定改变;主机的状态改变通过心跳机制,会话状态需要改变 -handle_event(info, {'DOWN', _Ref, process, ChannelPid, Reason}, _, State = #state{uuid = UUID, channel_pid = ChannelPid, has_session = HasSession}) -> - lager:warning("[iot_host] uuid: ~p, channel: ~p, down with reason: ~p, has_session: ~p, state: ~p", [UUID, ChannelPid, Reason, HasSession, State]), - {keep_state, State#state{channel_pid = undefined, has_session = false}}; - -handle_event(info, {'DOWN', _Ref, process, Pid, Reason}, _, State = #state{uuid = UUID}) -> - lager:debug("[iot_host] uuid: ~p, process_pid: ~p, down with reason: ~p, state: ~p", [UUID, Pid, Reason, State]), - {keep_state, State}; - -handle_event(Event, Info, StateName, State = #state{uuid = UUID}) -> - lager:warning("[iot_host] host: ~p, event: ~p, unknown message: ~p, state_name: ~p, state: ~p", [UUID, Event, Info, StateName, state_map(State)]), - {keep_state, State}. - -%% @private -%% @doc This function is called by a gen_statem 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_statem terminates with -%% Reason. The return value is ignored. -terminate(Reason, _StateName, _State = #state{uuid = UUID, has_session = HasSession}) -> - lager:debug("[iot_host] host: ~p, terminate with reason: ~p, has_session: ~p", [UUID, Reason, HasSession]), - ok. - -%% @private -%% @doc Convert process state when code is changed -code_change(_OldVsn, StateName, State = #state{}, _Extra) -> - {ok, StateName, State}. - -%%%=================================================================== -%%% Internal functions -%%%=================================================================== - --spec get_route_key(binary()) -> binary(). -get_route_key(<<"">>) -> - <<"/">>; -get_route_key(RouteKey) when is_binary(RouteKey) -> - RouteKey. - -%% 将当前的state转换成map -state_map(#state{host_id = HostId, uuid = UUID, has_session = HasSession, heartbeat_counter = HeartbeatCounter, channel_pid = ChannelPid, metrics = Metrics}) -> - #{ - host_id => HostId, - uuid => UUID, - has_session => HasSession, - heartbeat_counter => HeartbeatCounter, - channel_pid => ChannelPid, - metrics => Metrics - }. \ No newline at end of file diff --git a/apps/iot/src/iot_host_sup.erl b/apps/iot/src/iot_host_sup.erl deleted file mode 100644 index 4762613..0000000 --- a/apps/iot/src/iot_host_sup.erl +++ /dev/null @@ -1,59 +0,0 @@ -%%%------------------------------------------------------------------- -%%% @author aresei -%%% @copyright (C) 2023, -%%% @doc -%%% @end -%%%------------------------------------------------------------------- --module(iot_host_sup). --include("iot.hrl"). - --behaviour(supervisor). - --export([start_link/0, init/1, delete_host/1, ensured_host_started/1]). - -start_link() -> - supervisor:start_link({local, ?MODULE}, ?MODULE, []). - -init([]) -> - Specs = lists:map(fun child_spec/1, iot_api:get_all_hosts()), - - {ok, {#{strategy => one_for_one, intensity => 1000, period => 3600}, Specs}}. - --spec ensured_host_started(UUID :: binary()) -> {ok, Pid :: pid()} | {error, Reason :: any()}. -ensured_host_started(UUID) when is_binary(UUID) -> - case iot_host:get_pid(UUID) of - undefined -> - %% 尝试删除下host对应的信息 - delete_host(UUID), - case supervisor:start_child(?MODULE, child_spec(UUID)) of - {ok, Pid} when is_pid(Pid) -> - {ok, Pid}; - {error, {'already_started', Pid}} when is_pid(Pid) -> - {ok, Pid}; - {error, Error} -> - {error, Error} - end; - Pid when is_pid(Pid) -> - {ok, Pid} - end. - -delete_host(UUID) -> - Id = iot_host:get_name(UUID), - ok = supervisor:terminate_child(?MODULE, Id), - case supervisor:delete_child(?MODULE, Id) of - {error, running} -> - %% ensure killed then delete again - iot_host:kill(UUID), - supervisor:delete_child(?MODULE, Id); - _ -> - ok - end. - -child_spec(UUID) -> - Id = iot_host:get_name(UUID), - #{id => Id, - start => {iot_host, start_link, [Id, UUID]}, - restart => permanent, - shutdown => 2000, - type => worker, - modules => ['iot_host']}. \ No newline at end of file diff --git a/apps/iot/src/iot_http_client.erl b/apps/iot/src/iot_http_client.erl deleted file mode 100644 index 1fd5fad..0000000 --- a/apps/iot/src/iot_http_client.erl +++ /dev/null @@ -1,40 +0,0 @@ -%%%------------------------------------------------------------------- -%%% @author licheng5 -%%% @copyright (C) 2023, -%%% @doc -%%% -%%% @end -%%% Created : 03. 3月 2023 11:48 -%%%------------------------------------------------------------------- --module(iot_http_client). --author("licheng5"). - -%% API --export([post/2]). - -post(Url, Body) when is_list(Url), is_binary(Body) -> - case hackney:request(post, Url, [], Body) of - {ok, 200, _, ClientRef} -> - case hackney:body(ClientRef) of - {ok, RespBody} -> - lager:debug("[iot_http_client] url: ~p, response is: ~p", [Url, RespBody]), - ok; - {error, Reason} -> - lager:warning("[iot_http_client] url: ~p, get error: ~p", [Url, Reason]), - {error, Reason} - end; - - {ok, HttpCode, _, ClientRef} -> - case hackney:body(ClientRef) of - {ok, RespBody} -> - lager:debug("[iot_http_client] url: ~p, http_code: ~p, response is: ~p", [Url, HttpCode, RespBody]), - ok; - {error, Reason} -> - lager:warning("[iot_http_client] url: ~p, http_code: ~p, get error: ~p", [Url, HttpCode, Reason]), - {error, Reason} - end; - - {error, Reason} -> - lager:warning("[iot_http_client] url: ~p, get error: ~p", [Url, Reason]), - {error, Reason} - end. \ No newline at end of file diff --git a/apps/iot/src/iot_log.erl b/apps/iot/src/iot_log.erl new file mode 100644 index 0000000..ea4421c --- /dev/null +++ b/apps/iot/src/iot_log.erl @@ -0,0 +1,10 @@ +%%%------------------------------------------------------------------- +%%% @doc Logger helper for project-scoped metadata. +%%%------------------------------------------------------------------- +-module(iot_log). + +-export([set_metadata/0]). + +-spec set_metadata() -> ok. +set_metadata() -> + logger:set_process_metadata(#{domain => [iot]}). diff --git a/apps/iot/src/iot_sup.erl b/apps/iot/src/iot_sup.erl index c84a665..d12f3a4 100644 --- a/apps/iot/src/iot_sup.erl +++ b/apps/iot/src/iot_sup.erl @@ -29,21 +29,21 @@ init([]) -> Specs = [ #{ - id => 'iot_event_stream_observer', - start => {'iot_event_stream_observer', start_link, []}, - restart => permanent, - shutdown => 2000, - type => worker, - modules => ['iot_event_stream_observer'] - }, - - #{ - id => endpoint_sup_sup, - start => {'endpoint_sup_sup', start_link, []}, + id => 'iot_container_task_sup', + start => {'iot_container_task_sup', start_link, []}, restart => permanent, shutdown => 2000, type => supervisor, - modules => ['endpoint_sup_sup'] + modules => ['iot_container_task_sup'] + }, + + #{ + id => 'udp_server', + start => {'udp_server', start_link, []}, + restart => permanent, + shutdown => 2000, + type => worker, + modules => ['udp_server'] }, #{ diff --git a/apps/iot/src/iot_udp_handler.erl b/apps/iot/src/iot_udp_handler.erl deleted file mode 100644 index 227153c..0000000 --- a/apps/iot/src/iot_udp_handler.erl +++ /dev/null @@ -1,26 +0,0 @@ -%%%------------------------------------------------------------------- -%%% @author aresei -%%% @copyright (C) 2023, -%%% @doc -%%% -%%% @end -%%% Created : 31. 8月 2023 13:48 -%%%------------------------------------------------------------------- --module(iot_udp_handler). --author("aresei"). - -%% API --export([start_link/2, loop/2]). - -start_link(Transport, Peer) -> - {ok, spawn_link(?MODULE, loop, [Transport, Peer])}. - -loop(Transport = {udp, Server, _Sock}, Peer) -> - receive - {datagram, Server, <>} -> - Pid = iot_host:get_pid(HostUUID), - iot_host:heartbeat(Pid), - loop(Transport, Peer); - {datagram, Server, _} -> - exit(normal) - end. \ No newline at end of file diff --git a/apps/iot/src/iot_util.erl b/apps/iot/src/iot_util.erl index 2c97506..a074693 100644 --- a/apps/iot/src/iot_util.erl +++ b/apps/iot/src/iot_util.erl @@ -10,15 +10,15 @@ -author("licheng5"). %% API --export([timestamp/0, number_format/2, current_time/0, timestamp_of_seconds/0, float_to_binary/2, int_format/2, file_uri/1]). --export([step/3, chunks/2, rand_bytes/1, uuid/0, md5/1, parse_mapper/1]). +-export([timestamp/0, number_format/2, current_time/0, timestamp_of_seconds/0, float_to_binary/2, int_format/2]). +-export([step/3, rand_bytes/1, uuid/0, md5/1, parse_mapper/1]). -export([json_data/1, json_error/2, is_json/1]). -export([queue_limited_in/3, assert_call/2, assert/2]). -export([sha256/1]). -spec is_json(Json :: term()) -> boolean(). is_json(Json) when is_binary(Json) -> - case catch jiffy:decode(Json, [return_maps]) of + case catch json:decode(Json) of Result when is_list(Result) orelse is_map(Result) -> true; _ -> @@ -61,33 +61,18 @@ step(Start, End, Step, Acc) when Start < End -> step(_, _, _, Acc) -> lists:reverse(Acc). -%% 将数据分组 --spec chunks(list(), integer()) -> [list()]. -chunks(List, Size) when is_list(List), is_integer(Size), Size > 0, length(List) =< Size -> - [List]; -chunks(List, Size) when is_list(List), is_integer(Size), Size > 0 -> - chunks0(List, Size, Size, [], []). -chunks0([], _, _, [], AccTarget) -> - lists:reverse(AccTarget); -chunks0([], _, _, Target, AccTarget) -> - lists:reverse([lists:reverse(Target) | AccTarget]); -chunks0(List, Size, 0, Target, AccTarget) -> - chunks0(List, Size, Size, [], [lists:reverse(Target) | AccTarget]); -chunks0([Hd | Tail], Size, Num, Target, AccTarget) -> - chunks0(Tail, Size, Num - 1, [Hd | Target], AccTarget). - json_data(Data) -> - jiffy:encode(#{ + iolist_to_binary(json:encode(#{ <<"result">> => Data - }, [force_utf8]). + })). json_error(ErrCode, ErrMessage) when is_integer(ErrCode) -> - jiffy:encode(#{ + iolist_to_binary(json:encode(#{ <<"error">> => #{ <<"code">> => ErrCode, <<"message">> => ErrMessage } - }, [force_utf8]). + })). uuid() -> rand_bytes(16). @@ -132,18 +117,9 @@ hex(N) -> %% 转换映射器 -spec parse_mapper(Mapper :: binary() | string()) -> error | {ok, F :: fun((binary(), any()) -> any())}. -parse_mapper(Mapper) when is_binary(Mapper) -> - parse_mapper(binary_to_list(Mapper)); -parse_mapper(Mapper) when is_list(Mapper) -> - {ok, Tokens, _} = erl_scan:string(Mapper), - {ok, ExprList} = erl_parse:parse_exprs(Tokens), - {value, F, _} = erl_eval:exprs(ExprList, []), - case is_function(F, 2) orelse is_function(F, 3) of - true -> - {ok, F}; - false -> - error - end. +parse_mapper(_Mapper) -> + %% Dynamic Erlang eval is intentionally disabled in runtime code. + error. -spec float_to_binary(Num :: number(), integer()) -> binary(). float_to_binary(V, _) when is_integer(V) -> @@ -158,13 +134,3 @@ assert(false, F) when is_function(F) -> F(); assert(false, Msg) -> throw(Msg). - - --spec file_uri(Filename :: binary()) -> error | {ok, FileUri :: binary()}. -file_uri(Filename) when is_binary(Filename) -> - case binary:split(Filename, <<"-">>, [global]) of - [Year, Month, Day | _] -> - {ok, <<"https://lgsiot.njau.edu.cn/upload/", Year/binary, $/, Month/binary, $/, Day/binary, $/, Filename/binary>>}; - _ -> - error - end. \ No newline at end of file diff --git a/apps/iot/src/message/message_codec.erl b/apps/iot/src/message/message_codec.erl deleted file mode 100644 index 769c11e..0000000 --- a/apps/iot/src/message/message_codec.erl +++ /dev/null @@ -1,141 +0,0 @@ -%%%------------------------------------------------------------------- -%%% @author anlicheng -%%% @copyright (C) 2025, -%%% @doc -%%% -%%% @end -%%% Created : 17. 9月 2025 16:05 -%%%------------------------------------------------------------------- --module(message_codec). --author("anlicheng"). --include("message.hrl"). - --define(I8, 1). --define(I16, 2). --define(I32, 3). --define(Bytes, 4). - -%% API --export([encode/2, decode/1]). - --spec encode(MessageType :: integer(), Message :: any()) -> binary(). -encode(MessageType, Message) when is_integer(MessageType) -> - Bin = encode0(Message), - <>. -encode0(#auth_request{uuid = UUID, username = Username, salt = Salt, token = Token, timestamp = Timestamp}) -> - iolist_to_binary([ - marshal(?Bytes, UUID), - marshal(?Bytes, Username), - marshal(?Bytes, Salt), - marshal(?Bytes, Token), - marshal(?I32, Timestamp) - ]); -encode0(#auth_reply{code = Code, payload = Payload}) -> - iolist_to_binary([ - marshal(?I32, Code), - marshal(?Bytes, Payload) - ]); -encode0(#jsonrpc_reply{result = Result, error = undefined}) -> - ResultBin = erlang:term_to_binary(#{<<"result">> => Result}), - iolist_to_binary([ - marshal(?Bytes, ResultBin) - ]); -encode0(#jsonrpc_reply{result = undefined, error = Error}) -> - ResultBin = erlang:term_to_binary(#{<<"error">> => Error}), - iolist_to_binary([ - marshal(?Bytes, ResultBin) - ]); -encode0(#pub{topic = Topic, qos = Qos, content = Content}) -> - iolist_to_binary([ - marshal(?Bytes, Topic), - marshal(?I8, Qos), - marshal(?Bytes, Content) - ]); -encode0(#command{command_type = CommandType, command = Command}) -> - iolist_to_binary([ - marshal(?I32, CommandType), - marshal(?Bytes, Command) - ]); - -encode0(#jsonrpc_request{method = Method, params = Params}) -> - ReqBody = erlang:term_to_binary(#{<<"method">> => Method, <<"params">> => Params}), - iolist_to_binary([ - marshal(?Bytes, ReqBody) - ]); -encode0(#data{route_key = RouteKey, metric = Metric}) -> - iolist_to_binary([ - marshal(?Bytes, RouteKey), - marshal(?Bytes, Metric) - ]); -encode0(#task_event_stream{task_id = TaskId, type = Type, stream = Stream}) -> - iolist_to_binary([ - marshal(?I32, TaskId), - marshal(?Bytes, Type), - marshal(?Bytes, Stream) - ]). - --spec decode(Bin :: binary()) -> {ok, Message :: any()} | error. -decode(<>) -> - case unmarshal(Packet) of - {ok, Fields} -> - decode0(PacketType, Fields); - error -> - error - end. -decode0(?MESSAGE_AUTH_REQUEST, [UUID, Username, Salt, Token, Timestamp]) -> - {ok, #auth_request{uuid = UUID, username = Username, salt = Salt, token = Token, timestamp = Timestamp}}; -decode0(?MESSAGE_JSONRPC_REPLY, [ReplyBin]) -> - case erlang:binary_to_term(ReplyBin) of - #{<<"result">> := Result} -> - {ok, #jsonrpc_reply{result = Result}}; - #{<<"error">> := Error} -> - {ok, #jsonrpc_reply{error = Error}}; - _ -> - error - end; -decode0(?MESSAGE_PUB, [Topic, Qos, Content]) -> - {ok, #pub{topic = Topic, qos = Qos, content = Content}}; -decode0(?MESSAGE_COMMAND, [CommandType, Command]) -> - {ok, #command{command_type = CommandType, command = Command}}; -decode0(?MESSAGE_AUTH_REPLY, [Code, Payload]) -> - {ok, #auth_reply{code = Code, payload = Payload}}; -decode0(?MESSAGE_JSONRPC_REQUEST, [ReqBody]) -> - #{<<"method">> := Method, <<"params">> := Params} = erlang:binary_to_term(ReqBody), - {ok, #jsonrpc_request{method = Method, params = Params}}; -decode0(?MESSAGE_DATA, [RouteKey, Metric]) -> - {ok, #data{route_key = RouteKey, metric = Metric}}; -decode0(?MESSAGE_EVENT_STREAM, [TaskId, Type, Stream]) -> - {ok, #task_event_stream{task_id = TaskId, type = Type, stream = Stream}}; -decode0(_, _) -> - error. - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%%% helper methods -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - --spec marshal(Type :: ?I8 | ?I16 | ?I32 | ?Bytes, Field :: integer() | binary()) -> binary(). -marshal(?I8, Field) when is_integer(Field) -> - <>; -marshal(?I16, Field) when is_integer(Field) -> - <>; -marshal(?I32, Field) when is_integer(Field) -> - <>; -marshal(?Bytes, Field) when is_binary(Field) -> - Len = byte_size(Field), - <>. - --spec unmarshal(Bin :: binary()) -> {ok, Components :: [any()]} | error. -unmarshal(Bin) when is_binary(Bin) -> - unmarshal(Bin, []). -unmarshal(<<>>, Acc) -> - {ok, lists:reverse(Acc)}; -unmarshal(<>, Acc) -> - unmarshal(Rest, [F|Acc]); -unmarshal(<>, Acc) -> - unmarshal(Rest, [F|Acc]); -unmarshal(<>, Acc) -> - unmarshal(Rest, [F|Acc]); -unmarshal(<>, Acc) -> - unmarshal(Rest, [F|Acc]); -unmarshal(_, _) -> - error. diff --git a/apps/iot/src/mnesia/efka_client_store.erl b/apps/iot/src/mnesia/efka_client_store.erl new file mode 100644 index 0000000..075c68a --- /dev/null +++ b/apps/iot/src/mnesia/efka_client_store.erl @@ -0,0 +1,154 @@ +%%%------------------------------------------------------------------- +%%% @doc efka_client table store based on mnesia. +%%%------------------------------------------------------------------- +-module(efka_client_store). + +-include("iot_tables.hrl"). + +-export([ensure_table/0, register/2, get/1, update/1, delete/1, list/0]). +-export([auth/2, verify_heartbeat/4]). + +-define(TOKEN_HASH_ITERATIONS, 32). +-define(TOKEN_HASH_BYTES, 32). +-define(HEARTBEAT_TIMESTAMP_WINDOW, 120). + +-spec ensure_table() -> ok | {error, term()}. +ensure_table() -> + case mnesia:create_table(efka_client, [ + {attributes, record_info(fields, efka_client)}, + {type, set}, + {record_name, efka_client}, + {disc_copies, [node()]} + ]) of + {atomic, ok} -> + ok; + {aborted, {already_exists, efka_client}} -> + ok; + {aborted, Reason} -> + {error, Reason} + end. + +-spec auth(UUID :: binary(), Token :: binary()) -> boolean(). +auth(UUID, Token) when is_binary(UUID), UUID =/= <<>>, is_binary(Token) -> + case mnesia:dirty_read(efka_client, UUID) of + [] -> + false; + [#efka_client{token_hash = TokenHash, salt = Salt}] -> + hash_token(Token, Salt) =:= TokenHash + end. + +-spec verify_heartbeat(UUID :: binary(), Timestamp :: integer(), Payload :: binary(), Mac :: binary()) -> boolean(). +verify_heartbeat(UUID, Timestamp, Payload, Mac) + when is_binary(UUID), UUID =/= <<>>, is_integer(Timestamp), is_binary(Payload), is_binary(Mac) -> + case valid_heartbeat_timestamp(Timestamp) of + true -> + case mnesia:dirty_read(efka_client, UUID) of + [] -> + false; + [#efka_client{heartbeat_secret = HeartbeatSecret}] -> + ExpectedMac = crypto:mac(hmac, sha256, HeartbeatSecret, Payload), + ExpectedMac =:= Mac + end; + false -> + false + end. + +-spec register(UUID :: binary(), Token :: binary()) -> ok | {error, term()}. +register(UUID, Token) when is_binary(UUID), UUID =/= <<>>, is_binary(Token), byte_size(Token) >= 32 -> + case mnesia:transaction(fun() -> + case mnesia:read(efka_client, UUID, write) of + [] -> + %% 1. 生成 16 字节随机盐 + Salt = crypto:strong_rand_bytes(16), + TokenHash = hash_token(Token, Salt), + HeartbeatSecret = heartbeat_secret(Token), + ok = mnesia:write(#efka_client{ + uuid = UUID, + token_hash = TokenHash, + salt = Salt, + heartbeat_secret = HeartbeatSecret, + timestamp = iot_util:timestamp() + }), + ok; + [_] -> + {error, already_exists} + end + end) of + {atomic, ok} -> + ok; + {atomic, {error, Reason}} -> + {error, Reason}; + {aborted, Reason} -> + {error, Reason} + end. + +-spec get(binary()) -> {ok, #efka_client{}} | not_found | {error, term()}. +get(UUID) when is_binary(UUID), UUID =/= <<>> -> + case mnesia:transaction(fun() -> + case mnesia:read(efka_client, UUID, read) of + [Client = #efka_client{}] -> + {ok, Client}; + [] -> + not_found + end + end) of + {atomic, Result} -> + Result; + {aborted, Reason} -> + {error, Reason} + end. + +-spec update(#efka_client{}) -> ok | {error, term()}. +update(Client = #efka_client{uuid = UUID}) when is_binary(UUID), UUID =/= <<>> -> + case mnesia:transaction(fun() -> + case mnesia:read(efka_client, UUID, write) of + [_] -> + ok = mnesia:write(Client), + ok; + [] -> + {error, not_found} + end + end) of + {atomic, ok} -> + ok; + {atomic, {error, Reason}} -> + {error, Reason}; + {aborted, Reason} -> + {error, Reason} + end. + +-spec delete(binary()) -> ok | {error, term()}. +delete(UUID) when is_binary(UUID), UUID =/= <<>> -> + case mnesia:transaction(fun() -> + ok = mnesia:delete({efka_client, UUID}), + ok + end) of + {atomic, ok} -> + ok; + {aborted, Reason} -> + {error, Reason} + end. + +-spec list() -> {ok, [#efka_client{}]} | {error, term()}. +list() -> + case mnesia:transaction(fun() -> + mnesia:foldl(fun(Client = #efka_client{}, Acc) -> [Client | Acc] end, [], efka_client) + end) of + {atomic, Clients} -> + {ok, lists:reverse(Clients)}; + {aborted, Reason} -> + {error, Reason} + end. + +-spec hash_token(binary(), binary()) -> binary(). +hash_token(Token, Salt) when is_binary(Token), is_binary(Salt) -> + crypto:pbkdf2_hmac(sha256, Token, Salt, ?TOKEN_HASH_ITERATIONS, ?TOKEN_HASH_BYTES). + +-spec heartbeat_secret(binary()) -> binary(). +heartbeat_secret(Token) when is_binary(Token) -> + crypto:hash(sha256, Token). + +-spec valid_heartbeat_timestamp(integer()) -> boolean(). +valid_heartbeat_timestamp(Timestamp) -> + Now = iot_util:current_time(), + Timestamp =< Now andalso Now - Timestamp =< ?HEARTBEAT_TIMESTAMP_WINDOW. diff --git a/apps/iot/src/mnesia/iot_mnesia.erl b/apps/iot/src/mnesia/iot_mnesia.erl new file mode 100644 index 0000000..2b4b151 --- /dev/null +++ b/apps/iot/src/mnesia/iot_mnesia.erl @@ -0,0 +1,74 @@ +%%%------------------------------------------------------------------- +%%% @doc Mnesia bootstrap and runtime startup helpers. +%%% +%%% `init/0` is a manual, one-time bootstrap operation. Application +%%% startup must call `start/0`, which only starts an existing schema and +%%% verifies required tables. +%%%------------------------------------------------------------------- +-module(iot_mnesia). + +-include("iot_tables.hrl"). + +-export([safe_start/0, stop/0]). + +-define(TABLES, [efka_client]). +-define(WAIT_TIMEOUT, 5000). + +safe_start() -> + case ensure_schema() of + create_and_start -> + ok = mnesia:start(), + ok = create_tables(), + ok = wait_for_tables(); + only_start -> + ok = mnesia:start(), + ok = wait_for_tables() + end, + log_runtime_info(). + +ensure_schema() -> + stopped = mnesia:stop(), + case mnesia:create_schema([node()]) of + ok -> + create_and_start; + {error, {_, {already_exists, _}}} -> + only_start; + {error, {_, already_exists}} -> + only_start; + {error, _Reason} -> + only_start + end. + +-spec stop() -> ok. +stop() -> + _ = mnesia:stop(), + ok. + +-spec create_tables() -> ok. +create_tables() -> + case efka_client_store:ensure_table() of + ok -> + ok; + {error, Reason} -> + {error, {create_table_failed, {efka_client, Reason}}} + end. + +-spec wait_for_tables() -> ok. +wait_for_tables() -> + case mnesia:wait_for_tables(?TABLES, ?WAIT_TIMEOUT) of + ok -> + ok; + {timeout, Tables} -> + {error, {mnesia_tables_timeout, Tables}}; + {error, Reason} -> + {error, {mnesia_tables_error, Reason}} + end. + +-spec log_runtime_info() -> ok. +log_runtime_info() -> + logger:debug("[iot_mnesia] node: ~p, dir: ~p, tables: ~p", [ + node(), + mnesia:system_info(directory), + mnesia:system_info(tables) + ]), + ok. \ No newline at end of file diff --git a/apps/iot/src/mysql/mysql_pool.erl b/apps/iot/src/mysql/mysql_pool.erl deleted file mode 100644 index cbceac5..0000000 --- a/apps/iot/src/mysql/mysql_pool.erl +++ /dev/null @@ -1,48 +0,0 @@ -%%%------------------------------------------------------------------- -%%% @author aresei -%%% @copyright (C) 2018, -%%% @doc -%%% -%%% @end -%%% Created : 29. 九月 2018 17:01 -%%%------------------------------------------------------------------- --module(mysql_pool). --author("aresei"). - -%% API --export([get_row/2, get_row/3, get_all/2, get_all/3]). --export([update/4, update_by/2, update_by/3, insert/4]). - -%% 从数据库中查找一行记录 --spec get_row(Pool :: atom(), Sql::binary()) -> {ok, Record::map()} | undefined. -get_row(Pool, Sql) when is_atom(Pool), is_binary(Sql) -> - poolboy:transaction(Pool, fun(ConnPid) -> mysql_provider:get_row(ConnPid, Sql) end). - --spec get_row(Pool :: atom(), Sql::binary(), Params::list()) -> {ok, Record::map()} | undefined. -get_row(Pool, Sql, Params) when is_atom(Pool), is_binary(Sql), is_list(Params) -> - poolboy:transaction(Pool, fun(ConnPid) -> mysql_provider:get_row(ConnPid, Sql, Params) end). - --spec get_all(Pool :: atom(), Sql::binary()) -> {ok, Rows::list()} | {error, Reason :: any()}. -get_all(Pool, Sql) when is_atom(Pool), is_binary(Sql) -> - poolboy:transaction(Pool, fun(ConnPid) -> mysql_provider:get_all(ConnPid, Sql) end). - --spec get_all(Pool :: atom(), Sql::binary(), Params::list()) -> {ok, Rows::list()} | {error, Reason::any()}. -get_all(Pool, Sql, Params) when is_atom(Pool), is_binary(Sql), is_list(Params) -> - poolboy:transaction(Pool, fun(ConnPid) -> mysql_provider:get_all(ConnPid, Sql, Params) end). - --spec insert(Pool :: atom(), Table :: binary(), Fields :: map() | list(), boolean()) -> - ok | {ok, InsertId :: integer()} | {error, Reason :: any()}. -insert(Pool, Table, Fields, FetchInsertId) when is_atom(Pool), is_binary(Table), is_list(Fields); is_map(Fields), is_boolean(FetchInsertId) -> - poolboy:transaction(Pool, fun(ConnPid) -> mysql_provider:insert(ConnPid, Table, Fields, FetchInsertId) end). - --spec update_by(Pool :: atom(), UpdateSql :: binary()) -> {ok, AffectedRows :: integer()} | {error, Reason :: any()}. -update_by(Pool, UpdateSql) when is_atom(Pool), is_binary(UpdateSql) -> - poolboy:transaction(Pool, fun(ConnPid) -> mysql_provider:update_by(ConnPid, UpdateSql) end). - --spec update_by(Pool :: atom(), UpdateSql :: binary(), Params :: list()) -> {ok, AffectedRows :: integer()} | {error, Reason :: any()}. -update_by(Pool, UpdateSql, Params) when is_atom(Pool), is_binary(UpdateSql) -> - poolboy:transaction(Pool, fun(ConnPid) -> mysql_provider:update_by(ConnPid, UpdateSql, Params) end). - --spec update(Pool :: atom(), Table :: binary(), Fields :: map(), WhereFields :: map()) -> {ok, AffectedRows::integer()} | {error, Reason::any()}. -update(Pool, Table, Fields, WhereFields) when is_atom(Pool), is_binary(Table), is_map(Fields), is_map(WhereFields) -> - poolboy:transaction(Pool, fun(ConnPid) -> mysql_provider:update(ConnPid, Table, Fields, WhereFields) end). \ No newline at end of file diff --git a/apps/iot/src/mysql/mysql_provider.erl b/apps/iot/src/mysql/mysql_provider.erl deleted file mode 100644 index 7415f9d..0000000 --- a/apps/iot/src/mysql/mysql_provider.erl +++ /dev/null @@ -1,144 +0,0 @@ -%%%------------------------------------------------------------------- -%%% @author aresei -%%% @copyright (C) 2018, -%%% @doc -%%% -%%% @end -%%% Created : 29. 九月 2018 17:01 -%%%------------------------------------------------------------------- --module(mysql_provider). --author("aresei"). - -%% API --export([get_row/2, get_row/3, get_all/2, get_all/3]). --export([update/4, update_by/2, update_by/3, insert/4]). - -%% 从数据库中查找一行记录 --spec get_row(ConnPid :: pid(), Sql::binary()) -> {ok, Record::map()} | undefined. -get_row(ConnPid, Sql) when is_pid(ConnPid), is_binary(Sql) -> - lager:debug("[mysql_client] get_row sql is: ~p", [Sql]), - case mysql:query(ConnPid, Sql) of - {ok, Names, [Row | _]} -> - {ok, maps:from_list(lists:zip(Names, Row))}; - {ok, _, []} -> - undefined; - Error -> - lager:warning("[mysql_client] get error: ~p", [Error]), - undefined - end. - --spec get_row(ConnPid :: pid(), Sql::binary(), Params::list()) -> {ok, Record::map()} | undefined. -get_row(ConnPid, Sql, Params) when is_pid(ConnPid), is_binary(Sql), is_list(Params) -> - lager:debug("[mysql_client] get_row sql is: ~p, params: ~p", [Sql, Params]), - case mysql:query(ConnPid, Sql, Params) of - {ok, Names, [Row | _]} -> - {ok, maps:from_list(lists:zip(Names, Row))}; - {ok, _, []} -> - undefined; - Error -> - lager:warning("[mysql_client] get error: ~p", [Error]), - undefined - end. - --spec get_all(ConnPid :: pid(), Sql::binary()) -> {ok, Rows::list()} | {error, Reason :: any()}. -get_all(ConnPid, Sql) when is_pid(ConnPid), is_binary(Sql) -> - lager:debug("[mysql_client] get_all sql is: ~p", [Sql]), - case mysql:query(ConnPid, Sql) of - {ok, Names, Rows} -> - {ok, lists:map(fun(Row) -> maps:from_list(lists:zip(Names, Row)) end, Rows)}; - {error, Reason} -> - lager:warning("[mysql_client] get error: ~p", [Reason]), - {error, Reason} - end. - --spec get_all(ConnPid :: pid(), Sql::binary(), Params::list()) -> {ok, Rows::list()} | {error, Reason::any()}. -get_all(ConnPid, Sql, Params) when is_pid(ConnPid), is_binary(Sql), is_list(Params) -> - lager:debug("[mysql_client] get_all sql is: ~p, params: ~p", [Sql, Params]), - case mysql:query(ConnPid, Sql, Params) of - {ok, Names, Rows} -> - {ok, lists:map(fun(Row) -> maps:from_list(lists:zip(Names, Row)) end, Rows)}; - {error, Reason} -> - lager:warning("[mysql_client] get error: ~p", [Reason]), - {error, Reason} - end. - --spec insert(ConnPid :: pid(), Table :: binary(), Fields :: map() | list(), boolean()) -> - ok | {ok, InsertId :: integer()} | {error, Reason :: any()}. -insert(ConnPid, Table, Fields, FetchInsertId) when is_pid(ConnPid), is_binary(Table), is_map(Fields), is_boolean(FetchInsertId) -> - insert(ConnPid, Table, maps:to_list(Fields), FetchInsertId); -insert(ConnPid, Table, Fields, FetchInsertId) when is_pid(ConnPid), is_binary(Table), is_list(Fields), is_boolean(FetchInsertId) -> - {Keys, Values} = kvs(Fields), - - FieldSql = iolist_to_binary(lists:join(<<", ">>, Keys)), - Placeholders = lists:duplicate(length(Keys), <<"?">>), - ValuesPlaceholder = iolist_to_binary(lists:join(<<", ">>, Placeholders)), - - Sql = <<"INSERT INTO ", Table/binary, "(", FieldSql/binary, ") VALUES(", ValuesPlaceholder/binary, ")">>, - lager:debug("[mysql_client] insert sql is: ~p, params: ~p", [Sql, Values]), - case mysql:query(ConnPid, Sql, Values) of - ok -> - case FetchInsertId of - true -> - InsertId = mysql:insert_id(ConnPid), - {ok, InsertId}; - false -> - ok - end; - Error -> - Error - end. - --spec update_by(ConnPid :: pid(), UpdateSql :: binary()) -> {ok, AffectedRows :: integer()} | {error, Reason :: any()}. -update_by(ConnPid, UpdateSql) when is_pid(ConnPid), is_binary(UpdateSql) -> - lager:debug("[mysql_client] updateBySql sql: ~p", [UpdateSql]), - case mysql:query(ConnPid, UpdateSql) of - ok -> - AffectedRows = mysql:affected_rows(ConnPid), - {ok, AffectedRows}; - Error -> - Error - end. - --spec update_by(ConnPid :: pid(), UpdateSql :: binary(), Params :: list()) -> {ok, AffectedRows :: integer()} | {error, Reason :: any()}. -update_by(ConnPid, UpdateSql, Params) when is_pid(ConnPid), is_binary(UpdateSql) -> - lager:debug("[mysql_client] updateBySql sql: ~p, params: ~p", [UpdateSql, Params]), - case mysql:query(ConnPid, UpdateSql, Params) of - ok -> - AffectedRows = mysql:affected_rows(ConnPid), - {ok, AffectedRows}; - Error -> - Error - end. - --spec update(ConnPid :: pid(), Sql :: binary(), Fields :: map(), WhereFields :: map()) -> - {ok, AffectedRows::integer()} | {error, Reason::any()}. -update(ConnPid, Table, Fields, WhereFields) when is_pid(ConnPid), is_binary(Table), is_map(Fields), is_map(WhereFields) -> - %% 拼接set - {SetKeys, SetVals} = kvs(Fields), - SetKeys1 = lists:map(fun(K) when is_binary(K) -> <<"`", K/binary, "` = ?">> end, SetKeys), - SetSql = iolist_to_binary(lists:join(<<", ">>, SetKeys1)), - - %% 拼接where - {WhereKeys, WhereVals} = kvs(WhereFields), - WhereKeys1 = lists:map(fun(K) when is_binary(K) -> <<"`", K/binary, "` = ?">> end, WhereKeys), - WhereSql = iolist_to_binary(lists:join(<<" AND ">>, WhereKeys1)), - - Params = SetVals ++ WhereVals, - - Sql = <<"UPDATE ", Table/binary, " SET ", SetSql/binary, " WHERE ", WhereSql/binary>>, - lager:debug("[mysql_client] update sql is: ~p, params: ~p", [Sql, Params]), - case mysql:query(ConnPid, Sql, Params) of - ok -> - AffectedRows = mysql:affected_rows(ConnPid), - {ok, AffectedRows}; - Error -> - lager:error("[mysql_client] update sql: ~p, params: ~p, get a error: ~p", [Sql, Params, Error]), - Error - end. - --spec kvs(Fields :: map() | list()) -> {Keys :: list(), Values :: list()}. -kvs(Fields) when is_map(Fields) -> - kvs(maps:to_list(Fields)); -kvs(Fields) when is_list(Fields) -> - {Keys0, Values0} = lists:foldl(fun({K, V}, {Acc0, Acc1}) -> {[K|Acc0], [V|Acc1]} end, {[], []}, Fields), - {lists:reverse(Keys0), lists:reverse(Values0)}. \ No newline at end of file diff --git a/apps/iot/src/simulator/simulator_api_handler.erl b/apps/iot/src/simulator/simulator_api_handler.erl new file mode 100644 index 0000000..3605823 --- /dev/null +++ b/apps/iot/src/simulator/simulator_api_handler.erl @@ -0,0 +1,156 @@ +%%%------------------------------------------------------------------- +%%% @author Codex +%%% @doc +%%% 本地模拟 control api,用于替代 iot_api_client 依赖的远端接口。 +%%% @end +%%%------------------------------------------------------------------- +-module(simulator_api_handler). + +-include_lib("endpoint/include/endpoint.hrl"). + +-export([handle_request/4]). + +-define(HOST_UUID, <<"qbxmjyzrkpntfgswaevodhluicqzxplkm">>). +-define(HOST_ID, 1). +-define(DEVICE_UUID, <<"sim-device-001">>). +-define(DEVICE_ID, 1). +-define(ENDPOINT_ID, 1). +-define(TABLE, ?MODULE). + +-spec handle_request(string(), string(), map(), map()) -> + {ok, non_neg_integer(), iodata()}. +handle_request("GET", "/simulator/get_all_hosts", _, _) -> + {ok, 200, iot_util:json_data([?HOST_UUID])}; +handle_request("GET", "/simulator/get_host_by_uuid", #{<<"uuid">> := UUID}, _) when is_binary(UUID) -> + reply_host_by_uuid(UUID); +handle_request("GET", "/simulator/get_host_by_id", #{<<"host_id">> := HostIdBin}, _) when is_binary(HostIdBin) -> + case binary_to_integer(HostIdBin) of + ?HOST_ID -> + {ok, 200, iot_util:json_data(host_info())}; + _ -> + {ok, 200, iot_util:json_error(404, <<"host not found">>)} + end; +handle_request("POST", "/simulator/change_host_status", _, #{<<"uuid">> := UUID, <<"new_status">> := Status}) + when is_binary(UUID), is_integer(Status) -> + reply_change_host_status(UUID, Status); +handle_request("GET", "/simulator/get_host_devices", #{<<"host_id">> := HostIdBin}, _) when is_binary(HostIdBin) -> + case binary_to_integer(HostIdBin) of + ?HOST_ID -> + {ok, 200, iot_util:json_data([device_info()])}; + _ -> + {ok, 200, iot_util:json_error(404, <<"host not found">>)} + end; +handle_request("GET", "/simulator/get_device_by_uuid", #{<<"device_uuid">> := DeviceUUID}, _) when is_binary(DeviceUUID) -> + case DeviceUUID of + ?DEVICE_UUID -> + {ok, 200, iot_util:json_data(device_info())}; + _ -> + {ok, 200, iot_util:json_error(404, <<"device not found">>)} + end; +handle_request("POST", "/simulator/change_device_status", _, #{<<"device_uuid">> := DeviceUUID, <<"new_status">> := Status}) + when is_binary(DeviceUUID), is_integer(Status) -> + reply_change_device_status(DeviceUUID, Status); +handle_request("GET", "/simulator/get_all_endpoints", _, _) -> + {ok, 200, iot_util:json_data([endpoint_info()])}; +handle_request("GET", "/simulator/get_endpoint", #{<<"id">> := IdBin}, _) when is_binary(IdBin) -> + case binary_to_integer(IdBin) of + ?ENDPOINT_ID -> + {ok, 200, iot_util:json_data(endpoint_info())}; + _ -> + {ok, 200, iot_util:json_error(404, <<"endpoint not found">>)} + end; +handle_request("POST", "/simulator/endpoint_sink", _, Body) -> + logger:debug("[simulator_api_handler] receive endpoint sink body: ~p", [Body]), + {ok, 200, iot_util:json_data(<<"ok">>)}; +handle_request(_, Path, _, _) -> + PathBin = list_to_binary(Path), + {ok, 200, iot_util:json_error(-1, <<"url: ", PathBin/binary, " not found">>)}. + +%%%=================================================================== +%%% Internal functions +%%%=================================================================== + +-spec reply_host_by_uuid(binary()) -> {ok, non_neg_integer(), iodata()}. +reply_host_by_uuid(?HOST_UUID) -> + {ok, 200, iot_util:json_data(host_info())}; +reply_host_by_uuid(_) -> + {ok, 200, iot_util:json_error(404, <<"host not found">>)}. + +-spec reply_change_host_status(binary(), integer()) -> {ok, non_neg_integer(), iodata()}. +reply_change_host_status(?HOST_UUID, Status) -> + ok = put_state({host_status, ?HOST_UUID}, Status), + {ok, 200, iot_util:json_data(1)}; +reply_change_host_status(_, _) -> + {ok, 200, iot_util:json_error(404, <<"host not found">>)}. + +-spec reply_change_device_status(binary(), integer()) -> {ok, non_neg_integer(), iodata()}. +reply_change_device_status(?DEVICE_UUID, Status) -> + ok = put_state({device_status, ?DEVICE_UUID}, Status), + {ok, 200, iot_util:json_data(1)}; +reply_change_device_status(_, _) -> + {ok, 200, iot_util:json_error(404, <<"device not found">>)}. + +-spec host_info() -> map(). +host_info() -> + #{ + <<"id">> => ?HOST_ID, + <<"uuid">> => ?HOST_UUID, + <<"authorize_status">> => 1, + <<"status">> => get_state({host_status, ?HOST_UUID}, 1) + }. + +-spec device_info() -> map(). +device_info() -> + #{ + <<"id">> => ?DEVICE_ID, + <<"host_id">> => ?HOST_ID, + <<"device_uuid">> => ?DEVICE_UUID, + <<"status">> => get_state({device_status, ?DEVICE_UUID}, 1) + }. + +-spec endpoint_info() -> map(). +endpoint_info() -> + #{ + <<"id">> => ?ENDPOINT_ID, + <<"matcher">> => <<"simulator.metric">>, + <<"title">> => <<"Simulator HTTP Endpoint">>, + <<"type">> => <<"http">>, + <<"config">> => #{ + <<"url">> => <<"http://127.0.0.1:18090/simulator/endpoint_sink">>, + <<"pool_size">> => 4 + }, + <<"status">> => 1, + <<"updated_at">> => iot_util:timestamp(), + <<"created_at">> => iot_util:timestamp() + }. + +-spec ensure_table() -> ets:tid(). +ensure_table() -> + case ets:info(?TABLE) of + undefined -> + try ets:new(?TABLE, [named_table, public, set]) of + Tid -> + Tid + catch + error:badarg -> + ?TABLE + end; + _ -> + ?TABLE + end. + +-spec get_state(term(), term()) -> term(). +get_state(Key, Default) -> + _ = ensure_table(), + case ets:lookup(?TABLE, Key) of + [{_, Value}] -> + Value; + [] -> + Default + end. + +-spec put_state(term(), term()) -> ok. +put_state(Key, Value) -> + _ = ensure_table(), + true = ets:insert(?TABLE, {Key, Value}), + ok. diff --git a/apps/iot/src/tcp/tcp_channel.erl b/apps/iot/src/tcp/tcp_channel.erl deleted file mode 100644 index cf344a6..0000000 --- a/apps/iot/src/tcp/tcp_channel.erl +++ /dev/null @@ -1,195 +0,0 @@ -%%%------------------------------------------------------------------- -%%% @author licheng5 -%%% @copyright (C) 2021, -%%% @doc -%%% -%%% @end -%%% Created : 11. 1月 2021 上午12:17 -%%%------------------------------------------------------------------- --module(tcp_channel). --author("licheng5"). --include("message.hrl"). --behaviour(ranch_protocol). - -%% API --export([pub/4, jsonrpc_call/3, command/3]). - --export([start_link/3, stop/2]). -%% gen_server callbacks --export([init/3, handle_call/3, handle_cast/2, handle_info/2, code_change/3, terminate/2]). - --record(state, { - transport, - socket, - - uuid :: undefined | binary(), - %% 用户进程id - host_pid = undefined, - %% 发送消息对应的id - packet_id = 1 :: integer(), - - %% 请求响应的对应关系 - inflight = #{} -}). - -%% 向通道中写入消息 --spec pub(Pid :: pid(), Topic :: binary(), Qos :: integer(), Content :: binary()) -> no_return(). -pub(Pid, Topic, Qos, Content) when is_pid(Pid), is_binary(Topic), is_integer(Qos), is_binary(Content) -> - gen_server:cast(Pid, {pub, Topic, Qos, Content}). - -%% 向通道中写入消息 --spec command(Pid :: pid(), CommandType :: integer(), Command :: binary()) -> no_return(). -command(Pid, CommandType, Command) when is_pid(Pid), is_integer(CommandType), is_binary(Command) -> - gen_server:cast(Pid, {command, CommandType, Command}). - -%% 向通道中写入消息 --spec jsonrpc_call(Pid :: pid(), ReceiverPid :: pid(), CallBin :: binary()) -> Ref :: reference(). -jsonrpc_call(Pid, ReceiverPid, CallBin) when is_pid(Pid), is_pid(ReceiverPid), is_binary(CallBin) -> - Ref = make_ref(), - gen_server:cast(Pid, {jsonrpc_call, ReceiverPid, Ref, CallBin}), - Ref. - -%% 关闭方法 --spec stop(Pid :: pid(), Reason :: any()) -> no_return(). -stop(undefined, _Reason) -> - ok; -stop(Pid, Reason) when is_pid(Pid) -> - gen_server:stop(Pid, Reason, 5000). - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%% 逻辑处理方法 -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -start_link(Ref, Transport, Opts) -> - {ok, proc_lib:spawn_link(?MODULE, init, [Ref, Transport, Opts])}. - -init(Ref, Transport, _Opts = []) -> - {ok, Socket} = ranch:handshake(Ref), - lager:debug("[sdlan_channel] get a new connection: ~p", [Socket]), - Transport:setopts(Socket, [binary, {active, true}, {packet, 4}]), - % erlang:start_timer(?PING_TICKER, self(), ping_ticker), - gen_server:enter_loop(?MODULE, [], #state{transport = Transport, socket = Socket}). - -handle_call(_Request, _From, State) -> - {reply, ok, State}. - -%% 发送消息, 基于pub/sub机制 -handle_cast({pub, Topic, Qos, Content}, State = #state{transport = Transport, socket = Socket}) -> - EncPub = message_codec:encode(?MESSAGE_PUB, #pub{topic = Topic, qos = Qos, content = Content}), - Transport:send(Socket, <>), - {noreply, State}; - -%% 发送Command消息 -handle_cast({command, CommandType, Command}, State = #state{transport = Transport, socket = Socket}) -> - EncCommand = message_codec:encode(?MESSAGE_COMMAND, #command{command_type = CommandType, command = Command}), - Transport:send(Socket, <>), - {noreply, State}; - -%% 推送消息 -handle_cast({jsonrpc_call, ReceiverPid, Ref, CallBin}, State = #state{transport = Transport, socket = Socket, packet_id = PacketId, inflight = Inflight}) -> - Transport:send(Socket, <>), - {noreply, State#state{packet_id = PacketId + 1, inflight = maps:put(PacketId, {ReceiverPid, Ref}, Inflight)}}. - -%% auth验证 -handle_info({tcp, Socket, <>}, State = #state{transport = Transport, socket = Socket}) -> - {ok, #auth_request{uuid = UUID, username = Username, token = Token, salt = Salt, timestamp = Timestamp}} = message_codec:decode(RequestBin), - lager:debug("[ws_channel] auth uuid: ~p", [UUID]), - case iot_auth:check(Username, Token, UUID, Salt, Timestamp) of - true -> - case iot_api:get_host_by_uuid(UUID) of - undefined -> - lager:warning("[ws_channel] uuid: ~p, user: ~p, host not found", [UUID, Username]), - {stop, State}; - {ok, _} -> - %% 尝试启动主机的服务进程 - {ok, HostPid} = iot_host_sup:ensured_host_started(UUID), - case iot_host:attach_channel(HostPid, self()) of - ok -> - %% 建立到host的monitor - erlang:monitor(process, HostPid), - AuthReplyBin = message_codec:encode(?MESSAGE_AUTH_REPLY, #auth_reply{code = 0, payload = <<"ok">>}), - Transport:send(Socket, <>), - - {noreply, State#state{uuid = UUID, host_pid = HostPid}}; - {denied, Reason} when is_binary(Reason) -> - erlang:monitor(process, HostPid), - AuthReplyBin = message_codec:encode(?MESSAGE_AUTH_REPLY, #auth_reply{code = 1, payload = Reason}), - Transport:send(Socket, <>), - lager:debug("[ws_channel] uuid: ~p, attach channel get error: ~p, stop channel", [UUID, Reason]), - - {noreply, State#state{uuid = UUID, host_pid = HostPid}}; - - {error, Reason} when is_binary(Reason) -> - AuthReplyBin = message_codec:encode(?MESSAGE_AUTH_REPLY, #auth_reply{code = 2, payload = Reason}), - Transport:send(Socket, <>), - lager:debug("[ws_channel] uuid: ~p, attach channel get error: ~p, stop channel", [UUID, Reason]), - - {stop, State} - end - end; - false -> - lager:warning("[ws_channel] uuid: ~p, user: ~p, auth failed", [UUID, Username]), - {stop, State} - end; - -handle_info({tcp, Socket, <>}, State = #state{socket = Socket, host_pid = HostPid}) when is_pid(HostPid) -> - {ok, CastMessage} = message_codec:decode(CastBin), - case CastMessage of - #data{} = Data -> - iot_host:handle(HostPid, {data, Data}); - #task_event_stream{task_id = TaskId, type = <<"close">>, stream = Reason} -> - iot_event_stream_observer:stream_close(TaskId, Reason); - #task_event_stream{task_id = TaskId, type = Type, stream = Stream} -> - lager:debug("[tcp_channel] get task_id: ~p, type: ~ts, stream: ~ts", [TaskId, Type, Stream]), - iot_event_stream_observer:stream_data(TaskId, Type, Stream) - end, - {noreply, State}; - -%handle_info({tcp, Socket, <>}, State = #state{socket = Socket, host_pid = HostPid}) when is_pid(HostPid) -> -% Ping = message_pb:decode_msg(PingData, ping), -% iot_host:handle(HostPid, {ping, Ping}), -% {noreply, State}; - -%% 主机端的消息响应 -handle_info({tcp, Socket, <>}, State = #state{socket = Socket, inflight = Inflight}) when PacketId > 0 -> - {ok, RpcReply} = message_codec:decode(ResponseBin), - case maps:take(PacketId, Inflight) of - error -> - {noreply, State}; - {{ReceiverPid, Ref}, NInflight} -> - case is_pid(ReceiverPid) andalso is_process_alive(ReceiverPid) of - true -> - ReceiverPid ! {jsonrpc_reply, Ref, RpcReply}; - false -> - lager:warning("[ws_channel] get async_call_reply message: ~p, packet_id: ~p, but receiver_pid is deaded", [RpcReply, PacketId]) - end, - {noreply, State#state{inflight = NInflight}} - end; - -handle_info({tcp_error, Sock, Reason}, State = #state{socket = Sock}) -> - lager:notice("[sdlan_channel] tcp_error: ~p", [Reason]), - {stop, normal, State}; - -handle_info({tcp_closed, Sock}, State = #state{socket = Sock}) -> - lager:notice("[sdlan_channel] tcp_closed"), - {stop, normal, State}; - -%% 关闭当前通道 -handle_info({stop, Reason}, State) -> - {stop, Reason, State}; - -%% 主机进程挂掉的时候,需要关闭掉链接 -handle_info({'DOWN', _, process, HostPid, Reason}, State = #state{uuid = UUID, host_pid = HostPid}) -> - lager:debug("[ws_channel] uuid: ~p, channel will close because host exited with reason: ~p", [UUID, Reason]), - {stop, State}; - -handle_info(Info, State) -> - lager:warning("[sdlan_channel] get a unknown message: ~p, channel will closed, state: ~p", [Info, State]), - {noreply, State}. - -terminate(Reason, #state{}) -> - lager:warning("[sdlan_channel] stop with reason: ~p", [Reason]), - ok. - -code_change(_OldVsn, State, _Extra) -> - {ok, State}. \ No newline at end of file diff --git a/apps/iot/src/transport/ssl_channel.erl b/apps/iot/src/transport/ssl_channel.erl new file mode 100644 index 0000000..ddb510b --- /dev/null +++ b/apps/iot/src/transport/ssl_channel.erl @@ -0,0 +1,369 @@ +%%%------------------------------------------------------------------- +%%% @author licheng5 +%%% @copyright (C) 2021, +%%% @doc +%%% +%%% @end +%%% Created : 11. 1月 2021 上午12:17 +%%%------------------------------------------------------------------- +-module(ssl_channel). +-author("licheng5"). +-behaviour(ranch_protocol). + +-define(INFLIGHT_TIMEOUT, 60000). +-define(SSL_IDLE_TIMEOUT, 120000). + +%% API +-export([pub/4, container_call/4, cancel_command_call/2]). + +-export([start_link/3, stop/2]). +%% gen_server callbacks +-export([init/3, handle_call/3, handle_cast/2, handle_info/2, code_change/3, terminate/2]). + +-record(state, { + transport, + socket, + + uuid :: undefined | binary(), + is_authed = false :: boolean(), + %% 用户进程id + host_pid = undefined, + + %% iot 发起的 command 与 command_response 的对应关系 + inflight = #{}, + + idle_timer_ref :: undefined | reference() +}). + +-record(inflight_command, { + receiver_pid :: undefined | pid(), + timer_ref :: reference() +}). + +-type request_ref() :: binary(). + +%% 向通道中写入消息 +-spec pub(Pid :: pid(), Topic :: binary(), Qos :: integer(), Content :: binary()) -> ok. +pub(Pid, Topic, Qos, Content) when is_pid(Pid), is_binary(Topic), is_integer(Qos), is_binary(Content) -> + gen_server:cast(Pid, {pub, Topic, Qos, Content}). + +-spec container_call(Pid :: pid(), ReceiverPid :: pid(), Ref :: request_ref(), Request :: map()) -> ok. +container_call(Pid, ReceiverPid, Ref, Request) when is_pid(Pid), is_pid(ReceiverPid), is_binary(Ref), is_map(Request) -> + gen_server:cast(Pid, {command_call, ReceiverPid, Ref, {container, Request}}), + ok. + +-spec cancel_command_call(Pid :: pid(), Ref :: request_ref()) -> ok. +cancel_command_call(Pid, Ref) when is_pid(Pid), is_binary(Ref) -> + gen_server:call(Pid, {cancel_command_call, Ref}). + +%% 关闭方法 +-spec stop(Pid :: undefined | pid(), Reason :: any()) -> ok. +stop(undefined, _Reason) -> + ok; +stop(Pid, Reason) when is_pid(Pid) -> + gen_server:stop(Pid, Reason, 5000). + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%% 逻辑处理方法 +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +start_link(Ref, Transport, Opts) -> + {ok, proc_lib:spawn_link(?MODULE, init, [Ref, Transport, Opts])}. + +init(Ref, Transport, _Opts = []) -> + ok = iot_log:set_metadata(), + {ok, Socket} = ranch:handshake(Ref), + logger:debug("[ssl_channel] get a new connection: ~p", [Socket]), + Transport:setopts(Socket, [binary, {active, true}, {packet, 4}]), + IdleTimerRef = start_idle_timer(), + gen_server:enter_loop(?MODULE, [], #state{transport = Transport, socket = Socket, idle_timer_ref = IdleTimerRef}). + +handle_call({cancel_command_call, Ref}, _From, State = #state{inflight = Inflight}) -> + case maps:take(Ref, Inflight) of + {#inflight_command{timer_ref = TimerRef}, NInflight} -> + erlang:cancel_timer(TimerRef), + {reply, ok, State#state{inflight = NInflight}}; + error -> + {reply, ok, State} + end; +handle_call(_Request, _From, State) -> + {reply, ok, State}. + +%% 发送消息, 基于pub/sub机制 +handle_cast({pub, Topic, Qos, Content}, State = #state{transport = Transport, socket = Socket}) -> + Packet = term_to_binary({<<"message">>, {<<"pub">>, #{<<"topic">> => Topic, <<"qos">> => Qos, <<"content">> => Content}}}), + case Transport:send(Socket, Packet) of + ok -> + {noreply, State}; + {error, Reason} -> + logger:warning("[ssl_channel] send pub failed, reason: ~p", [Reason]), + {stop, {send_failed, Reason}, State} + end; + +%% iot 请求 efka 时使用 command/command_response 语义。 +handle_cast({command_call, ReceiverPid, Ref, Body}, State = #state{transport = Transport, socket = Socket, inflight = Inflight}) -> + Packet = term_to_binary({<<"command">>, Ref, encode_command_body(Body)}), + case Transport:send(Socket, Packet) of + ok -> + TimerRef = erlang:start_timer(?INFLIGHT_TIMEOUT, self(), {command_timeout, Ref}), + CommandInfo = #inflight_command{receiver_pid = ReceiverPid, timer_ref = TimerRef}, + {noreply, State#state{inflight = maps:put(Ref, CommandInfo, Inflight)}}; + {error, Reason} -> + logger:warning("[ssl_channel] send command failed, ref: ~p, reason: ~p", [Ref, Reason]), + deliver_command_error(ReceiverPid, Ref, {send_failed, Reason}), + {stop, {send_failed, Reason}, State} + end. + +handle_info({timeout, TimerRef, {command_timeout, Ref}}, State = #state{inflight = Inflight}) -> + case maps:get(Ref, Inflight, undefined) of + #inflight_command{receiver_pid = ReceiverPid, timer_ref = TimerRef} -> + logger:warning("[ws_channel] command timeout, ref: ~p", [Ref]), + reply_command_timeout(ReceiverPid, Ref), + {noreply, State#state{inflight = maps:remove(Ref, Inflight)}}; + _ -> + {noreply, State} + end; + +handle_info({timeout, TimerRef, ssl_idle_timeout}, State = #state{idle_timer_ref = TimerRef}) -> + logger:notice("[ssl_channel] ssl channel idle timeout"), + {stop, ssl_idle_timeout, State#state{idle_timer_ref = undefined}}; +handle_info({timeout, _TimerRef, ssl_idle_timeout}, State) -> + {noreply, State}; + +%% 关闭当前通道 +handle_info({stop, Reason}, State) -> + {stop, Reason, State}; + +%% 主机进程挂掉的时候,需要关闭掉链接 +handle_info({'DOWN', _, process, HostPid, Reason}, State = #state{uuid = UUID, host_pid = HostPid}) -> + logger:debug("[ws_channel] uuid: ~p, channel will close because host exited with reason: ~p", [UUID, Reason]), + {stop, Reason, State}; + +handle_info({ssl, Socket, PacketBin}, State = #state{socket = Socket}) when is_binary(PacketBin) -> + State1 = reset_idle_timer(State), + try binary_to_term(PacketBin, [safe]) of + {<<"request">>, Ref, Body} -> + handle_request_frame(Ref, Body, State1); + {<<"message">>, Body} -> + handle_message_frame(Body, State1); + {<<"command_response">>, Ref, Response} -> + handle_command_response_frame(Ref, Response, State1); + Other -> + logger:warning("[ssl_channel] unsupported packet: ~p", [Other]), + {stop, bad_packet, State1} + catch error:Error -> + logger:warning("[ssl_channel] binary_to_term get error: ~p, packet_size: ~p", [Error, byte_size(PacketBin)]), + {stop, bad_packet, State1} + end; + +handle_info({ssl_closed, Socket}, State = #state{socket = Socket}) -> + logger:notice("[ssl_channel] ssl socket closed"), + {stop, normal, State}; + +handle_info({ssl_error, Socket, Reason}, State = #state{socket = Socket}) -> + logger:notice("[ssl_channel] ssl socket error: ~p", [Reason]), + {stop, normal, State}; + +handle_info({ssl_passive, Socket}, State = #state{transport = Transport, socket = Socket}) -> + ok = Transport:setopts(Socket, [{active, true}]), + {noreply, State}; + +handle_info(Info, State) -> + logger:warning("[ssl_channel] get a unknown message: ~p, state: ~p", [Info, State]), + {noreply, State}. + +terminate(Reason, #state{inflight = Inflight, transport = Transport, socket = Socket, idle_timer_ref = IdleTimerRef}) -> + cancel_timer(IdleTimerRef), + maps:foreach(fun(Ref, CommandInfo) -> reply_command_closed(Ref, CommandInfo, Reason) end, Inflight), + Transport:close(Socket), + logger:warning("[ssl_channel] stop with reason: ~p", [Reason]), + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%%% helper methods +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +-spec handle_request_frame(request_ref(), tuple(), #state{}) -> {noreply, #state{}} | {stop, term(), #state{}}. +handle_request_frame(Ref, {<<"auth_request">>, #{<<"uuid">> := UUID}}, State = #state{is_authed = true}) -> + logger:warning("[ws_channel] repeated auth request, ref: ~p, uuid: ~p, close channel", [Ref, UUID]), + {stop, repeated_auth, State}; +handle_request_frame(Ref, {<<"auth_request">>, #{<<"uuid">> := UUID, <<"token">> := Token, <<"timestamp">> := Timestamp}}, State = #state{transport = Transport, socket = Socket}) -> + maybe + ok ?= auth(Token, UUID, Timestamp), + {ok, HostPid} ?= iot_host:lookup_pid(UUID), + ok ?= iot_host:attach_channel(HostPid, self()), + erlang:monitor(process, HostPid), + ok = send_reply_frame(Transport, Socket, Ref, {<<"auth_response">>, <<"ok">>}), + logger:debug("[ws_channel] auth uuid: ~p", [UUID]), + {noreply, State#state{uuid = UUID, is_authed = true, host_pid = HostPid}} + else + {error, Reason} -> + logger:warning("[ws_channel] uuid: ~p, auth failed with reason: ~p", [UUID, Reason]), + case send_reply_frame(Transport, Socket, Ref, {<<"auth_response">>, {<<"error">>, {<<"failed">>, Reason}}}) of + ok -> + {stop, Reason, State}; + {error, SendReason} -> + logger:warning("[ws_channel] uuid: ~p, send auth failed response failed: ~p", [UUID, SendReason]), + {stop, {send_failed, SendReason}, State} + end + end; +handle_request_frame(Ref, {<<"container">>, ContainerCommand}, State) -> + logger:warning("[ws_channel] unsupported request message type: container, ref: ~p, command: ~p", [Ref, ContainerCommand]), + {stop, normal, State}; +handle_request_frame(Ref, Body, State) -> + logger:warning("[ws_channel] unsupported request body, ref: ~p, body: ~p", [Ref, Body]), + {stop, normal, State}. + +-spec handle_message_frame(term(), #state{}) -> + {noreply, #state{}} | {stop, term(), #state{}}. +handle_message_frame(<<"ping">>, State = #state{transport = Transport, socket = Socket}) -> + Packet = term_to_binary({<<"message">>, <<"pong">>}), + case Transport:send(Socket, Packet) of + ok -> + {noreply, State}; + {error, Reason} -> + logger:warning("[ssl_channel] send pong failed, reason: ~p", [Reason]), + {stop, {send_failed, Reason}, State} + end; +handle_message_frame({<<"data">>, #{<<"route_key">> := RouteKey, <<"metric">> := Metric}}, State = #state{host_pid = HostPid}) when is_pid(HostPid) -> + iot_host:handle(HostPid, {data, RouteKey, Metric}), + {noreply, State}; +handle_message_frame({<<"task_event">>, Event}, State = #state{uuid = UUID, host_pid = HostPid}) when is_binary(UUID), is_pid(HostPid) -> + handle_event_stream_frame(UUID, Event), + {noreply, State}; +handle_message_frame(Body, State) -> + logger:warning("[ssl_channel] unsupported message body: ~p", [Body]), + {noreply, State}. + +-spec handle_event_stream_frame(binary(), map()) -> ok. +handle_event_stream_frame(UUID, #{<<"task_id">> := TaskId, <<"type">> := <<"close">>, <<"stream">> := Reason}) -> + logger:debug("[ssl_channel] get uuid: ~p, task_id: ~p, close with reason: ~p", [UUID, TaskId, Reason]), + iot_container_task_sup:close(UUID, TaskId, Reason); +handle_event_stream_frame(UUID, #{<<"task_id">> := TaskId, <<"type">> := Type, <<"stream">> := Stream}) -> + logger:debug("[ssl_channel] get uuid: ~p, task_id: ~p, type: ~ts, stream: ~ts", [UUID, TaskId, Type, Stream]), + iot_container_task_sup:stream(UUID, TaskId, Type, Stream); +handle_event_stream_frame(UUID, Event) -> + logger:warning("[ssl_channel] invalid task_event, uuid: ~p, event: ~p", [UUID, Event]), + ok. + +-spec handle_command_response_frame(request_ref(), tuple(), #state{}) -> + {noreply, #state{}}. +handle_command_response_frame(Ref, Reply, State = #state{inflight = Inflight}) when is_binary(Ref) -> + case maps:take(Ref, Inflight) of + error -> + {noreply, State}; + {#inflight_command{receiver_pid = ReceiverPid, timer_ref = TimerRef}, NInflight} -> + erlang:cancel_timer(TimerRef), + deliver_command_response(ReceiverPid, Ref, Reply), + {noreply, State#state{inflight = NInflight}} + end; +handle_command_response_frame(Ref, Reply, State) -> + logger:warning("[ws_channel] unexpected command_response frame, ref: ~p, reply: ~p", [Ref, Reply]), + {noreply, State}. + +-spec send_reply_frame(module(), any(), request_ref(), tuple()) -> ok | {error, term()}. +send_reply_frame(Transport, Socket, Ref, Reply) -> + Packet = term_to_binary({<<"response">>, Ref, Reply}), + Transport:send(Socket, Packet). + +-spec decode_command_response({binary(), term()} | tuple()) -> + ok | {ok, term()} | {error, term()}. +decode_command_response({<<"container">>, <<"ok">>}) -> + ok; +decode_command_response({<<"container">>, {<<"ok">>, Result}}) -> + {ok, Result}; +decode_command_response({<<"container">>, {<<"error">>, Reason}}) -> + {error, Reason}; +decode_command_response(_Reply) -> + {error, invalid_response}. + +-spec deliver_command_response(undefined | pid(), request_ref(), tuple()) -> ok. +deliver_command_response(undefined, _Ref, _Reply) -> + ok; +deliver_command_response(ReceiverPid, Ref, Reply) when is_pid(ReceiverPid) -> + case is_process_alive(ReceiverPid) of + true -> + ReceiverPid ! {command_reply, Ref, decode_command_response(Reply)}; + false -> + logger:warning("[ws_channel] get command_response: ~p, ref: ~p, but receiver_pid is deaded", [Reply, Ref]) + end. + +-spec deliver_command_error(undefined | pid(), request_ref(), term()) -> ok. +deliver_command_error(ReceiverPid, Ref, Reason) when is_pid(ReceiverPid) -> + ReceiverPid ! {command_reply, Ref, {error, Reason}}, + ok; +deliver_command_error(_ReceiverPid, _Ref, _Reason) -> + ok. + +-spec reply_command_timeout(undefined | pid(), request_ref()) -> ok. +reply_command_timeout(ReceiverPid, Ref) when is_pid(ReceiverPid) -> + ReceiverPid ! {command_reply, Ref, {error, timeout}}, + ok; +reply_command_timeout(_ReceiverPid, _Ref) -> + ok. + +-spec reply_command_closed(request_ref(), #inflight_command{}, term()) -> ok. +reply_command_closed(Ref, #inflight_command{receiver_pid = ReceiverPid}, Reason) when is_pid(ReceiverPid) -> + ReceiverPid ! {command_reply, Ref, {error, {channel_closed, Reason}}}, + ok; +reply_command_closed(_Ref, _CommandInfo, _Reason) -> + ok. + +%% 检测token是否是合法值 +-spec auth(Token :: binary(), UUID :: binary(), Timestamp :: integer()) -> + ok | {error, Reason :: binary()}. +auth(Token, UUID, Timestamp) when is_binary(Token), is_binary(UUID), is_integer(Timestamp) -> + %% 1分钟内有效 + Now = iot_util:current_time(), + case Timestamp =< Now andalso Now - Timestamp =< 60 of + true -> + case efka_client_store:auth(UUID, Token) of + true -> + ok; + false -> + {error, <<"invalid token">>} + end; + false -> + {error, <<"invalid timestamp">>} + end. + +-spec encode_command_body({container, map()}) -> {binary(), map()}. +encode_command_body({container, Request}) -> + {<<"container">>, safe_term(Request)}. + +-spec safe_term(term()) -> term(). +safe_term(true) -> + true; +safe_term(false) -> + false; +safe_term(undefined) -> + undefined; +safe_term(Value) when is_atom(Value) -> + atom_to_binary(Value, utf8); +safe_term(Value) when is_map(Value) -> + maps:from_list([{safe_term(K), safe_term(V)} || {K, V} <- maps:to_list(Value)]); +safe_term(Value) when is_list(Value) -> + [safe_term(Item) || Item <- Value]; +safe_term(Value) when is_tuple(Value) -> + list_to_tuple([safe_term(Item) || Item <- tuple_to_list(Value)]); +safe_term(Value) -> + Value. + +-spec start_idle_timer() -> reference(). +start_idle_timer() -> + erlang:start_timer(?SSL_IDLE_TIMEOUT, self(), ssl_idle_timeout). + +-spec reset_idle_timer(#state{}) -> #state{}. +reset_idle_timer(State = #state{idle_timer_ref = TimerRef}) -> + cancel_timer(TimerRef), + State#state{idle_timer_ref = start_idle_timer()}. + +-spec cancel_timer(undefined | reference()) -> ok. +cancel_timer(undefined) -> + ok; +cancel_timer(TimerRef) -> + _ = erlang:cancel_timer(TimerRef), + ok. diff --git a/apps/iot/src/iot_event_stream_observer.erl b/apps/iot/src/transport/udp_server.erl similarity index 57% rename from apps/iot/src/iot_event_stream_observer.erl rename to apps/iot/src/transport/udp_server.erl index 043b90d..db0657d 100644 --- a/apps/iot/src/iot_event_stream_observer.erl +++ b/apps/iot/src/transport/udp_server.erl @@ -1,19 +1,22 @@ %%%------------------------------------------------------------------- %%% @author anlicheng -%%% @copyright (C) 2025, +%%% @copyright (C) 2026, %%% @doc %%% %%% @end -%%% Created : 26. 9月 2025 12:19 +%%% Created : 09. 5月 2026 18:02 %%%------------------------------------------------------------------- --module(iot_event_stream_observer). +-module(udp_server). -author("anlicheng"). -behaviour(gen_server). +-define(HEARTBEAT_VERSION, 1). +-define(HEARTBEAT_NONCE_BYTES, 16). +-define(HEARTBEAT_MAC_BYTES, 32). + %% API -export([start_link/0]). --export([add_listener/2, stream_data/3, stream_close/2]). %% gen_server callbacks -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). @@ -21,25 +24,13 @@ -define(SERVER, ?MODULE). -record(state, { - listeners = #{} + socket :: gen_udp:socket() }). %%%=================================================================== %%% API %%%=================================================================== --spec add_listener(ListenerPid :: pid(), TaskId :: integer()) -> ok. -add_listener(ListenerPid, TaskId) when is_pid(ListenerPid), is_integer(TaskId) -> - gen_server:call(?SERVER, {add_listener, ListenerPid, TaskId}). - --spec stream_data(TaskId :: integer(), Type :: binary(), Stream :: binary()) -> no_return(). -stream_data(TaskId, Type, Stream) when is_integer(TaskId), is_binary(Type), is_binary(Stream) -> - gen_server:cast(?SERVER, {stream_data, TaskId, Type, Stream}). - --spec stream_close(TaskId :: integer(), Reason :: binary()) -> no_return(). -stream_close(TaskId, Reason) when is_integer(TaskId), is_binary(Reason) -> - gen_server:cast(?SERVER, {stream_close, TaskId, Reason}). - %% @doc Spawns the server and registers the local name (unique) -spec(start_link() -> {ok, Pid :: pid()} | ignore | {error, Reason :: term()}). @@ -56,7 +47,12 @@ start_link() -> {ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} | {stop, Reason :: term()} | ignore). init([]) -> - {ok, #state{}}. + ok = iot_log:set_metadata(), + {ok, UdpServerProps} = application:get_env(iot, udp_server), + Port = proplists:get_value(port, UdpServerProps), + logger:debug("[udp_server] start at port: ~p", [Port]), + {ok, Socket} = gen_udp:open(Port, [binary, {active, true}]), + {ok, #state{socket = Socket}}. %% @private %% @doc Handling call messages @@ -68,9 +64,8 @@ init([]) -> {noreply, NewState :: #state{}, timeout() | hibernate} | {stop, Reason :: term(), Reply :: term(), NewState :: #state{}} | {stop, Reason :: term(), NewState :: #state{}}). -handle_call({add_listener, ListenerPid, TaskId}, _From, State = #state{listeners = Listeners}) -> - erlang:monitor(process, ListenerPid), - {reply, ok, State#state{listeners = maps:put(TaskId, ListenerPid, Listeners)}}. +handle_call(_Request, _From, State = #state{}) -> + {reply, ok, State}. %% @private %% @doc Handling cast messages @@ -78,21 +73,7 @@ handle_call({add_listener, ListenerPid, TaskId}, _From, State = #state{listeners {noreply, NewState :: #state{}} | {noreply, NewState :: #state{}, timeout() | hibernate} | {stop, Reason :: term(), NewState :: #state{}}). -handle_cast({stream_data, TaskId, Type, Stream}, State = #state{listeners = Listeners}) -> - case maps:find(TaskId, Listeners) of - error -> - ok; - {ok, ListenerPid} -> - is_process_alive(ListenerPid) andalso ListenerPid ! {stream_data, TaskId, Type, Stream} - end, - {noreply, State}; -handle_cast({stream_close, TaskId, Reason}, State = #state{listeners = Listeners}) -> - case maps:find(TaskId, Listeners) of - error -> - ok; - {ok, ListenerPid} -> - is_process_alive(ListenerPid) andalso ListenerPid ! {stream_close, TaskId, Reason} - end, +handle_cast(_Request, State = #state{}) -> {noreply, State}. %% @private @@ -101,9 +82,12 @@ handle_cast({stream_close, TaskId, Reason}, State = #state{listeners = Listeners {noreply, NewState :: #state{}} | {noreply, NewState :: #state{}, timeout() | hibernate} | {stop, Reason :: term(), NewState :: #state{}}). -handle_info({'DOWN', _Ref, process, Pid, _Reason}, State = #state{listeners = Listeners}) -> - NListeners = maps:filter(fun(_, ListenerPid) -> ListenerPid /= Pid end, Listeners), - {noreply, State#state{listeners = NListeners}}. +handle_info({udp, Socket, Ip, Port, Packet}, State = #state{socket = Socket}) -> + handle_heartbeat_packet(Packet, {Ip, Port}), + {noreply, State}; +handle_info(Info, State = #state{}) -> + logger:warning("[udp_server] ignore unknown info: ~p", [Info]), + {noreply, State}. %% @private %% @doc This function is called by a gen_server when it is about to @@ -112,7 +96,8 @@ handle_info({'DOWN', _Ref, process, Pid, _Reason}, State = #state{listeners = Li %% with Reason. The return value is ignored. -spec(terminate(Reason :: (normal | shutdown | {shutdown, term()} | term()), State :: #state{}) -> term()). -terminate(_Reason, _State = #state{}) -> +terminate(_Reason, _State = #state{socket = Socket}) -> + gen_udp:close(Socket), ok. %% @private @@ -126,3 +111,31 @@ code_change(_OldVsn, State = #state{}, _Extra) -> %%%=================================================================== %%% Internal functions %%%=================================================================== + +-spec handle_heartbeat_packet(binary(), term()) -> ok. +handle_heartbeat_packet(Packet, Peer) when is_binary(Packet) -> + case decode_heartbeat_packet(Packet) of + {ok, UUID, Timestamp, Payload, Mac} -> + case efka_client_store:verify_heartbeat(UUID, Timestamp, Payload, Mac) of + true -> + Pid = iot_host:get_pid(UUID), + iot_host:heartbeat(Pid); + false -> + logger:warning("[udp_server] ignore invalid heartbeat auth from peer: ~p, uuid: ~p", [Peer, UUID]), + ok + end; + error -> + logger:warning("[udp_server] ignore invalid heartbeat packet from peer: ~p, packet: ~p", [Peer, Packet]), + ok + end. + +-spec decode_heartbeat_packet(binary()) -> + {ok, UUID :: binary(), Timestamp :: integer(), Payload :: binary(), Mac :: binary()} | error. +decode_heartbeat_packet(Packet = <>) + when UUIDLen > 0 -> + PayloadSize = byte_size(Packet) - ?HEARTBEAT_MAC_BYTES, + <> = Packet, + {ok, UUID, Timestamp, Payload, Mac}; +decode_heartbeat_packet(_Packet) -> + error. diff --git a/apps/iot/src/util/http_client.erl b/apps/iot/src/util/http_client.erl deleted file mode 100644 index fb064ab..0000000 --- a/apps/iot/src/util/http_client.erl +++ /dev/null @@ -1,34 +0,0 @@ -%%%------------------------------------------------------------------- -%%% @author anlicheng -%%% @copyright (C) 2025, -%%% @doc -%%% -%%% @end -%%% Created : 18. 8月 2025 18:40 -%%%------------------------------------------------------------------- --module(http_client). --author("anlicheng"). - -%% API --export([post/3]). - -%% Headers = [ -%% {<<"content-type">>, <<"application/json">>} -%% ] --spec post(Url :: string(), Headers :: list(), Body :: binary()) -> {ok, RespBody :: binary()} | {error, Reason :: any()}. -post(Url, Headers, Body) when is_list(Url), is_list(Headers), is_binary(Body) -> - case hackney:request(post, Url, Headers, Body, [{pool, false}]) of - {ok, 200, _, ClientRef} -> - {ok, RespBody} = hackney:body(ClientRef), - lager:debug("[iot_api] send body: ~p, get error is: ~p", [Body, RespBody]), - hackney:close(ClientRef), - {ok, RespBody}; - {ok, HttpCode, _, ClientRef} -> - {ok, RespBody} = hackney:body(ClientRef), - hackney:close(ClientRef), - lager:warning("[iot_api] send body: ~p, get error is: ~p", [Body, {HttpCode, RespBody}]), - {error, {HttpCode, RespBody}}; - {error, Reason} -> - lager:warning("[iot_api] send body: ~p, get error is: ~p", [Body, Reason]), - {error, Reason} - end. \ No newline at end of file diff --git a/config/sys-dev.config b/config/sys-dev.config index 2d3a5df..5466f0c 100644 --- a/config/sys-dev.config +++ b/config/sys-dev.config @@ -2,114 +2,75 @@ {iot, [ {http_server, [ {port, 18090}, - {acceptors, 500}, - {max_connections, 10240}, - {backlog, 10240} + {acceptors, 5}, + {max_connections, 1024}, + {backlog, 1024} ]}, - {tcp_server, [ - {port, 18092}, - {acceptors, 500}, - {max_connections, 10240}, - {backlog, 10240} - ]}, - - {redis_server, [ - {port, 16379}, - {acceptors, 500}, - {max_connections, 10240}, - {backlog, 10240} + {ssl_server, [ + {port, 1443}, + {acceptors, 5}, + {max_connections, 1024}, + {backlog, 1024} ]}, {udp_server, [ {port, 18080} ]}, - {api_url, "http://100.123.0.4/api/v1"}, + {api_url, "http://127.0.0.1:18090/simulator"} + ]}, + + {endpoint, [ %% 支持的协议 {endpoints, [ + {root_dir, "/usr/local/code/database/"}, {support_protocols, [ http ]} ]}, - %% 目标服务器地址 - {emqx_server, [ - {host, {39, 98, 184, 67}}, - {port, 1883}, - {tcp_opts, []}, - {username, "test"}, - {password, "test1234"}, - {keepalive, 86400}, - {retry_interval, 5} - ]}, - - %% 权限检验时的预埋token - {pre_tokens, [ - {<<"test">>, <<"iot2023">>} + {endpoint_log, [ + {path, "${endpoint_root}/endpoint_log/unmatched_publish.log"}, + {max_bytes, 10485760}, + {max_files, 10} ]} - - %{pools, [ - % %% mysql连接池配置 - % {mysql_iot, - % [{size, 10}, {max_overflow, 20}, {worker_module, mysql}], - % [ - % {host, "47.111.101.3"}, - % {port, 3306}, - % {user, "root"}, - % {connect_mode, synchronous}, - % {keep_alive, true}, - % {password, "r3a-7Qrh#3Q"}, - % {database, "nannong_demo"} - % ] - % }, - - % %% redis连接池 - % {redis_pool, - % [{size, 10}, {max_overflow, 20}, {worker_module, eredis}], - % [ - % {host, "127.0.0.1"}, - % {port, 6379}, - % {database, 1} - % ] - % } - %]} - ]}, + %% 系统日志配置,使用 OTP logger + {kernel, [ + %% 设置 Logger 的 primary log level + {logger_level, debug}, + {logger, [ + {handler, default, logger_std_h, + #{ + level => debug, + filter_default => stop, + filters => [ + {iot_domain, {fun logger_filters:domain/2, {log, sub, [iot]}}}, + {endpoint_domain, {fun logger_filters:domain/2, {log, sub, [endpoint]}}} + ], + formatter => {logger_formatter, #{template => [time, " [", level, "] ", msg, "\n"]}} + } + }, - %% 系统日志配置,系统日志为lager, 支持日志按日期自动分割 - {lager, [ - {colored, true}, - %% Whether to write a crash log, and where. Undefined means no crash logger. - {crash_log, "trade_hub.crash.log"}, - %% Maximum size in bytes of events in the crash log - defaults to 65536 - {crash_log_msg_size, 65536}, - %% Maximum size of the crash log in bytes, before its rotated, set - %% to 0 to disable rotation - default is 0 - {crash_log_size, 10485760}, - %% What time to rotate the crash log - default is no time - %% rotation. See the README for a description of this format. - {crash_log_date, "$D0"}, - %% Number of rotated crash logs to keep, 0 means keep only the - %% current one - default is 0 - {crash_log_count, 5}, - %% Whether to redirect error_logger messages into lager - defaults to true - {error_logger_redirect, true}, + {handler, disk, logger_disk_log_h, + #{ + level => debug, + filter_default => stop, + filters => [ + {iot_domain, {fun logger_filters:domain/2, {log, sub, [iot]}}}, + {endpoint_domain, {fun logger_filters:domain/2, {log, sub, [endpoint]}}} + ], + config => #{ + file => "log/debug.log", + max_no_files => 10, + max_no_bytes => 524288000 + }, + formatter => {logger_formatter, #{template => [time, " [", level, "] ", msg, "\n"]}} + } + } - %% How big the gen_event mailbox can get before it is switched into sync mode - {async_threshold, 20}, - %% Switch back to async mode, when gen_event mailbox size decrease from `async_threshold' - %% to async_threshold - async_threshold_window - {async_threshold_window, 5}, - - {handlers, [ - %% debug | info | warning | error, 日志级别 - {lager_console_backend, debug}, - {lager_file_backend, [{file, "debug.log"}, {level, debug}, {size, 314572800}]}, - {lager_file_backend, [{file, "notice.log"}, {level, notice}, {size, 314572800}]}, - {lager_file_backend, [{file, "error.log"}, {level, error}, {size, 314572800}]}, - {lager_file_backend, [{file, "info.log"}, {level, info}, {size, 314572800}]} ]} ]} diff --git a/config/sys-prod.config b/config/sys-prod.config index 6bef008..c5973b9 100644 --- a/config/sys-prod.config +++ b/config/sys-prod.config @@ -2,83 +2,74 @@ {iot, [ {http_server, [ {port, 18080}, - {acceptors, 500}, - {max_connections, 10240}, - {backlog, 10240} + {acceptors, 5}, + {max_connections, 1024}, + {backlog, 1024} ]}, - {redis_server, [ - {port, 16379}, - {acceptors, 500}, - {max_connections, 10240}, - {backlog, 10240} + {ssl_server, [ + {port, 1443}, + {acceptors, 5}, + {max_connections, 1024}, + {backlog, 1024} ]}, {udp_server, [ {port, 18080} ]}, - %% 权限检验时的预埋token - {pre_tokens, [ - {<<"test">>, <<"iot2023">>} - ]}, - - {api_url, "https://lgsiot.njau.edu.cn/api/v1/taskLog"}, - - {influxdb, [ - {host, "172.19.0.4"}, - {port, 8086}, - {token, <<"A-ZRjqMK_7NR45lXXEiR7AEtYCd1ETzq9Z61FTMQLb5O4-1hSf8sCrjdPB84e__xsrItKHL3qjJALgbYN-H_VQ==">>} - ]} - - %{pools, [ - % %% redis连接池 - % {redis_pool, - % [{size, 10}, {max_overflow, 20}, {worker_module, eredis}], - % [ - % {host, "172.30.6.175"}, - % {port, 26379}, - % {database, 1} - % ] - % } - - %]} - + {api_url, "https://lgsiot.njau.edu.cn/api/v1/taskLog"} ]}, + {endpoint, [ + {endpoints, [ + {root_dir, "/var/lib/endpoint/database/"}, + {support_protocols, [ + http + ]} + ]}, - %% 系统日志配置,系统日志为lager, 支持日志按日期自动分割 - {lager, [ - {colored, true}, - %% Whether to write a crash log, and where. Undefined means no crash logger. - {crash_log, "trade_hub.crash.log"}, - %% Maximum size in bytes of events in the crash log - defaults to 65536 - {crash_log_msg_size, 65536}, - %% Maximum size of the crash log in bytes, before its rotated, set - %% to 0 to disable rotation - default is 0 - {crash_log_size, 10485760}, - %% What time to rotate the crash log - default is no time - %% rotation. See the README for a description of this format. - {crash_log_date, "$D0"}, - %% Number of rotated crash logs to keep, 0 means keep only the - %% current one - default is 0 - {crash_log_count, 5}, - %% Whether to redirect error_logger messages into lager - defaults to true - {error_logger_redirect, true}, + {endpoint_log, [ + {path, "/var/lib/endpoint/endpoint_log/unmatched_publish.log"}, + {max_bytes, 10485760}, + {max_files, 10} + ]} + ]}, - %% How big the gen_event mailbox can get before it is switched into sync mode - {async_threshold, 20}, - %% Switch back to async mode, when gen_event mailbox size decrease from `async_threshold' - %% to async_threshold - async_threshold_window - {async_threshold_window, 5}, + %% 系统日志配置,使用 OTP logger + {kernel, [ + %% 设置 Logger 的 primary log level + {logger_level, debug}, + {logger, [ + {handler, default, logger_std_h, + #{ + level => debug, + filter_default => stop, + filters => [ + {iot_domain, {fun logger_filters:domain/2, {log, sub, [iot]}}}, + {endpoint_domain, {fun logger_filters:domain/2, {log, sub, [endpoint]}}} + ], + formatter => {logger_formatter, #{template => [time, " [", level, "] ", msg, "\n"]}} + } + }, + + {handler, disk, logger_disk_log_h, + #{ + level => debug, + filter_default => stop, + filters => [ + {iot_domain, {fun logger_filters:domain/2, {log, sub, [iot]}}}, + {endpoint_domain, {fun logger_filters:domain/2, {log, sub, [endpoint]}}} + ], + config => #{ + file => "log/debug.log", + max_no_files => 10, + max_no_bytes => 524288000 + }, + formatter => {logger_formatter, #{template => [time, " [", level, "] ", msg, "\n"]}} + } + } - {handlers, [ - %% debug | info | warning | error, 日志级别 - {lager_console_backend, debug}, - {lager_file_backend, [{file, "debug.log"}, {level, debug}, {size, 314572800}]}, - {lager_file_backend, [{file, "notice.log"}, {level, notice}, {size, 314572800}]}, - {lager_file_backend, [{file, "error.log"}, {level, error}, {size, 314572800}]}, - {lager_file_backend, [{file, "info.log"}, {level, info}, {size, 314572800}]} ]} ]} diff --git a/config/sys-test.config b/config/sys-test.config new file mode 100644 index 0000000..acf4650 --- /dev/null +++ b/config/sys-test.config @@ -0,0 +1,77 @@ +[ + {iot, [ + {http_server, [ + {port, 18090}, + {acceptors, 5}, + {max_connections, 1024}, + {backlog, 1024} + ]}, + + {ssl_server, [ + {port, 1443}, + {acceptors, 5}, + {max_connections, 1024}, + {backlog, 1024} + ]}, + + {udp_server, [ + {port, 18080} + ]}, + + {api_url, "http://127.0.0.1/api/v1"} + ]}, + + {endpoint, [ + %% 支持的协议 + {endpoints, [ + {root_dir, "/usr/local/code/database/"}, + {support_protocols, [ + http + ]} + ]}, + + {endpoint_log, [ + {path, "${endpoint_root}/endpoint_log/unmatched_publish.log"}, + {max_bytes, 10485760}, + {max_files, 10} + ]} + ]}, + + %% 系统日志配置,使用 OTP logger + {kernel, [ + %% 设置 Logger 的 primary log level + {logger_level, debug}, + {logger, [ + {handler, default, logger_std_h, + #{ + level => debug, + filter_default => stop, + filters => [ + {iot_domain, {fun logger_filters:domain/2, {log, sub, [iot]}}}, + {endpoint_domain, {fun logger_filters:domain/2, {log, sub, [endpoint]}}} + ], + formatter => {logger_formatter, #{template => [time, " [", level, "] ", msg, "\n"]}} + } + }, + + {handler, disk, logger_disk_log_h, + #{ + level => debug, + filter_default => stop, + filters => [ + {iot_domain, {fun logger_filters:domain/2, {log, sub, [iot]}}}, + {endpoint_domain, {fun logger_filters:domain/2, {log, sub, [endpoint]}}} + ], + config => #{ + file => "log/debug.log", + max_no_files => 10, + max_no_bytes => 524288000 + }, + formatter => {logger_formatter, #{template => [time, " [", level, "] ", msg, "\n"]}} + } + } + + ]} + ]} + +]. diff --git a/config/vm.args b/config/vm.args index d7b21cd..bd45d19 100644 --- a/config/vm.args +++ b/config/vm.args @@ -5,7 +5,7 @@ +K true +A30 --mnesia dir '"/usr/local/var/mnesia/iot"' +-mnesia dir '"/var/lib/iot/mnesia/"' -mnesia dump_log_write_threshold 50000 -mnesia dc_dump_limit 40 diff --git a/docs/container_deploy_request.md b/docs/container_deploy_request.md new file mode 100644 index 0000000..00915d8 --- /dev/null +++ b/docs/container_deploy_request.md @@ -0,0 +1,670 @@ +# /container/deploy 容器创建请求参数说明 + +本文档说明 `iot` HTTP 接口 `POST /container/deploy` 当前支持的 JSON 参数、校验规则,以及服务端把 JSON 解码成 Erlang map 后如何转换成下发给 `efka` 的容器部署命令。 + +对应代码: + +- HTTP 入口:[src/transport/http/container_handler.erl](/usr/local/code/cloudkit/iot/src/transport/http/container_handler.erl:63) +- HTTP JSON 解析:[src/transport/http/http_protocol.erl](/usr/local/code/cloudkit/iot/src/transport/http/http_protocol.erl:68) +- 参数校验与内部 map 构造:[src/docker/docker_container_builder.erl](/usr/local/code/cloudkit/iot/src/docker/docker_container_builder.erl:25) +- 主机命令下发:[src/host/iot_host.erl](/usr/local/code/cloudkit/iot/src/host/iot_host.erl:99) + +## 1. 接口 + +```http +POST /container/deploy +Content-Type: application/json +``` + +HTTP body 必须是 JSON object。`http_protocol` 会使用 `json:decode/1` 解析请求体: + +- JSON object -> Erlang map +- JSON array -> Erlang list +- JSON string -> Erlang binary +- JSON integer -> Erlang integer +- JSON float -> Erlang float +- JSON boolean -> Erlang `true | false` + +`/container/deploy` handler 只接受顶层 map,且必须匹配: + +```erlang +#{<<"uuid">> := UUID, <<"task_id">> := TaskId, <<"config">> := Config} +``` + +其中: + +- `UUID` 必须是 binary,也就是 JSON string。 +- `TaskId` 必须是 integer。 +- `Config` 必须是 map,也就是 JSON object。 + +## 2. 完整 JSON 格式 + +下面示例包含当前支持的所有字段。实际请求可以只传必填字段和需要的可选字段。 + +```json +{ + "uuid": "qbxmjyzrkpntfgswaevodhluicqzxplkm", + "task_id": 1001, + "config": { + "image": "docker.io/library/nginx:latest", + "container_name": "my_nginx", + "command": ["nginx", "-g", "daemon off;"], + "restart": "always", + "entrypoint": ["/docker-entrypoint.sh"], + "envs": ["ENV=prod", "TZ=Asia/Shanghai"], + "expose": ["80", "443/tcp", "53/udp"], + "volumes": ["/host/data:/data", "/host/log:/var/log:ro"], + "networks": ["bridge"], + "network_mode": "bridge", + "labels": { + "app": "nginx", + "env": "prod" + }, + "user": "www-data", + "working_dir": "/app", + "hostname": "myhost", + "privileged": false, + "cap_add": ["NET_ADMIN"], + "cap_drop": ["MKNOD"], + "devices": ["/dev/ttyUSB0:/dev/ttyUSB0:rwm"], + "mem_limit": "512m", + "mem_reservation": "256m", + "cpu_shares": 512, + "cpus": 1.5, + "ulimits": { + "nofile": "1024:2048" + }, + "sysctls": { + "net.ipv4.ip_forward": "1" + }, + "tmpfs": ["/tmp", "/run:rw,size=64m"], + "extra_hosts": ["host.docker.internal:host-gateway"], + "healthcheck": { + "test": ["CMD-SHELL", "curl -f http://localhost || exit 1"], + "interval": "30s", + "timeout": "10s", + "retries": 3 + } + } +} +``` + +## 3. 顶层参数 + +| 字段 | JSON 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `uuid` | string | 是 | 目标 efka 所属主机 UUID。服务端用它查找 `iot_host` 进程,不会下发到 efka。 | +| `task_id` | non-negative integer | 是 | 部署任务 ID。会进入内部命令的 `task_id` 字段,用于关联部署结果和部署日志流。`iot` 内部使用 `{uuid, task_id}` 作为任务唯一标识。 | +| `config` | object | 是 | 容器创建配置。会被校验并转换成内部部署参数 map。 | + +顶层没有 `timeout` 字段。当前 HTTP handler 等待 efka command response 的超时时间固定为 10 秒。 + +部署过程的实时反馈通过 SSE 读取: + +```http +GET /event_stream?uuid=&task_id= +``` + +`/container/deploy` 收到合法请求后会先在 `iot` 内部创建或复用 `{uuid, task_id}` 对应的任务进程,然后再向 `efka` 下发部署命令。SSE handler 订阅同一个任务进程;该进程会缓存最近的部署事件,支持多个页面同时订阅,并在收到 efka 的 close 事件后关闭 SSE。 + +## 4. config 参数 + +### 必填字段 + +| 字段 | JSON 类型 | 内部类型 | 说明 | +| --- | --- | --- | --- | +| `image` | string | binary | 镜像名。没有 tag 时 efka 部署逻辑会补 `:latest`。 | +| `container_name` | string | binary | 容器名称。 | +| `command` | string[] | binary list | 容器启动命令,对应 Docker create config 的 `cmd`。 | +| `restart` | string | binary | 重启策略,例如 `no`、`always`、`unless-stopped`、`on-failure`、`on-failure:3`。 | + +### 可选字段 + +| 字段 | JSON 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `entrypoint` | string[] | `[]` | Docker create config 的 `entrypoint`。 | +| `envs` | string[] | `[]` | 环境变量列表,例如 `["A=1"]`,对应 Docker create config 的 `env`。 | +| `expose` | string[] | `[]` | 容器暴露端口,只表示容器端口,不绑定宿主机端口。 | +| `ports` | string[] | `[]` | 宿主机到容器的端口映射,格式为 `host_port:container_port`,例如 `["8080:80", "443:443"]`。 | +| `volumes` | string[] | `[]` | volume bind 列表,格式见后文。 | +| `networks` | string[] | `[]` | Docker network 名称列表,用于 networking config。 | +| `network_mode` | string | `""` | Docker host config 的 `network_mode`。 | +| `labels` | object string:string | `{}` | 容器 labels。key/value 都必须是 string。 | +| `user` | string | `""` | 容器运行用户。 | +| `working_dir` | string | `""` | 容器工作目录。 | +| `hostname` | string | `""` | 容器 hostname。 | +| `privileged` | boolean | `false` | 是否 privileged。 | +| `cap_add` | string[] | `[]` | 追加 Linux capability。 | +| `cap_drop` | string[] | `[]` | 删除 Linux capability。 | +| `devices` | string[] | `[]` | 设备映射列表,格式见后文。 | +| `mem_limit` | string | `0` | 内存上限,解析成字节数。 | +| `mem_reservation` | string | `0` | 内存软限制,解析成字节数。 | +| `cpu_shares` | non-negative integer | `0` | Docker CPU shares。 | +| `cpus` | non-negative number | `0` | CPU 数量,转换成 nano cpus。 | +| `ulimits` | object string:string | `[]` | ulimit 配置,格式见后文。 | +| `sysctls` | object string:string | `{}` | sysctl 配置。key/value 都必须是 string。 | +| `tmpfs` | string[] | `{}` | tmpfs mount 配置,格式见后文。 | +| `extra_hosts` | string[] | `[]` | 额外 hosts,例如 `["host.docker.internal:host-gateway"]`。 | +| `healthcheck` | object | `undefined` | 健康检查配置,格式见后文。 | + +### 不支持字段 + +| 字段 | 当前行为 | 说明 | +| --- | --- | --- | +| `container_dir` | 明确拒绝 | 容器目录由 efka 按系统默认规则管理,HTTP 调用方不能指定 efka 主机上的部署目录。 | +| `env_file` | 忽略 | 当前校验不会识别该字段,后续构造 Docker create options 时也不会使用。 | +| 其他未知字段 | 忽略 | 除 `container_dir` 外,未知字段不会报错,也不会进入内部部署参数。 | + +## 5. 校验规则 + +校验分为两层。 + +第一层在 `container_handler`: + +- `uuid` 必须是 binary。 +- `task_id` 必须是非负 integer。 +- `config` 必须是 map。 + +第二层在 `docker_container_builder:deploy_request/2`: + +- 先拒绝不支持的 `container_dir` 字段。 +- 检查必填字段是否存在。 +- 检查已知字段类型。 +- 检查 `healthcheck` 内部字段类型,避免无效嵌套字段进入构造阶段。 +- 构造内部部署 map。 +- 对需要解析的字段做格式解析,例如端口、volume、size、duration、ulimit。 + +类型错误会返回类似: + +```text +required parameter: <<"command">>, type must be: list of string +optional parameter: <<"labels">>, type must be: map of string:string +``` + +缺少必填字段会返回类似: + +```text +miss requied parameter: <<"image">> +``` + +注意:错误文本中的 `requied` 是当前代码里的原始拼写。 + +## 6. 内部转换结果 + +`config` 校验通过后,`docker_container_builder:deploy_request(TaskId, Config)` 生成: + +```erlang +#{ + <<"action">> => <<"deploy">>, + <<"task_id">> => TaskId, + <<"params">> => #{ + <<"container_name">> => ContainerName, + <<"create">> => #{ + <<"config">> => ContainerConfig, + <<"host_config">> => HostConfig, + <<"networking_config">> => NetworkingConfig + } + } +} +``` + +该 map 会通过 efka/iot 长连接协议下发: + +```erlang +{<<"command">>, Ref, {<<"container">>, #{ + <<"action">> => <<"deploy">>, + <<"task_id">> => TaskId, + <<"params">> => Params +}}} +``` + +efka 返回: + +```erlang +{<<"command_response">>, Ref, {<<"container">>, Reply}} +``` + +其中 `Ref` 是 `crypto:strong_rand_bytes(16)` 生成的 16 字节 binary。网络帧只使用 safe term,协议 label、command map key 和 action 使用 binary;`efka` 收到后直接按 binary key/action 处理。 + +HTTP handler 最多等待 10 秒。超时返回 HTTP 504,其他参数或执行错误通常返回 HTTP 400;找不到 host 返回业务错误 code 404。 + +## 7. Docker create config 映射 + +`create.config` 由 `build_docker_container_config/1` 生成: + +| 内部字段 | 来源 JSON 字段 | 转换规则 | +| --- | --- | --- | +| `image` | `image` | 原值。 | +| `cmd` | `command` | 原值。 | +| `entrypoint` | `entrypoint` | 默认 `[]`。 | +| `env` | `envs` | 默认 `[]`。 | +| `labels` | `labels` | 默认 `{}`。 | +| `volumes` | `volumes` | 只保留 container path 列表。 | +| `user` | `user` | 默认 `""`。 | +| `working_dir` | `working_dir` | 默认 `""`。 | +| `hostname` | `hostname` | 默认 `""`。 | +| `exposed_ports` | `expose` + `ports` | 转成 `#{container_port, protocol}` map 列表;`ports` 中的容器端口会自动补进 `exposed_ports`。 | +| `healthcheck` | `healthcheck` | 未传时为 `undefined`。 | + +## 8. Docker host config 映射 + +`create.host_config` 由 `build_docker_host_config/1` 生成: + +| 内部字段 | 来源 JSON 字段 | 转换规则 | +| --- | --- | --- | +| `binds` | `volumes` | 转成 Docker bind 字符串列表。 | +| `network_mode` | `network_mode` | 默认 `""`。 | +| `restart_policy` | `restart` | 转成 `#{name, maximum_retry_count}`。 | +| `privileged` | `privileged` | 默认 `false`。 | +| `cap_add` | `cap_add` | 默认 `[]`。 | +| `cap_drop` | `cap_drop` | 默认 `[]`。 | +| `devices` | `devices` | 转成设备映射 map 列表。 | +| `memory` | `mem_limit` | 解析成字节数,未传为 `0`。 | +| `memory_reservation` | `mem_reservation` | 解析成字节数,未传为 `0`。 | +| `nano_cpus` | `cpus` | `cpus * 1000000000`,未传为 `0`。 | +| `cpu_shares` | `cpu_shares` | 未传为 `0`。 | +| `port_bindings` | `ports` | 转成 `#{host_ip, host_port, container_port, protocol}` map 列表。 | +| `ulimits` | `ulimits` | 转成 ulimit map 列表。 | +| `tmpfs` | `tmpfs` | 转成 map。 | +| `sysctls` | `sysctls` | 默认 `{}`。 | +| `extra_hosts` | `extra_hosts` | 默认 `[]`。 | + +## 9. Docker networking config 映射 + +`create.networking_config` 由 `build_docker_networking_config/1` 生成: + +```erlang +#{<<"endpoints">> => [#{<<"name">> => Network} || Network <- Networks]} +``` + +来源字段: + +```json +"networks": ["bridge", "mynet"] +``` + +转换结果: + +```erlang +#{<<"endpoints">> => [ + #{<<"name">> => <<"bridge">>}, + #{<<"name">> => <<"mynet">>} +]} +``` + +## 10. 复杂字段转换规则 + +### restart + +输入: + +```json +"restart": "always" +``` + +转换: + +```erlang +#{<<"name">> => <<"always">>, <<"maximum_retry_count">> => 0} +``` + +输入: + +```json +"restart": "on-failure:3" +``` + +转换: + +```erlang +#{<<"name">> => <<"on-failure">>, <<"maximum_retry_count">> => 3} +``` + +### expose + +输入: + +```json +["80", "443/tcp", "53/udp"] +``` + +转换: + +```erlang +[ + #{<<"container_port">> => 80, <<"protocol">> => <<"tcp">>}, + #{<<"container_port">> => 443, <<"protocol">> => <<"tcp">>}, + #{<<"container_port">> => 53, <<"protocol">> => <<"udp">>} +] +``` + +端口必须是无符号整数,且不能超过 `4294967295`。 + +### ports + +输入: + +```json +["8080:80", "443:443", "8053:53/udp"] +``` + +转换为 `create.host_config.port_bindings`: + +```erlang +[ + #{<<"host_ip">> => <<>>, <<"host_port">> => 8080, <<"container_port">> => 80, <<"protocol">> => <<"tcp">>}, + #{<<"host_ip">> => <<>>, <<"host_port">> => 443, <<"container_port">> => 443, <<"protocol">> => <<"tcp">>}, + #{<<"host_ip">> => <<>>, <<"host_port">> => 8053, <<"container_port">> => 53, <<"protocol">> => <<"udp">>} +] +``` + +规则: + +- 端口映射之间使用冒号分隔:`host_port:container_port`。 +- 容器端口可以带协议:`host_port:container_port/protocol`。 +- 未指定协议时默认为 `tcp`。 +- host port 和 container port 都不能为空,且必须是 `0..65535` 范围内的无符号整数。 +- `ports` 中出现的容器端口会自动补进 `create.config.exposed_ports`,因此不需要在 `expose` 里重复声明。 + +### volumes + +输入: + +```json +["/host/data:/data", "/host/log:/var/log:ro", "/host/cache:/cache:rw"] +``` + +转换为 `create.config.volumes`: + +```erlang +[<<"/data">>, <<"/var/log">>, <<"/cache">>] +``` + +转换为 `create.host_config.binds`: + +```erlang +[ + <<"/host/data:/data">>, + <<"/host/log:/var/log:ro">>, + <<"/host/cache:/cache">> +] +``` + +规则: + +- `host_path:container_path` -> 读写挂载。 +- `host_path:container_path:ro` -> 只读挂载。 +- `host_path:container_path:rw` -> 当前实现视为读写挂载,输出时不会保留 `:rw`。 +- host path 和 container path 都不能为空。 + +### devices + +输入: + +```json +["/dev/ttyUSB0:/dev/ttyUSB0", "/dev/snd:/dev/snd:rwm"] +``` + +转换: + +```erlang +[ + #{ + <<"path_on_host">> => <<"/dev/ttyUSB0">>, + <<"path_in_container">> => <<"/dev/ttyUSB0">>, + <<"cgroup_permissions">> => <<"rwm">> + }, + #{ + <<"path_on_host">> => <<"/dev/snd">>, + <<"path_in_container">> => <<"/dev/snd">>, + <<"cgroup_permissions">> => <<"rwm">> + } +] +``` + +规则: + +- `host_path:container_path` -> 权限默认为 `rwm`。 +- `host_path:container_path:permissions` -> 使用第三段作为权限。 +- 任意路径或 permissions 为空时返回 `invalid device mapping`。 + +### ulimits + +输入: + +```json +{ + "nofile": "1024:2048", + "nproc": "4096" +} +``` + +转换: + +```erlang +[ + #{<<"name">> => <<"nofile">>, <<"soft">> => 1024, <<"hard">> => 2048}, + #{<<"name">> => <<"nproc">>, <<"soft">> => 4096, <<"hard">> => 4096} +] +``` + +规则: + +- `"soft:hard"` -> 分别设置 soft 和 hard。 +- `"limit"` -> soft 和 hard 都等于 limit。 +- 数值必须是无符号整数。 + +### tmpfs + +输入: + +```json +["/tmp", "/run:rw,size=64m"] +``` + +转换: + +```erlang +#{ + <<"/tmp">> => <<>>, + <<"/run">> => <<"rw,size=64m">> +} +``` + +规则: + +- `"path"` -> options 为 `""`。 +- `"path:options"` -> options 为第二段。 +- path 不能为空。 + +### healthcheck + +输入: + +```json +{ + "test": ["CMD-SHELL", "curl -f http://localhost || exit 1"], + "interval": "30s", + "timeout": "10s", + "retries": 3 +} +``` + +转换: + +```erlang +#{ + <<"test">> => [<<"CMD-SHELL">>, <<"curl -f http://localhost || exit 1">>], + <<"interval_ns">> => 30000000000, + <<"timeout_ns">> => 10000000000, + <<"retries">> => 3 +} +``` + +字段规则: + +| 字段 | JSON 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `test` | string[] | `[]` | Docker healthcheck test 参数列表。 | +| `interval` | string 或 non-negative integer | `"0s"` | string 会解析时间单位;integer 直接视为纳秒。 | +| `timeout` | string 或 non-negative integer | `"0s"` | string 会解析时间单位;integer 直接视为纳秒。 | +| `retries` | non-negative integer | `0` | Docker healthcheck 重试次数。 | + +时间单位: + +| 单位 | 含义 | +| --- | --- | +| `ns` | 纳秒 | +| `us` | 微秒 | +| `ms` | 毫秒 | +| `s` | 秒 | +| `m` | 分钟 | +| `h` | 小时 | +| 无单位 | 秒 | + +示例: + +- `"30s"` -> `30000000000` +- `"10ms"` -> `10000000` +- `"2m"` -> `120000000000` +- `"5"` -> `5000000000` + +### mem_limit 和 mem_reservation + +输入: + +```json +"mem_limit": "512m", +"mem_reservation": "1g" +``` + +转换: + +```erlang +<<"memory">> => 536870912, +<<"memory_reservation">> => 1073741824 +``` + +支持单位: + +| 单位 | 倍数 | +| --- | --- | +| `b` 或无单位 | 1 | +| `k`, `kb`, `ki`, `kib` | 1024 | +| `m`, `mb`, `mi`, `mib` | 1048576 | +| `g`, `gb`, `gi`, `gib` | 1073741824 | +| `t`, `tb`, `ti`, `tib` | 1099511627776 | + +数值支持整数或小数,例如 `"1.5g"`。 + +### cpus + +输入: + +```json +"cpus": 1.5 +``` + +转换: + +```erlang +<<"nano_cpus">> => 1500000000 +``` + +规则: + +- integer 或 float 都可以。 +- 必须大于等于 0。 +- 转换公式:`trunc(Cpus * 1000000000)`。 + +## 11. 最小请求示例 + +```json +{ + "uuid": "qbxmjyzrkpntfgswaevodhluicqzxplkm", + "task_id": 1001, + "config": { + "image": "docker.io/library/nginx:latest", + "container_name": "my_nginx", + "command": ["nginx", "-g", "daemon off;"], + "restart": "always" + } +} +``` + +对应下发给 `efka` 的协议命令示意: + +```erlang +{<<"command">>, Ref, {<<"container">>, #{ + <<"action">> => <<"deploy">>, + <<"task_id">> => 1001, + <<"params">> => #{ + <<"container_name">> => <<"my_nginx">>, + <<"create">> => #{ + <<"config">> => #{ + <<"image">> => <<"docker.io/library/nginx:latest">>, + <<"cmd">> => [<<"nginx">>, <<"-g">>, <<"daemon off;">>], + <<"entrypoint">> => [], + <<"env">> => [], + <<"labels">> => #{}, + <<"volumes">> => [], + <<"user">> => <<>>, + <<"working_dir">> => <<>>, + <<"hostname">> => <<>>, + <<"exposed_ports">> => [], + <<"healthcheck">> => undefined + }, + <<"host_config">> => #{ + <<"binds">> => [], + <<"network_mode">> => <<>>, + <<"restart_policy">> => #{<<"name">> => <<"always">>, <<"maximum_retry_count">> => 0}, + <<"privileged">> => false, + <<"cap_add">> => [], + <<"cap_drop">> => [], + <<"devices">> => [], + <<"memory">> => 0, + <<"memory_reservation">> => 0, + <<"nano_cpus">> => 0, + <<"cpu_shares">> => 0, + <<"port_bindings">> => [], + <<"ulimits">> => [], + <<"tmpfs">> => #{}, + <<"sysctls">> => #{}, + <<"extra_hosts">> => [] + }, + <<"networking_config">> => #{<<"endpoints">> => []} + } + } +}}} +``` + +## 12. 响应 + +成功时 HTTP body 由 `iot_util:json_data/1` 包装: + +```json +{ + "result": "ok" +} +``` + +如果 efka 返回的是 JSON binary,handler 会尝试解码后再放入 `result`。 + +错误示例: + +```json +{ + "error": { + "code": 400, + "message": "invalid port binding" + } +} +``` + +常见状态: + +| HTTP 状态 | 场景 | +| --- | --- | +| `200` | host not found 或构造阶段返回的业务错误也可能通过 200 包装业务错误。 | +| `400` | 参数校验失败、efka 返回普通错误。 | +| `504` | 等待 efka command response 超时。 | diff --git a/docs/efka_iot_protocol.md b/docs/efka_iot_protocol.md new file mode 100644 index 0000000..38dbefb --- /dev/null +++ b/docs/efka_iot_protocol.md @@ -0,0 +1,268 @@ +# EFKA 与 IOT 交互协议 + +本文档描述 `efka` 与 `iot` 之间的 TLS 长连接协议。当前协议由 Erlang term 直接序列化,发送端使用 `term_to_binary/1`,接收端使用 `binary_to_term(PacketBin, [safe])`。 + +协议帧只使用 safe external term:顶层 label、业务 label、map key 使用 binary;`Ref` 使用 `crypto:strong_rand_bytes(16)` 生成,是 16 字节 binary。网络协议里不发送 Erlang `reference()`,也不依赖动态创建 atom。 + +## 传输层 + +- `efka` 作为 TLS client 连接 `iot`。 +- `iot` 作为 TLS server 接收多个 `efka` 连接,一个连接对应一个 `ssl_channel` 进程。 +- socket 使用 `{packet, 4}`,每个 Erlang term binary 作为一个完整包发送。 +- `Ref` 使用 `crypto:strong_rand_bytes(16)` 生成,只在当前连接的 inflight 表内匹配。 + +## 顶层帧 + +协议顶层 tuple 用来表达交互语义: + +```erlang +{<<"request">>, Ref, Body} +{<<"response">>, Ref, Reply} +{<<"command">>, Ref, {Domain, Payload}} +{<<"command_response">>, Ref, {Domain, Reply}} +{<<"message">>, Body} +``` + +语义说明: + +| 帧 | 方向 | 语义 | +| --- | --- | --- | +| `{<<"request">>, Ref, Body}` | efka -> iot | efka 发起请求,需要 iot 回复 | +| `{<<"response">>, Ref, Reply}` | iot -> efka | iot 对 efka request 的回复 | +| `{<<"command">>, Ref, {Domain, Payload}}` | iot -> efka | iot 下发命令,需要 efka 回复 | +| `{<<"command_response">>, Ref, {Domain, Reply}}` | efka -> iot | efka 对 iot command 的回复 | +| `{<<"message">>, Body}` | 双向 | 异步消息,不要求回复 | + +`command` 和 `command_response` 的 `Domain` 表示业务域,目前支持: + +- `<<"container">>` + +## 鉴权请求 + +初始连接由 `efka` 发起鉴权 request。每条 TLS 连接只允许一次鉴权;`iot` 侧鉴权成功后会在 `ssl_channel` 标记该连接已鉴权,如果同一连接再次发送 `auth_request`,`iot` 会直接关闭连接。 + +```erlang +{<<"request">>, Ref, {<<"auth_request">>, #{ + <<"uuid">> => UUID, + <<"token">> => Token, + <<"timestamp">> => Timestamp +}}} +``` + +`iot` 回复: + +```erlang +{<<"response">>, Ref, {<<"auth_response">>, <<"ok">>}} +{<<"response">>, Ref, {<<"auth_response">>, {<<"error">>, {<<"failed">>, Reason}}}} +``` + +处理语义: + +- `<<"ok">>`:`efka` 进入 `activated` 状态。 +- `{<<"error">>, {<<"failed">>, Reason}}`:鉴权失败,`iot` 返回失败响应后关闭连接;`efka` 进入重连流程。 + +## 授权控制 + +`/host/activate` 只修改 `iot` 本地和持久化的 host 授权状态,不再向 `efka` 下发 auth command。`efka` 可以继续保持连接并发送数据,是否处理这些数据由 `iot_host` 当前状态决定。 + +因此当前协议没有 `{<<"command">>, Ref, {<<"auth">>, ...}}` 和 `{<<"command_response">>, Ref, {<<"auth">>, ...}}`。授权关闭时,`iot_host` 保持 channel 在线,但不处理上报数据;授权重新打开后,已在线的 channel 可以继续使用。 + +## 容器管理命令 + +`iot` 对 `efka` 的容器管理使用 command 语义: + +```erlang +{<<"command">>, Ref, {<<"container">>, CommandMap}} +``` + +`efka` 回复: + +```erlang +{<<"command_response">>, Ref, {<<"container">>, Reply}} +``` + +`Reply` 取值: + +```erlang +<<"ok">> +{<<"ok">>, Result} +{<<"error">>, Reason} +``` + +`CommandMap` 使用 binary key 和 binary action;`efka` 接收后直接按 binary key/action 匹配,Docker 参数链路继续使用 binary-key map,不再转换成 atom-key map。 + +### list + +```erlang +#{<<"action">> => <<"list">>} +``` + +返回当前 `efka` 主机上的容器列表。 + +### deploy + +```erlang +#{ + <<"action">> => <<"deploy">>, + <<"task_id">> => TaskId, + <<"params">> => Params +} +``` + +触发容器部署。部署过程中的流式日志不通过该 command response 返回,而是通过 `message` 的 `task_event` 上报。 + +### start + +```erlang +#{ + <<"action">> => <<"start">>, + <<"target">> => Target +} +``` + +### stop + +```erlang +#{ + <<"action">> => <<"stop">>, + <<"target">> => Target, + <<"timeout_seconds">> => TimeoutSeconds +} +``` + +### kill + +```erlang +#{ + <<"action">> => <<"kill">>, + <<"target">> => Target, + <<"signal">> => Signal +} +``` + +### remove + +```erlang +#{ + <<"action">> => <<"remove">>, + <<"target">> => Target, + <<"force">> => Force, + <<"remove_volumes">> => RemoveVolumes +} +``` + +### config + +```erlang +#{ + <<"action">> => <<"config">>, + <<"target">> => Target, + <<"config">> => Config +} +``` + +更新容器配置文件。 + +### Target + +容器目标使用 map 表示: + +```erlang +#{ + <<"name">> => ContainerName, + <<"id">> => ContainerId +} +``` + +`name` 和 `id` 至少一个非空;优先使用 `name`,`name` 为空时使用 `id`。 + +## 异步消息 + +`message` 不带 `Ref`,不要求对端回复。 + +### efka -> iot: data + +```erlang +{<<"message">>, {<<"data">>, #{ + <<"route_key">> => RouteKey, + <<"metric">> => Metric +}}} +``` + +用于 `efka` 上报业务指标数据。 + +### efka -> iot: task_event + +```erlang +{<<"message">>, {<<"task_event">>, #{ + <<"task_id">> => TaskId, + <<"type">> => Type, + <<"stream">> => Stream +}}} +``` + +任务事件流关闭时: + +```erlang +{<<"message">>, {<<"task_event">>, #{ + <<"task_id">> => TaskId, + <<"type">> => <<"close">>, + <<"stream">> => Reason +}}} +``` + +`task_event` 本身不携带 `uuid`。`iot` 在接收该消息时使用当前已鉴权 `ssl_channel` 绑定的 host UUID,把事件路由到内部任务进程 `{UUID, TaskId}`。HTTP 页面通过 SSE 订阅时也必须使用同一组参数: + +```http +GET /event_stream?uuid=&task_id= +``` + +`iot` 会为每个 `{UUID, TaskId}` 维护一个独立的任务进程,用于缓存最近的部署日志、支持多个 SSE listener,并在收到 close 事件后结束事件流。 + +### efka -> iot: ping + +```erlang +{<<"message">>, <<"ping">>} +``` + +用于 TLS 长连接的应用层保活。`efka` 在鉴权成功后周期发送,当前发送间隔为 30 秒。 + +`iot` 收到后回复: + +```erlang +{<<"message">>, <<"pong">>} +``` + +`ping/pong` 只表示 TLS 连接仍可读写,不参与 host online/offline 判定。host 上下线仍由 UDP 心跳和 `iot_host` 本地连接状态共同维护。 + +### iot -> efka: pub + +```erlang +{<<"message">>, {<<"pub">>, #{ + <<"topic">> => Topic, + <<"qos">> => Qos, + <<"content">> => Content +}}} +``` + +用于 `iot` 向 `efka` 本地订阅系统发布 topic 消息。 + +## 状态与超时 + +- `efka` 鉴权超时时间:5 秒。 +- `iot` command inflight 超时时间:60 秒。 +- `iot` SSL channel 空闲超时时间:120 秒。120 秒内没有收到任何 TLS 包,包括 `ping`、业务 `message`、`request`、`command_response`,`iot` 会主动关闭该连接。 +- `iot` 管理多个 `efka` 时,每个连接有独立 `ssl_channel` 和独立 inflight 表。 +- command 超时后,`iot` 删除 inflight 记录;之后如果迟到的 `command_response` 到达,会被视为未预期响应。 + +## 兼容性 + +当前协议不兼容旧 tuple: + +- 旧容器管理:`{request, Ref, {container_request, ...}}` +- 旧容器回复:`{response, Ref, {container_response, ...}}` +- 旧授权控制:`{message, {auth_control, Command}}` +- 已移除的 auth command:`{command, Ref, {auth, activate | deactivate}}` +- 旧 Ref:Erlang `reference()`,例如 `make_ref()` 生成的值。 + +如果需要滚动升级,应先增加临时兼容分支或引入协议版本协商。 diff --git a/docs/heartbeat.md b/docs/heartbeat.md index c23975a..fadd2a7 100644 --- a/docs/heartbeat.md +++ b/docs/heartbeat.md @@ -1,11 +1,44 @@ ## 心跳机制 * 边缘主机通过心跳机制来判断主机是否存活(解决弱网环境下websocket链接会经常断开的问题) -* 边缘主机每隔5秒发送一次心跳包,服务端每隔2分钟检测一下,判断是否有收到心跳包;如果没有收到则认为主机离线 +* 边缘主机定期发送 UDP 心跳包,服务端按照 `iot_host` 的心跳检测周期判断是否有收到心跳包。 +* 如果 UDP 心跳丢失但 SSL channel 仍然存在,服务端不会把 host 标记为离线。 +* 如果 UDP 心跳丢失且 SSL channel 也不存在,服务端会把 host 标记为离线。 ### udp服务器 * 端口: 18080 ### 心跳包格式 -* <> -* Len表示HostUUID对应的字节数,Len本身占用2字节长度(HostUUID不一定是固定长度,因此需要标注) -* 注解:采用这种格式是为了方便后续扩展别的心跳信息字段 +旧格式 `<>` 已不再接受。 + +当前心跳包格式: + +```erlang +<< + Version:8, + UuidLen:16, + UUID:UuidLen/binary, + Timestamp:64/unsigned-big, + Nonce:16/binary, + Mac:32/binary +>> +``` + +字段说明: + +| 字段 | 说明 | +| --- | --- | +| `Version` | 当前固定为 `1`。 | +| `UuidLen` | `UUID` 的字节数。 | +| `UUID` | efka 对应的 host UUID。 | +| `Timestamp` | 秒级 Unix 时间戳。服务端只接受 120 秒时间窗内的心跳。 | +| `Nonce` | 16 字节随机数,必须加入 HMAC payload,使同一秒内多次心跳的 Mac 不同。当前服务端不保存 nonce。 | +| `Mac` | HMAC-SHA256 结果,32 字节。 | + +`Mac` 的计算内容为前面所有字段: + +```erlang +Payload = <>, +Mac = crypto:mac(hmac, sha256, HeartbeatSecret, Payload) +``` + +服务端在 `efka_client_store:verify_heartbeat/4` 中校验时间窗和 HMAC。`HeartbeatSecret` 使用 `SHA256(Token)`,其中 `Token` 和 TLS 鉴权使用同一个 auth token。`iot` 侧只保存派生后的 `heartbeat_secret`,不保存明文 token。 diff --git a/docs/iot_api.md b/docs/iot_api.md index a1f14bb..21ee303 100644 --- a/docs/iot_api.md +++ b/docs/iot_api.md @@ -3,7 +3,7 @@ ```markdown # 📘 IoT API 接口文档 -> 模块:`iot_api` +> 模块:`iot_api_client` > 作者:**anlicheng** > 创建时间:2023-12-24 > 数据格式:`application/json` @@ -133,7 +133,36 @@ POST /change_host_status --- -### 5. 获取主机下的设备列表 +### 5. 修改主机授权状态 + +**接口:** + +``` +POST /change_host_authorize_status +``` + +**请求体:** + +```json +{ + "uuid": "uuid-1", + "new_authorize_status": 1 +} +``` + +`new_authorize_status` 为 `1` 表示允许 `iot_host` 处理该 host 的上报数据,为 `0` 表示保持连接但不处理上报数据。 + +**返回示例:** + +```json +{ + "result": "ok" +} +``` + +--- + +### 6. 获取主机下的设备列表 **接口:** @@ -162,7 +191,7 @@ GET /get_host_devices?host_id= ## 🔧 设备(Device)相关接口 -### 6. 获取设备详情 +### 7. 获取设备详情 **接口:** @@ -190,7 +219,7 @@ GET /get_device_by_uuid?device_uuid= --- -### 7. 修改设备状态 +### 8. 修改设备状态 **接口:** @@ -219,7 +248,7 @@ POST /change_device_status ## 🌐 Endpoint(数据终端)相关接口 -### 8. 获取所有 Endpoint +### 9. 获取所有 Endpoint **接口:** @@ -312,7 +341,7 @@ GET /get_all_endpoints --- -### 9. 获取指定 Endpoint 信息 +### 10. 获取指定 Endpoint 信息 **接口:** @@ -406,4 +435,4 @@ GET /get_endpoint?id= "message": "Invalid parameter" } } -``` \ No newline at end of file +``` diff --git a/rebar.config b/rebar.config index 9acc2fa..7a6a118 100644 --- a/rebar.config +++ b/rebar.config @@ -1,4 +1,7 @@ {erl_opts, [debug_info]}. + +{project_app_dirs, ["apps/*"]}. + {deps, [ {poolboy, ".*", {git, "https://github.com/devinus/poolboy.git", {tag, "1.5.1"}}}, {hackney, ".*", {git, "https://github.com/benoitc/hackney.git", {tag, "1.25.0"}}}, @@ -6,27 +9,25 @@ {cowboy, ".*", {git, "https://github.com/ninenines/cowboy.git", {tag, "2.14.0"}}}, {ranch, ".*", {git, "https://github.com/ninenines/ranch.git", {tag, "2.2.0"}}}, {brod, ".*", {git, "https://github.com/kafka4beam/brod.git", {tag, "4.4.5"}}}, - {jiffy, ".*", {git, "https://github.com/davisp/jiffy.git", {tag, "1.1.1"}}}, - {mysql, ".*", {git, "https://github.com/mysql-otp/mysql-otp", {tag, "1.8.0"}}}, {eredis, ".*", {git, "https://github.com/wooga/eredis.git", {tag, "v1.2.0"}}}, - {emqtt, ".*", {git, "https://gitea.s5s8.com/anlicheng/emqtt.git", {branch, "main"}}}, - {gproc, ".*", {git, "https://github.com/uwiger/gproc.git", {tag, "0.9.1"}}}, - {parse_trans, ".*", {git, "https://github.com/uwiger/parse_trans", {tag, "3.0.0"}}}, - {lager, ".*", {git,"https://github.com/erlang-lager/lager.git", {tag, "3.9.2"}}} + {emqtt, ".*", {git, "https://gitea.s5s8.com/anlicheng/emqtt.git", {tag, "v1.2"}}}, + {gproc, ".*", {git, "https://github.com/uwiger/gproc.git", {tag, "0.9.1"}}} ]}. {relx, [{release, {iot, "0.1.0"}, [iot, + endpoint, sasl]}, - {mode, dev}, + {include_erts, true}, + {mode, prod}, %{mode, prod}, %% automatically picked up if the files %% exist but can be set manually, which %% is required if the names aren't exactly %% sys.config and vm.args - {sys_config, "./config/sys.config"}, + {sys_config, "./config/sys-dev.config"}, {vm_args, "./config/vm.args"} %% the .src form of the configuration files do @@ -39,14 +40,12 @@ [%% prod is the default mode when prod %% profile is used, so does not have %% to be explicitly included like this - {mode, prod} + {mode, prod}, + {sys_config, "./config/sys-prod.config"} %% use minimal mode to exclude ERTS %% {mode, minimal} ] }]}]}. -{erl_opts, [{parse_transform,lager_transform}]}. - {rebar_packages_cdn, "https://hexpm.upyun.com"}. - diff --git a/rebar.lock b/rebar.lock index dcc80ad..9ec1297 100644 --- a/rebar.lock +++ b/rebar.lock @@ -15,14 +15,13 @@ {<<"crc32cer">>,{pkg,<<"crc32cer">>,<<"1.0.3">>},2}, {<<"emqtt">>, {git,"https://gitea.s5s8.com/anlicheng/emqtt.git", - {ref,"5111914a9b1b92b0b497f825c77bdd365e3989b0"}}, + {ref,"c5a52dcb57cd23a4318e342b705f9f726e8d67a1"}}, 0}, {<<"eredis">>, {git,"https://github.com/wooga/eredis.git", {ref,"9ad91f149310a7d002cb966f62b7e2c3330abb04"}}, 0}, {<<"fs">>,{pkg,<<"fs">>,<<"6.1.1">>},1}, - {<<"goldrush">>,{pkg,<<"goldrush">>,<<"0.1.9">>},1}, {<<"gproc">>, {git,"https://github.com/uwiger/gproc.git", {ref,"4ca45e0a97722a418a31eb1753f4e3b953f7fb1d"}}, @@ -32,25 +31,10 @@ {ref,"8c00789e411d7c09a9808d720232098da1f19d69"}}, 0}, {<<"idna">>,{pkg,<<"idna">>,<<"6.1.1">>},1}, - {<<"jiffy">>, - {git,"https://github.com/davisp/jiffy.git", - {ref,"9ea1b35b6e60ba21dfd4adbd18e7916a831fd7d4"}}, - 0}, {<<"kafka_protocol">>,{pkg,<<"kafka_protocol">>,<<"4.2.7">>},1}, - {<<"lager">>, - {git,"https://github.com/erlang-lager/lager.git", - {ref,"459a3b2cdd9eadd29e5a7ce5c43932f5ccd6eb88"}}, - 0}, {<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},1}, {<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.4.0">>},1}, - {<<"mysql">>, - {git,"https://github.com/mysql-otp/mysql-otp", - {ref,"caf5ff96c677a8fe0ce6f4082bc036c8fd27dd62"}}, - 0}, - {<<"parse_trans">>, - {git,"https://github.com/uwiger/parse_trans", - {ref,"6f3645afb43c7c57d61b54ef59aecab288ce1013"}}, - 0}, + {<<"parse_trans">>,{pkg,<<"parse_trans">>,<<"3.4.1">>},1}, {<<"poolboy">>, {git,"https://github.com/devinus/poolboy.git", {ref,"3bb48a893ff5598f7c73731ac17545206d259fac"}}, @@ -70,22 +54,22 @@ {<<"certifi">>, <<"0E6E882FCDAAA0A5A9F2B3DB55B1394DBA07E8D6D9BCAD08318FB604C6839712">>}, {<<"crc32cer">>, <<"AD0E42BED8603F2C72DE2A00F1B5063FFE12D5988615CAD984096900431D1C1A">>}, {<<"fs">>, <<"9D147B944D60CFA48A349F12D06C8EE71128F610C90870BDF9A6773206452ED0">>}, - {<<"goldrush">>, <<"F06E5D5F1277DA5C413E84D5A2924174182FB108DABB39D5EC548B27424CD106">>}, {<<"idna">>, <<"8A63070E9F7D0C62EB9D9FCB360A7DE382448200FBBD1B106CC96D3D8099DF8D">>}, {<<"kafka_protocol">>, <<"6F53B15CD6F6A12C1D0010DB074B4A15985C71BC7F594BC2D67D9837B3B378A1">>}, {<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>}, {<<"mimerl">>, <<"3882A5CA67FBBE7117BA8947F27643557ADEC38FA2307490C4C4207624CB213B">>}, + {<<"parse_trans">>, <<"6E6AA8167CB44CC8F39441D05193BE6E6F4E7C2946CB2759F015F8C56B76E5FF">>}, {<<"ssl_verify_fun">>, <<"354C321CF377240C7B8716899E182CE4890C5938111A1296ADD3EC74CF1715DF">>}, {<<"unicode_util_compat">>, <<"BC84380C9AB48177092F43AC89E4DFA2C6D62B40B8BD132B1059ECC7232F9A78">>}]}, {pkg_hash_ext,[ {<<"certifi">>, <<"B147ED22CE71D72EAFDAD94F055165C1C182F61A2FF49DF28BCC71D1D5B94A60">>}, {<<"crc32cer">>, <<"08FDCD5CE51ACD839A12E98742F0F0EDA19A2A679FC9FBFAF6AAB958310FB70E">>}, {<<"fs">>, <<"EF94E95FFE79916860649FED80AC62B04C322B0BB70F5128144C026B4D171F8B">>}, - {<<"goldrush">>, <<"99CB4128CFFCB3227581E5D4D803D5413FA643F4EB96523F77D9E6937D994CEB">>}, {<<"idna">>, <<"92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA">>}, {<<"kafka_protocol">>, <<"1D5E9597AD3C0776C86DC5E08D3BAAEA7DB805A52E5FD35E3F071AAD7789FC4C">>}, {<<"metrics">>, <<"69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16">>}, {<<"mimerl">>, <<"13AF15F9F68C65884ECCA3A3891D50A7B57D82152792F3E19D88650AA126B144">>}, + {<<"parse_trans">>, <<"620A406CE75DADA827B82E453C19CF06776BE266F5A67CFF34E1EF2CBB60E49A">>}, {<<"ssl_verify_fun">>, <<"FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8">>}, {<<"unicode_util_compat">>, <<"25EEE6D67DF61960CF6A794239566599B09E17E668D3700247BC498638152521">>}]} ]. diff --git a/todo_docs/mysql.md b/todo_docs/mysql.md deleted file mode 100644 index c737b39..0000000 --- a/todo_docs/mysql.md +++ /dev/null @@ -1,37 +0,0 @@ -# 待完成 - -## 1. endpoint需要存储在数据库 -```mysql - -CREATE TABLE `endpoint` ( -`id` bigint unsigned NOT NULL AUTO_INCREMENT, -`name` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '名称,路由时基于名称', -`title` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '序列号', -`type` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '类型', -`config_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '配置信息,基于json格式存储', -`status` smallint NOT NULL DEFAULT '-1', -`creator` smallint NOT NULL DEFAULT '0' COMMENT '创建人', -`created_at` timestamp NULL DEFAULT NULL, -`updated_at` timestamp NULL DEFAULT NULL, -PRIMARY KEY (`id`), -KEY `idx_name` (`name`) -) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci -``` - -## 2. service_config微服务配置也要存在于数据库 -```mysql -CREATE TABLE `service_config` ( -`id` bigint unsigned NOT NULL AUTO_INCREMENT, -`service_id` bigint unsigned NOT NULL COMMENT '服务的id', -`host_uuid` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '名称,路由时基于名称', -`config_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '配置信息,基于json格式存储', -`last_config_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '配置信息,基于json格式存储', -`creator` smallint NOT NULL DEFAULT '0' COMMENT '创建人', -`created_at` timestamp NULL DEFAULT NULL, -`updated_at` timestamp NULL DEFAULT NULL, -PRIMARY KEY (`id`), -KEY `idx_service_id` (`service_id`) -) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci - - -``` \ No newline at end of file