拆分独立的enpoint
This commit is contained in:
parent
f96dd3fea7
commit
3defc05815
@ -27,9 +27,9 @@
|
|||||||
%% {"result": Data}
|
%% {"result": Data}
|
||||||
%%--------------------------------------------------------------------
|
%%--------------------------------------------------------------------
|
||||||
json_data(Data) ->
|
json_data(Data) ->
|
||||||
jiffy:encode(#{
|
iolist_to_binary(json:encode(#{
|
||||||
<<"result">> => Data
|
<<"result">> => Data
|
||||||
}, [force_utf8]).
|
})).
|
||||||
|
|
||||||
%%--------------------------------------------------------------------
|
%%--------------------------------------------------------------------
|
||||||
%% @doc
|
%% @doc
|
||||||
@ -42,12 +42,12 @@ json_data(Data) ->
|
|||||||
%% }
|
%% }
|
||||||
%%--------------------------------------------------------------------
|
%%--------------------------------------------------------------------
|
||||||
json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) ->
|
json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) ->
|
||||||
jiffy:encode(#{
|
iolist_to_binary(json:encode(#{
|
||||||
<<"error">> => #{
|
<<"error">> => #{
|
||||||
<<"code">> => ErrCode,
|
<<"code">> => ErrCode,
|
||||||
<<"message">> => ErrMessage
|
<<"message">> => ErrMessage
|
||||||
}
|
}
|
||||||
}, [force_utf8]).
|
})).
|
||||||
```
|
```
|
||||||
|
|
||||||
### 📘 返回格式说明
|
### 📘 返回格式说明
|
||||||
@ -157,7 +157,7 @@ json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage)
|
|||||||
| 参数名 | 类型 | 必填 | 说明 |
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|--------|------|------|------|
|
|--------|------|------|------|
|
||||||
| uuid | binary (string) | ✅ | 主机唯一标识符 |
|
| uuid | binary (string) | ✅ | 主机唯一标识符 |
|
||||||
| task_id | integer | ✅ | 任务 ID |
|
| task_id | integer | ✅ | 任务 ID,和 uuid 一起作为部署任务唯一标识 |
|
||||||
| config | map | ✅ | 部署配置内容 |
|
| config | map | ✅ | 部署配置内容 |
|
||||||
|
|
||||||
#### 响应参数
|
#### 响应参数
|
||||||
@ -255,13 +255,16 @@ json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage)
|
|||||||
#### 示例响应
|
#### 示例响应
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"result": {
|
"data": "ok"
|
||||||
"task_id": 1001,
|
|
||||||
"status": "deployed"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
部署过程实时日志通过 SSE 获取:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /event_stream?uuid=qbxmjyzrkpntfgswaevodhluicqzxplkm&task_id=1
|
||||||
|
```
|
||||||
|
|
||||||
#### 错误响应
|
#### 错误响应
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@ -475,9 +478,9 @@ json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage)
|
|||||||
%% {"result": Data}
|
%% {"result": Data}
|
||||||
%%--------------------------------------------------------------------
|
%%--------------------------------------------------------------------
|
||||||
json_data(Data) ->
|
json_data(Data) ->
|
||||||
jiffy:encode(#{
|
iolist_to_binary(json:encode(#{
|
||||||
<<"result">> => Data
|
<<"result">> => Data
|
||||||
}, [force_utf8]).
|
})).
|
||||||
|
|
||||||
%%--------------------------------------------------------------------
|
%%--------------------------------------------------------------------
|
||||||
%% @doc
|
%% @doc
|
||||||
@ -490,12 +493,12 @@ json_data(Data) ->
|
|||||||
%% }
|
%% }
|
||||||
%%--------------------------------------------------------------------
|
%%--------------------------------------------------------------------
|
||||||
json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) ->
|
json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) ->
|
||||||
jiffy:encode(#{
|
iolist_to_binary(json:encode(#{
|
||||||
<<"error">> => #{
|
<<"error">> => #{
|
||||||
<<"code">> => ErrCode,
|
<<"code">> => ErrCode,
|
||||||
<<"message">> => ErrMessage
|
<<"message">> => ErrMessage
|
||||||
}
|
}
|
||||||
}, [force_utf8]).
|
})).
|
||||||
```
|
```
|
||||||
|
|
||||||
### 📘 返回格式说明
|
### 📘 返回格式说明
|
||||||
@ -909,9 +912,9 @@ Content-Type: application/json
|
|||||||
%% {"result": Data}
|
%% {"result": Data}
|
||||||
%%--------------------------------------------------------------------
|
%%--------------------------------------------------------------------
|
||||||
json_data(Data) ->
|
json_data(Data) ->
|
||||||
jiffy:encode(#{
|
iolist_to_binary(json:encode(#{
|
||||||
<<"result">> => Data
|
<<"result">> => Data
|
||||||
}, [force_utf8]).
|
})).
|
||||||
|
|
||||||
%%--------------------------------------------------------------------
|
%%--------------------------------------------------------------------
|
||||||
%% @doc
|
%% @doc
|
||||||
@ -924,12 +927,12 @@ json_data(Data) ->
|
|||||||
%% }
|
%% }
|
||||||
%%--------------------------------------------------------------------
|
%%--------------------------------------------------------------------
|
||||||
json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) ->
|
json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage) ->
|
||||||
jiffy:encode(#{
|
iolist_to_binary(json:encode(#{
|
||||||
<<"error">> => #{
|
<<"error">> => #{
|
||||||
<<"code">> => ErrCode,
|
<<"code">> => ErrCode,
|
||||||
<<"message">> => ErrMessage
|
<<"message">> => ErrMessage
|
||||||
}
|
}
|
||||||
}, [force_utf8]).
|
})).
|
||||||
```
|
```
|
||||||
|
|
||||||
### 📘 返回格式说明
|
### 📘 返回格式说明
|
||||||
@ -1079,7 +1082,7 @@ json_error(ErrCode, ErrMessage) when is_integer(ErrCode), is_binary(ErrMessage)
|
|||||||
| 参数名 | 类型 | 必填 | 说明 |
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|--------|------|------|------|
|
|--------|------|------|------|
|
||||||
| uuid | binary (string) | ✅ | 主机唯一标识符 |
|
| uuid | binary (string) | ✅ | 主机唯一标识符 |
|
||||||
| auth | boolean | ✅ | `true` 激活, `false` 取消激活 |
|
| auth | boolean | ✅ | `true` 激活, `false` 取消激活。该操作只修改 iot 本地和持久化授权状态,不通知 efka;efka 连接可继续保持在线,数据是否处理由 iot_host 状态决定。 |
|
||||||
|
|
||||||
#### 响应参数
|
#### 响应参数
|
||||||
| 字段 | 类型 | 说明 |
|
| 字段 | 类型 | 说明 |
|
||||||
|
|||||||
24
apps/endpoint/src/endpoint.app.src
Normal file
24
apps/endpoint/src/endpoint.app.src
Normal file
@ -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, []}
|
||||||
|
]}.
|
||||||
@ -31,32 +31,32 @@ start_link(Endpoint = #endpoint{id = Id, config = #kafka_endpoint{}}) ->
|
|||||||
LocalName = get_name(Id),
|
LocalName = get_name(Id),
|
||||||
endpoint_kafka:start_link(LocalName, Endpoint).
|
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) ->
|
get_name(Id) when is_integer(Id) ->
|
||||||
list_to_atom("endpoint:" ++ integer_to_list(Id)).
|
{endpoint, Id}.
|
||||||
|
|
||||||
-spec get_pid(Id :: integer()) -> undefined | pid().
|
-spec get_pid(Id :: integer()) -> undefined | pid().
|
||||||
get_pid(Id) when is_integer(Id) ->
|
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) ->
|
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().
|
-spec get_alias_pid(Name :: binary()) -> undefined | pid().
|
||||||
get_alias_pid(Name) when is_binary(Name) ->
|
get_alias_pid(Name) when is_binary(Name) ->
|
||||||
gproc:whereis_name({n, l, get_alias_name(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) ->
|
forward(Pid, Metric) when is_pid(Pid), is_binary(Metric) ->
|
||||||
gen_server:cast(Pid, {forward, Metric}).
|
gen_server:cast(Pid, {forward, Metric}).
|
||||||
|
|
||||||
reload(Pid, NEndpoint = #endpoint{}) when is_pid(Pid) ->
|
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.
|
-spec clean_up(Pid :: pid()) -> ok.
|
||||||
clean_up(Pid) when is_pid(Pid) ->
|
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().
|
-spec get_protocol(Endpoint :: #endpoint{}) -> atom().
|
||||||
get_protocol(#endpoint{config = #http_endpoint{}}) ->
|
get_protocol(#endpoint{config = #http_endpoint{}}) ->
|
||||||
@ -68,7 +68,7 @@ get_protocol(#endpoint{config = #kafka_endpoint{}}) ->
|
|||||||
|
|
||||||
-spec is_support(Protocol :: atom()) -> boolean().
|
-spec is_support(Protocol :: atom()) -> boolean().
|
||||||
is_support(Protocol) when is_atom(Protocol) ->
|
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, []),
|
SupportProtocols = proplists:get_value(support_protocols, Props, []),
|
||||||
lists:member(Protocol, SupportProtocols).
|
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()]}.
|
-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}) ->
|
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
|
case check_mqtt_argument(Term) of
|
||||||
ok ->
|
ok ->
|
||||||
false;
|
false;
|
||||||
{error, Error} ->
|
{error, Error} ->
|
||||||
{true, Error}
|
{true, Error}
|
||||||
end
|
end
|
||||||
end, [{host, Host}, {port, Port}, {username, Username}, {password, Password}, {topic, Topic}, {qos, Qos}]),
|
end, CheckTerms),
|
||||||
case Errors =:= [] of
|
case Errors =:= [] of
|
||||||
true ->
|
true ->
|
||||||
{ok, #mqtt_endpoint{
|
{ok, #mqtt_endpoint{
|
||||||
@ -118,11 +129,24 @@ parse_config(<<"mqtt">>, #{<<"host">> := Host, <<"port">> := Port0, <<"client_id
|
|||||||
end;
|
end;
|
||||||
parse_config(<<"http">>, C = #{<<"url">> := Url, <<"pool_size">> := PoolSize}) ->
|
parse_config(<<"http">>, C = #{<<"url">> := Url, <<"pool_size">> := PoolSize}) ->
|
||||||
Token = maps:get(<<"token">>, C, <<>>),
|
Token = maps:get(<<"token">>, C, <<>>),
|
||||||
{ok, #http_endpoint{
|
Errors = lists:filtermap(fun(Term) ->
|
||||||
url = Url,
|
case check_http_argument(Term) of
|
||||||
token = Token,
|
ok ->
|
||||||
pool_size = PoolSize
|
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}) ->
|
parse_config(<<"kafka">>, #{<<"sasl_config">> := #{<<"username">> := Username, <<"password">> := Password, <<"mechanism">> := Mechanism0}, <<"bootstrap_servers">> := BootstrapServers, <<"topic">> := Topic}) ->
|
||||||
Errors = lists:filtermap(fun(Term) ->
|
Errors = lists:filtermap(fun(Term) ->
|
||||||
case check_kafka_argument(Term) of
|
case check_kafka_argument(Term) of
|
||||||
@ -168,8 +192,8 @@ parse_config(_, _) ->
|
|||||||
-spec parse_kafka_bootstrap_servers(BootstrapServers :: [binary()]) -> Servers :: [{Host :: string(), Port :: integer()}].
|
-spec parse_kafka_bootstrap_servers(BootstrapServers :: [binary()]) -> Servers :: [{Host :: string(), Port :: integer()}].
|
||||||
parse_kafka_bootstrap_servers(BootstrapServers) when is_list(BootstrapServers) ->
|
parse_kafka_bootstrap_servers(BootstrapServers) when is_list(BootstrapServers) ->
|
||||||
lists:map(fun(S) ->
|
lists:map(fun(S) ->
|
||||||
[Host0, Port0] = binary:split(S, <<":">>),
|
{ok, Host, Port} = parse_kafka_bootstrap_server(S),
|
||||||
{binary_to_list(Host0), binary_to_integer(Port0)}
|
{Host, Port}
|
||||||
end, BootstrapServers).
|
end, BootstrapServers).
|
||||||
|
|
||||||
-spec parse_kafka_mechanism(Mechanism0 :: binary()) -> atom().
|
-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
|
case is_list(BootstrapServers) andalso length(BootstrapServers) > 0 of
|
||||||
true ->
|
true ->
|
||||||
InvalidServers = lists:filtermap(fun(S) ->
|
InvalidServers = lists:filtermap(fun(S) ->
|
||||||
case binary:split(S, <<":">>) of
|
case parse_kafka_bootstrap_server(S) of
|
||||||
[Host0, Port0] ->
|
{ok, _Host, _Port} ->
|
||||||
Host = binary_to_list(Host0),
|
false;
|
||||||
Port = binary_to_integer(Port0),
|
error ->
|
||||||
case not string:is_empty(Host) andalso (is_integer(Port) andalso Port > 0) of
|
|
||||||
true ->
|
|
||||||
false;
|
|
||||||
false ->
|
|
||||||
{true, S}
|
|
||||||
end;
|
|
||||||
_ ->
|
|
||||||
{true, S}
|
{true, S}
|
||||||
end
|
end
|
||||||
end, BootstrapServers),
|
end, BootstrapServers),
|
||||||
@ -243,6 +260,29 @@ check_kafka_argument({bootstrap_servers, BootstrapServers}) ->
|
|||||||
{error, <<"bootstrap_servers is empty">>}
|
{error, <<"bootstrap_servers is empty">>}
|
||||||
end.
|
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()}.
|
-spec check_mqtt_argument(tuple()) -> ok | {error, Reason :: binary()}.
|
||||||
check_mqtt_argument({host, Host}) ->
|
check_mqtt_argument({host, Host}) ->
|
||||||
case Host /= <<>> of
|
case Host /= <<>> of
|
||||||
@ -286,3 +326,34 @@ check_mqtt_argument({qos, Qos}) ->
|
|||||||
false ->
|
false ->
|
||||||
{error, <<"qos invalid">>}
|
{error, <<"qos invalid">>}
|
||||||
end.
|
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.
|
||||||
@ -3,13 +3,14 @@
|
|||||||
%% @end
|
%% @end
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
|
|
||||||
-module(endpoint_sup).
|
-module(endpoint_adapter_sup).
|
||||||
|
|
||||||
-behaviour(supervisor).
|
-behaviour(supervisor).
|
||||||
-include("endpoint.hrl").
|
-include("endpoint.hrl").
|
||||||
|
|
||||||
-export([start_link/0]).
|
-export([start_link/0]).
|
||||||
-export([ensured_endpoint_started/1, delete_endpoint/1]).
|
-export([ensured_endpoint_started/1, delete_endpoint/1]).
|
||||||
|
-export([start_endpoints/1]).
|
||||||
|
|
||||||
-export([init/1]).
|
-export([init/1]).
|
||||||
|
|
||||||
@ -29,21 +30,15 @@ start_link() ->
|
|||||||
%% modules => modules()} % optional
|
%% modules => modules()} % optional
|
||||||
init([]) ->
|
init([]) ->
|
||||||
SupFlags = #{strategy => one_for_one, intensity => 1000, period => 3600},
|
SupFlags = #{strategy => one_for_one, intensity => 1000, period => 3600},
|
||||||
Endpoints = iot_api:get_all_endpoints(),
|
{ok, {SupFlags, []}}.
|
||||||
ChildSpecs = lists:filtermap(fun(EndpointInfo) ->
|
|
||||||
case endpoint:endpoint_record(EndpointInfo) of
|
-spec start_endpoints(Endpoints :: list()) -> ok.
|
||||||
error ->
|
start_endpoints(Endpoints) when is_list(Endpoints) ->
|
||||||
false;
|
lists:foreach(fun(Endpoint) ->
|
||||||
{ok, Endpoint} ->
|
Spec = child_spec(Endpoint),
|
||||||
case endpoint:is_support(endpoint:get_protocol(Endpoint)) of
|
{ok, _} = supervisor:start_child(?MODULE, Spec)
|
||||||
true ->
|
end, Endpoints),
|
||||||
{true, child_spec(Endpoint)};
|
ok.
|
||||||
false ->
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end, Endpoints),
|
|
||||||
{ok, {SupFlags, ChildSpecs}}.
|
|
||||||
|
|
||||||
-spec ensured_endpoint_started(Endpoint :: #endpoint{}) -> {ok, Pid :: pid()} | {error, Reason :: any()}.
|
-spec ensured_endpoint_started(Endpoint :: #endpoint{}) -> {ok, Pid :: pid()} | {error, Reason :: any()}.
|
||||||
ensured_endpoint_started(Endpoint = #endpoint{}) ->
|
ensured_endpoint_started(Endpoint = #endpoint{}) ->
|
||||||
@ -59,8 +54,24 @@ ensured_endpoint_started(Endpoint = #endpoint{}) ->
|
|||||||
-spec delete_endpoint(Id :: integer()) -> ok | {error, Reason :: any()}.
|
-spec delete_endpoint(Id :: integer()) -> ok | {error, Reason :: any()}.
|
||||||
delete_endpoint(Id) when is_integer(Id) ->
|
delete_endpoint(Id) when is_integer(Id) ->
|
||||||
Name = endpoint:get_name(Id),
|
Name = endpoint:get_name(Id),
|
||||||
supervisor:terminate_child(?MODULE, Name),
|
case supervisor:terminate_child(?MODULE, Name) of
|
||||||
supervisor:delete_child(?MODULE, Name).
|
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}) ->
|
child_spec(Endpoint = #endpoint{id = Id}) ->
|
||||||
Name = endpoint:get_name(Id),
|
Name = endpoint:get_name(Id),
|
||||||
15
apps/endpoint/src/endpoint_app.erl
Normal file
15
apps/endpoint/src/endpoint_app.erl
Normal file
@ -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.
|
||||||
304
apps/endpoint/src/endpoint_buffer.erl
Normal file
304
apps/endpoint/src/endpoint_buffer.erl
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @author aresei
|
||||||
|
%%% @copyright (C) 2023, <COMPANY>
|
||||||
|
%%% @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).
|
||||||
@ -30,10 +30,10 @@
|
|||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
|
|
||||||
%% @doc Spawns the server and registers the local name (unique)
|
%% @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()}).
|
{ok, Pid :: pid()} | ignore | {error, Reason :: term()}).
|
||||||
start_link(LocalName, Endpoint = #endpoint{config = #http_endpoint{}}) when is_atom(LocalName) ->
|
start_link(LocalName, Endpoint = #endpoint{config = #http_endpoint{}}) ->
|
||||||
gen_server:start_link({local, LocalName}, ?MODULE, [Endpoint], []).
|
gen_server:start_link({via, gproc, {n, l, LocalName}}, ?MODULE, [Endpoint], []).
|
||||||
|
|
||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
%%% gen_server callbacks
|
%%% gen_server callbacks
|
||||||
@ -44,9 +44,10 @@ start_link(LocalName, Endpoint = #endpoint{config = #http_endpoint{}}) when is_a
|
|||||||
-spec(init(Args :: term()) ->
|
-spec(init(Args :: term()) ->
|
||||||
{ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} |
|
{ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} |
|
||||||
{stop, Reason :: term()} | ignore).
|
{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()),
|
endpoint_subscription:subscribe(Matcher, self()),
|
||||||
Buffer = endpoint_buffer:new(Endpoint, 10),
|
Buffer = endpoint_buffer:new(Endpoint, PoolSize),
|
||||||
{ok, #state{endpoint = Endpoint, buffer = Buffer}}.
|
{ok, #state{endpoint = Endpoint, buffer = Buffer}}.
|
||||||
|
|
||||||
%% @private
|
%% @private
|
||||||
@ -69,20 +70,17 @@ handle_call(get_stat, _From, State = #state{buffer = Buffer}) ->
|
|||||||
{noreply, NewState :: #state{}} |
|
{noreply, NewState :: #state{}} |
|
||||||
{noreply, NewState :: #state{}, timeout() | hibernate} |
|
{noreply, NewState :: #state{}, timeout() | hibernate} |
|
||||||
{stop, Reason :: term(), NewState :: #state{}}).
|
{stop, Reason :: term(), NewState :: #state{}}).
|
||||||
handle_cast({forward, Metric}, State = #state{buffer = Buffer, endpoint = #endpoint{config = #http_endpoint{token = Token}}}) ->
|
handle_cast({forward, Metric}, State = #state{buffer = Buffer}) ->
|
||||||
Tuple = case is_binary(Token) andalso Token /= <<>> of
|
NBuffer = endpoint_buffer:append(Metric, Buffer),
|
||||||
true ->
|
|
||||||
Sign = iot_util:sha256(erlang:iolist_to_binary([Token, Metric, Token])),
|
|
||||||
{Metric, Sign};
|
|
||||||
false ->
|
|
||||||
{Metric, <<>>}
|
|
||||||
end,
|
|
||||||
NBuffer = endpoint_buffer:append(Tuple, Buffer),
|
|
||||||
{noreply, State#state{buffer = NBuffer}};
|
{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}) ->
|
handle_cast(cleanup, State = #state{buffer = Buffer}) ->
|
||||||
endpoint_buffer:cleanup(Buffer),
|
NBuffer = endpoint_buffer:cleanup(Buffer),
|
||||||
{noreply, State}.
|
{noreply, State#state{buffer = NBuffer}}.
|
||||||
|
|
||||||
%% @private
|
%% @private
|
||||||
%% @doc Handling all non call/cast messages
|
%% @doc Handling all non call/cast messages
|
||||||
@ -90,32 +88,25 @@ handle_cast(cleanup, State = #state{buffer = Buffer}) ->
|
|||||||
{noreply, NewState :: #state{}} |
|
{noreply, NewState :: #state{}} |
|
||||||
{noreply, NewState :: #state{}, timeout() | hibernate} |
|
{noreply, NewState :: #state{}, timeout() | hibernate} |
|
||||||
{stop, Reason :: term(), NewState :: #state{}}).
|
{stop, Reason :: term(), NewState :: #state{}}).
|
||||||
handle_info({next_data, Id, {Metric, Sign}}, State = #state{buffer = Buffer, endpoint = #endpoint{config = #http_endpoint{url = Url}}}) ->
|
handle_info({next_data, Id, Metric}, State = #state{buffer = Buffer, endpoint = #endpoint{config = #http_endpoint{url = Url, token = Token}}}) ->
|
||||||
BaseHeaders = [{<<"Content-Type">>, <<"application/json">>}],
|
Headers = [{<<"Content-Type">>, <<"application/json">>}] ++ patch_headers(Metric, Token),
|
||||||
ExtraHeaders = if
|
|
||||||
Sign =:= <<>> -> [];
|
|
||||||
true -> [{<<"X-Signature">>, Sign}]
|
|
||||||
end,
|
|
||||||
Headers = BaseHeaders ++ ExtraHeaders,
|
|
||||||
|
|
||||||
case hackney:request(post, Url, Headers, Metric) of
|
case hackney:request(post, Url, Headers, Metric) of
|
||||||
{ok, 200, _, ClientRef} ->
|
{ok, HttpCode, _, ClientRef} when HttpCode >= 200, HttpCode < 300 ->
|
||||||
{ok, RespBody} = hackney:body(ClientRef),
|
RespBody = read_response_body(ClientRef),
|
||||||
hackney:close(ClientRef),
|
logger:debug("[endpoint_http] url: ~p, response is: ~p", [Url, RespBody]),
|
||||||
lager:debug("[endpoint_http] url: ~p, response is: ~p", [Url, RespBody]),
|
|
||||||
NBuffer = endpoint_buffer:ack(Id, Buffer),
|
NBuffer = endpoint_buffer:ack(Id, Buffer),
|
||||||
|
|
||||||
{noreply, State#state{buffer = NBuffer}};
|
{noreply, State#state{buffer = NBuffer}};
|
||||||
{ok, HttpCode, _, ClientRef} ->
|
{ok, HttpCode, _, ClientRef} ->
|
||||||
{ok, RespBody} = hackney:body(ClientRef),
|
RespBody = read_response_body(ClientRef),
|
||||||
hackney:close(ClientRef),
|
logger:warning("[endpoint_http] url: ~p, http_code: ~p, response is: ~p", [Url, HttpCode, RespBody]),
|
||||||
lager:debug("[endpoint_http] url: ~p, http_code: ~p, response is: ~p", [Url, HttpCode, RespBody]),
|
{noreply, State};
|
||||||
NBuffer = endpoint_buffer:ack(Id, Buffer),
|
|
||||||
{noreply, State#state{buffer = NBuffer}};
|
|
||||||
{error, Reason} ->
|
{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}
|
{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
|
%% @private
|
||||||
%% @doc This function is called by a gen_server when it is about to
|
%% @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.
|
%% with Reason. The return value is ignored.
|
||||||
-spec(terminate(Reason :: (normal | shutdown | {shutdown, term()} | term()),
|
-spec(terminate(Reason :: (normal | shutdown | {shutdown, term()} | term()),
|
||||||
State :: #state{}) -> term()).
|
State :: #state{}) -> term()).
|
||||||
terminate(_Reason, _State = #state{}) ->
|
terminate(_Reason, #state{buffer = Buffer}) ->
|
||||||
|
_ = endpoint_buffer:cleanup(Buffer),
|
||||||
ok.
|
ok.
|
||||||
|
|
||||||
%% @private
|
%% @private
|
||||||
@ -138,3 +130,28 @@ code_change(_OldVsn, State = #state{}, _Extra) ->
|
|||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
%%% Internal functions
|
%%% 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(_, _) ->
|
||||||
|
[].
|
||||||
261
apps/endpoint/src/endpoint_kafka.erl
Normal file
261
apps/endpoint/src/endpoint_kafka.erl
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @author aresei
|
||||||
|
%%% @copyright (C) 2023, <COMPANY>
|
||||||
|
%%% @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.
|
||||||
227
apps/endpoint/src/endpoint_log.erl
Normal file
227
apps/endpoint/src/endpoint_log.erl
Normal file
@ -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,
|
||||||
|
<<LoggedContent:MaxBytes/binary, _/binary>> = Content,
|
||||||
|
{LoggedContent, true}.
|
||||||
285
apps/endpoint/src/endpoint_mqtt.erl
Normal file
285
apps/endpoint/src/endpoint_mqtt.erl
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @author aresei
|
||||||
|
%%% @copyright (C) 2023, <COMPANY>
|
||||||
|
%%% @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.
|
||||||
953
apps/endpoint/src/endpoint_outbox.erl
Normal file
953
apps/endpoint/src/endpoint_outbox.erl
Normal file
@ -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, <<Payload:PayloadSize/binary, StoredCrc:32/unsigned-big-integer>>} ->
|
||||||
|
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 = <<Tail/binary, Bin/binary>>,
|
||||||
|
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.
|
||||||
457
apps/endpoint/src/endpoint_subscription.erl
Normal file
457
apps/endpoint/src/endpoint_subscription.erl
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @author anlicheng
|
||||||
|
%%% @copyright (C) 2025, <COMPANY>
|
||||||
|
%%% @doc
|
||||||
|
%%% Endpoint 数据分发订阅索引。
|
||||||
|
%%%
|
||||||
|
%%% 当前实现面向较大订阅量场景,publish 热路径不再全量遍历订阅表,而是把
|
||||||
|
%%% 订阅拆成 exact 和 wildcard 两类:
|
||||||
|
%%%
|
||||||
|
%%% <ul>
|
||||||
|
%%% <li>精确订阅写入 endpoint_subscription_exact,publish 时按 RouteKey
|
||||||
|
%%% 直接 ets:lookup/2。</li>
|
||||||
|
%%% <li>包含通配符的订阅写入 ETS trie。trie 边存放在
|
||||||
|
%%% endpoint_subscription_trie_edge,节点订阅存放在
|
||||||
|
%%% endpoint_subscription_trie_sub。</li>
|
||||||
|
%%% <li>endpoint_subscription_reverse 保存 SubscriberPid 到订阅对象的反向索引,
|
||||||
|
%%% 用于 unsubscribe 和进程 DOWN 清理。</li>
|
||||||
|
%%% <li>endpoint_subscription_pid 记录每个 SubscriberPid 的 monitor 和订阅计数,
|
||||||
|
%%% 同一个 pid 只 monitor 一次。</li>
|
||||||
|
%%% </ul>
|
||||||
|
%%%
|
||||||
|
%%% 所有 matcher 共用同一个 root node,root node id 固定为 0。新 trie 节点使用
|
||||||
|
%%% 单调递增的 integer node id。subscribe/unsubscribe 通过本 gen_server 串行写 ETS;
|
||||||
|
%%% publish/2 直接并发读取 ETS,避免中心 gen_server 成为数据分发瓶颈。
|
||||||
|
%%%
|
||||||
|
%%% 匹配规则:
|
||||||
|
%%%
|
||||||
|
%%% <ul>
|
||||||
|
%%% <li>普通 segment 必须完全相等,例如 <<"device/a/temp">> 只精确匹配
|
||||||
|
%%% 同名订阅。</li>
|
||||||
|
%%% <li><<"*">> 是单级通配,只匹配一个 segment。例如
|
||||||
|
%%% <<"device/*/temp">> 匹配 <<"device/a/temp">>,不匹配
|
||||||
|
%%% <<"device/a/b/temp">>。</li>
|
||||||
|
%%% <li><<"+">> 是末尾多级通配,只允许出现在 matcher 最后一段,并且至少
|
||||||
|
%%% 匹配一个剩余 segment。例如 <<"device/+">> 匹配 <<"device/a">>
|
||||||
|
%%% 和 <<"device/a/temp">>,不匹配 <<"device">>。</li>
|
||||||
|
%%% </ul>
|
||||||
|
%%%
|
||||||
|
%%% publish/2 的匹配流程:
|
||||||
|
%%%
|
||||||
|
%%% <ol>
|
||||||
|
%%% <li>从 exact 表直接查找 RouteKey 对应的订阅。</li>
|
||||||
|
%%% <li>把 RouteKey 按 "/" 拆分后,从 trie root 开始逐层匹配。每层最多查找
|
||||||
|
%%% 当前 segment 分支和 <<"*">> 分支。</li>
|
||||||
|
%%% <li>每消费一层前,收集当前候选节点上的 plus 订阅;全部 segment 消费完后,
|
||||||
|
%%% 收集候选节点上的 exact 订阅。</li>
|
||||||
|
%%% <li>合并 exact 和 trie 命中结果,按原有优先级排序,再按 SubscriberPid 去重。
|
||||||
|
%%% 同一个 pid 同时被多个 matcher 命中时,保留优先级最高的订阅。</li>
|
||||||
|
%%% <li>对最终订阅者执行 endpoint:forward/2。</li>
|
||||||
|
%%% </ol>
|
||||||
|
%%% @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)].
|
||||||
@ -3,7 +3,7 @@
|
|||||||
%% @end
|
%% @end
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
|
|
||||||
-module(endpoint_sup_sup).
|
-module(endpoint_sup).
|
||||||
|
|
||||||
-behaviour(supervisor).
|
-behaviour(supervisor).
|
||||||
-include("endpoint.hrl").
|
-include("endpoint.hrl").
|
||||||
@ -28,6 +28,15 @@ start_link() ->
|
|||||||
init([]) ->
|
init([]) ->
|
||||||
SupFlags = #{strategy => one_for_all, intensity => 1000, period => 3600},
|
SupFlags = #{strategy => one_for_all, intensity => 1000, period => 3600},
|
||||||
ChildSpecs = [
|
ChildSpecs = [
|
||||||
|
#{
|
||||||
|
id => endpoint_log,
|
||||||
|
start => {'endpoint_log', start_link, []},
|
||||||
|
restart => permanent,
|
||||||
|
shutdown => 2000,
|
||||||
|
type => worker,
|
||||||
|
modules => ['endpoint_log']
|
||||||
|
},
|
||||||
|
|
||||||
#{
|
#{
|
||||||
id => endpoint_subscription,
|
id => endpoint_subscription,
|
||||||
start => {'endpoint_subscription', start_link, []},
|
start => {'endpoint_subscription', start_link, []},
|
||||||
@ -38,12 +47,12 @@ init([]) ->
|
|||||||
},
|
},
|
||||||
|
|
||||||
#{
|
#{
|
||||||
id => 'endpoint_sup',
|
id => 'endpoint_adapter_sup',
|
||||||
start => {'endpoint_sup', start_link, []},
|
start => {'endpoint_adapter_sup', start_link, []},
|
||||||
restart => permanent,
|
restart => permanent,
|
||||||
shutdown => 2000,
|
shutdown => 2000,
|
||||||
type => supervisor,
|
type => supervisor,
|
||||||
modules => ['endpoint_sup']
|
modules => ['endpoint_adapter_sup']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
{ok, {SupFlags, ChildSpecs}}.
|
{ok, {SupFlags, ChildSpecs}}.
|
||||||
93
apps/endpoint/src/endpoint_tester.erl
Normal file
93
apps/endpoint/src/endpoint_tester.erl
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @author anlicheng
|
||||||
|
%%% @copyright (C) 2026, <COMPANY>
|
||||||
|
%%% @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.
|
||||||
18
apps/endpoint/src/endpoint_util.erl
Normal file
18
apps/endpoint/src/endpoint_util.erl
Normal file
@ -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)).
|
||||||
20
apps/iot/include/domain_model.hrl
Normal file
20
apps/iot/include/domain_model.hrl
Normal file
@ -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()
|
||||||
|
}).
|
||||||
@ -13,6 +13,10 @@
|
|||||||
-define(HOST_ONLINE, 1).
|
-define(HOST_ONLINE, 1).
|
||||||
-define(HOST_NOT_JOINED, -1).
|
-define(HOST_NOT_JOINED, -1).
|
||||||
|
|
||||||
|
%% 主机是否授权处理上报数据
|
||||||
|
-define(HOST_DENIED, 0).
|
||||||
|
-define(HOST_AUTHORIZED, 1).
|
||||||
|
|
||||||
%% 设备是否在线状态
|
%% 设备是否在线状态
|
||||||
-define(DEVICE_OFFLINE, 0).
|
-define(DEVICE_OFFLINE, 0).
|
||||||
-define(DEVICE_ONLINE, 1).
|
-define(DEVICE_ONLINE, 1).
|
||||||
@ -22,52 +26,3 @@
|
|||||||
-define(TASK_STATUS_INIT, -1). %% 未接入
|
-define(TASK_STATUS_INIT, -1). %% 未接入
|
||||||
-define(TASK_STATUS_FAILED, 0). %% 离线
|
-define(TASK_STATUS_FAILED, 0). %% 离线
|
||||||
-define(TASK_STATUS_OK, 1). %% 在线
|
-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()
|
|
||||||
}).
|
|
||||||
@ -1,23 +1,17 @@
|
|||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
%%% @author anlicheng
|
%%% @author anlicheng
|
||||||
%%% @copyright (C) 2025, <COMPANY>
|
%%% @copyright (C) 2026, <COMPANY>
|
||||||
%%% @doc
|
%%% @doc
|
||||||
%%%
|
%%%
|
||||||
%%% @end
|
%%% @end
|
||||||
%%% Created : 08. 5月 2025 12:08
|
%%% Created : 24. 4月 2026 16:54
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-author("anlicheng").
|
-author("anlicheng").
|
||||||
|
|
||||||
%% 用来保存微服务的配置
|
-record(efka_client, {
|
||||||
-record(service_config, {
|
uuid :: binary(),
|
||||||
service_id :: binary(),
|
token_hash :: binary(),
|
||||||
host_uuid :: binary(),
|
salt :: binary(),
|
||||||
config_json = <<>> :: binary(),
|
heartbeat_secret :: binary(),
|
||||||
%% 保持上一个版本的内容,错误时回滚
|
timestamp = 0 :: integer()
|
||||||
last_config_json = <<>> :: binary(),
|
|
||||||
%% 最后一次修改的用户id
|
|
||||||
last_edit_user :: integer(),
|
|
||||||
%% 状态: 0: 停止, 1: 运行中
|
|
||||||
update_ts = 0,
|
|
||||||
create_ts = 0
|
|
||||||
}).
|
}).
|
||||||
@ -1,86 +0,0 @@
|
|||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author anlicheng
|
|
||||||
%%% @copyright (C) 2025, <COMPANY>
|
|
||||||
%%% @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()
|
|
||||||
}).
|
|
||||||
16
apps/iot/include/protocol.hrl
Normal file
16
apps/iot/include/protocol.hrl
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @author anlicheng
|
||||||
|
%%% @copyright (C) 2025, <COMPANY>
|
||||||
|
%%% @doc
|
||||||
|
%%% 传输层协议常量定义
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-author("anlicheng").
|
||||||
|
|
||||||
|
%% 一级帧类型
|
||||||
|
%% REQUEST: 需要响应的请求帧
|
||||||
|
%% REPLY: 对 REQUEST 的响应帧
|
||||||
|
%% CAST: 单向消息帧,不需要响应
|
||||||
|
-define(FRAME_REQUEST, 16#01).
|
||||||
|
-define(FRAME_REPLY, 16#02).
|
||||||
|
-define(FRAME_CAST, 16#03).
|
||||||
@ -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}
|
|
||||||
23
apps/iot/priv/ssl/server.crt
Normal file
23
apps/iot/priv/ssl/server.crt
Normal file
@ -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-----
|
||||||
18
apps/iot/priv/ssl/server.csr
Normal file
18
apps/iot/priv/ssl/server.csr
Normal file
@ -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-----
|
||||||
28
apps/iot/priv/ssl/server.key
Normal file
28
apps/iot/priv/ssl/server.key
Normal file
@ -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-----
|
||||||
@ -6,8 +6,9 @@
|
|||||||
%%% @end
|
%%% @end
|
||||||
%%% Created : 24. 12月 2023 15:42
|
%%% Created : 24. 12月 2023 15:42
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-module(iot_api).
|
-module(iot_api_client).
|
||||||
-author("anlicheng").
|
-author("anlicheng").
|
||||||
|
-include("domain_model.hrl").
|
||||||
|
|
||||||
%% API
|
%% API
|
||||||
-export([ai_event/1]).
|
-export([ai_event/1]).
|
||||||
@ -31,20 +32,30 @@ get_all_hosts() ->
|
|||||||
[]
|
[]
|
||||||
end.
|
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) ->
|
get_host_by_uuid(UUID) when is_binary(UUID) ->
|
||||||
case do_get("/get_host_by_uuid", [{<<"uuid">>, UUID}]) of
|
case do_get("/get_host_by_uuid", [{<<"uuid">>, UUID}]) of
|
||||||
{ok, HostInfo} ->
|
{ok, HostInfo} ->
|
||||||
{ok, HostInfo};
|
case host_info_record(HostInfo) of
|
||||||
|
{ok, Record} ->
|
||||||
|
{ok, Record};
|
||||||
|
error ->
|
||||||
|
undefined
|
||||||
|
end;
|
||||||
_ ->
|
_ ->
|
||||||
undefined
|
undefined
|
||||||
end.
|
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) ->
|
get_host_by_id(HostId) when is_integer(HostId) ->
|
||||||
case do_get("/get_host_by_id", [{<<"host_id">>, integer_to_binary(HostId)}]) of
|
case do_get("/get_host_by_id", [{<<"host_id">>, integer_to_binary(HostId)}]) of
|
||||||
{ok, HostInfo} ->
|
{ok, HostInfo} ->
|
||||||
{ok, HostInfo};
|
case host_info_record(HostInfo) of
|
||||||
|
{ok, Record} ->
|
||||||
|
{ok, Record};
|
||||||
|
error ->
|
||||||
|
undefined
|
||||||
|
end;
|
||||||
_ ->
|
_ ->
|
||||||
undefined
|
undefined
|
||||||
end.
|
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) ->
|
change_host_status(UUID, NStatus) when is_binary(UUID), is_integer(NStatus) ->
|
||||||
do_post("/change_host_status", #{<<"uuid">> => UUID, <<"new_status">> => 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) ->
|
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) ->
|
get_device_by_uuid(DeviceUUID) when is_binary(DeviceUUID) ->
|
||||||
case do_get("/get_device_by_uuid", [{<<"device_uuid">>, DeviceUUID}]) of
|
case do_get("/get_device_by_uuid", [{<<"device_uuid">>, DeviceUUID}]) of
|
||||||
{ok, DeviceInfo} ->
|
{ok, DeviceInfo} ->
|
||||||
{ok, DeviceInfo};
|
case device_info_record(DeviceInfo) of
|
||||||
|
{ok, Record} ->
|
||||||
|
{ok, Record};
|
||||||
|
error ->
|
||||||
|
undefined
|
||||||
|
end;
|
||||||
_ ->
|
_ ->
|
||||||
undefined
|
undefined
|
||||||
end.
|
end.
|
||||||
@ -106,18 +129,18 @@ ai_event(Id) when is_integer(Id) ->
|
|||||||
<<"token">> => Token,
|
<<"token">> => Token,
|
||||||
<<"id">> => Id
|
<<"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
|
case hackney:request(post, Url, Headers, Body, [{pool, false}]) of
|
||||||
{ok, 200, _, ClientRef} ->
|
{ok, 200, _, ClientRef} ->
|
||||||
{ok, RespBody} = hackney:body(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);
|
hackney:close(ClientRef);
|
||||||
{ok, HttpCode, _, ClientRef} ->
|
{ok, HttpCode, _, ClientRef} ->
|
||||||
{ok, RespBody} = hackney:body(ClientRef),
|
{ok, RespBody} = hackney:body(ClientRef),
|
||||||
hackney:close(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} ->
|
{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.
|
end.
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
@ -132,13 +155,13 @@ do_post(Path, Params) when is_list(Path), is_map(Params) ->
|
|||||||
{<<"Accept">>, <<"application/json">>}
|
{<<"Accept">>, <<"application/json">>}
|
||||||
],
|
],
|
||||||
Url = BaseUrl ++ Path,
|
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
|
case hackney:request(post, Url, Headers, Body, [{pool, false}]) of
|
||||||
{ok, 200, _, ClientRef} ->
|
{ok, 200, _, ClientRef} ->
|
||||||
{ok, RespBody} = hackney:body(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),
|
hackney:close(ClientRef),
|
||||||
case catch jiffy:decode(RespBody, [return_maps]) of
|
case catch json:decode(RespBody) of
|
||||||
#{<<"result">> := Result} ->
|
#{<<"result">> := Result} ->
|
||||||
{ok, Result};
|
{ok, Result};
|
||||||
#{<<"error">> := #{<<"code">> := Code, <<"message">> := Message}} ->
|
#{<<"error">> := #{<<"code">> := Code, <<"message">> := Message}} ->
|
||||||
@ -151,10 +174,10 @@ do_post(Path, Params) when is_list(Path), is_map(Params) ->
|
|||||||
{ok, HttpCode, _, ClientRef} ->
|
{ok, HttpCode, _, ClientRef} ->
|
||||||
{ok, RespBody} = hackney:body(ClientRef),
|
{ok, RespBody} = hackney:body(ClientRef),
|
||||||
hackney:close(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, {HttpCode, RespBody}};
|
||||||
{error, Reason} ->
|
{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}
|
{error, Reason}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
@ -177,8 +200,8 @@ do_get(Path, Params) when is_list(Path), is_list(Params) ->
|
|||||||
{ok, 200, _, ClientRef} ->
|
{ok, 200, _, ClientRef} ->
|
||||||
{ok, RespBody} = hackney:body(ClientRef),
|
{ok, RespBody} = hackney:body(ClientRef),
|
||||||
hackney:close(ClientRef),
|
hackney:close(ClientRef),
|
||||||
lager:debug("[iot_api] url: ~p, get response is: ~p", [Url, RespBody]),
|
logger:debug("[iot_api_client] url: ~p, get response is: ~p", [Url, RespBody]),
|
||||||
case catch jiffy:decode(RespBody, [return_maps]) of
|
case catch json:decode(RespBody) of
|
||||||
#{<<"result">> := Result} ->
|
#{<<"result">> := Result} ->
|
||||||
{ok, Result};
|
{ok, Result};
|
||||||
#{<<"error">> := #{<<"code">> := Code, <<"message">> := Message}} ->
|
#{<<"error">> := #{<<"code">> := Code, <<"message">> := Message}} ->
|
||||||
@ -191,9 +214,46 @@ do_get(Path, Params) when is_list(Path), is_list(Params) ->
|
|||||||
{ok, HttpCode, _, ClientRef} ->
|
{ok, HttpCode, _, ClientRef} ->
|
||||||
{ok, RespBody} = hackney:body(ClientRef),
|
{ok, RespBody} = hackney:body(ClientRef),
|
||||||
hackney:close(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, {HttpCode, RespBody}};
|
||||||
{error, Reason} ->
|
{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}
|
{error, Reason}
|
||||||
end.
|
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).
|
||||||
@ -57,7 +57,7 @@ get_precision(Timestamp) when is_integer(Timestamp) ->
|
|||||||
<<"ms">>
|
<<"ms">>
|
||||||
end.
|
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) ->
|
write_data(Measurement, Tags, FieldsList, Timestamp) when is_binary(Measurement), is_map(Tags), is_list(FieldsList), is_integer(Timestamp) ->
|
||||||
%% 过来掉没有key的选项
|
%% 过来掉没有key的选项
|
||||||
NFieldsList = lists:filter(fun data_filter/1, FieldsList),
|
NFieldsList = lists:filter(fun data_filter/1, FieldsList),
|
||||||
@ -76,12 +76,12 @@ write_data(Measurement, Tags, FieldsList, Timestamp) when is_binary(Measurement)
|
|||||||
ok
|
ok
|
||||||
end.
|
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, Points) when is_pid(Pid), is_binary(Bucket), is_binary(Org), is_list(Points) ->
|
||||||
write(Pid, Bucket, Org, <<"ms">>, Points).
|
write(Pid, Bucket, Org, <<"ms">>, Points).
|
||||||
|
|
||||||
%% Precision的值为: ms|ns|s; 表示时间的精度,默认为毫秒(ms)
|
%% 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) ->
|
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}).
|
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} |
|
{ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} |
|
||||||
{stop, Reason :: term()} | ignore).
|
{stop, Reason :: term()} | ignore).
|
||||||
init([InfluxProps]) ->
|
init([InfluxProps]) ->
|
||||||
|
ok = iot_log:set_metadata(),
|
||||||
Token = proplists:get_value(token, InfluxProps),
|
Token = proplists:get_value(token, InfluxProps),
|
||||||
Host = proplists:get_value(host, InfluxProps),
|
Host = proplists:get_value(host, InfluxProps),
|
||||||
Port = proplists:get_value(port, InfluxProps),
|
Port = proplists:get_value(port, InfluxProps),
|
||||||
@ -151,18 +152,18 @@ handle_cast({write, Bucket, Org, Precision, Points}, State = #state{host = Host,
|
|||||||
query => Query
|
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
|
case hackney:request(post, Url, Headers, GZipBody, [{pool, false}]) of
|
||||||
{ok, StatusCode, _RespHeaders, ClientRef} ->
|
{ok, StatusCode, _RespHeaders, ClientRef} ->
|
||||||
case hackney:body(ClientRef) of
|
case hackney:body(ClientRef) of
|
||||||
{ok, RespBody} ->
|
{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} ->
|
{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,
|
end,
|
||||||
hackney:close(ClientRef);
|
hackney:close(ClientRef);
|
||||||
{error, Reason} ->
|
{error, Reason} ->
|
||||||
lager:warning("[influx_client] request result is: ~p", [Reason])
|
logger:warning("[influx_client] request result is: ~p", [Reason])
|
||||||
end,
|
end,
|
||||||
|
|
||||||
{noreply, State}.
|
{noreply, State}.
|
||||||
@ -45,7 +45,7 @@ field_val(V) when is_float(V) ->
|
|||||||
field_val(V) when is_binary(V) ->
|
field_val(V) when is_binary(V) ->
|
||||||
<<$", V/binary, $">>;
|
<<$", V/binary, $">>;
|
||||||
field_val(V) when is_list(V); is_map(V) ->
|
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, $">>;
|
<<$", "base64:", S/binary, $">>;
|
||||||
field_val(true) ->
|
field_val(true) ->
|
||||||
<<"true">>;
|
<<"true">>;
|
||||||
@ -56,4 +56,3 @@ as_list(L) when is_list(L) ->
|
|||||||
L;
|
L;
|
||||||
as_list(L) when is_map(L) ->
|
as_list(L) when is_map(L) ->
|
||||||
maps:to_list(L).
|
maps:to_list(L).
|
||||||
|
|
||||||
@ -8,12 +8,12 @@
|
|||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-module(endpoint_kafka_test).
|
-module(endpoint_kafka_test).
|
||||||
-author("anlicheng").
|
-author("anlicheng").
|
||||||
-include("endpoint.hrl").
|
-include_lib("endpoint/include/endpoint.hrl").
|
||||||
|
|
||||||
%% API
|
%% API
|
||||||
-export([start_test/0, test_consumer/0]).
|
-export([start_manual/0, test_consumer/0]).
|
||||||
|
|
||||||
start_test() ->
|
start_manual() ->
|
||||||
Name = endpoint:get_name(100),
|
Name = endpoint:get_name(100),
|
||||||
{ok, Pid} = endpoint_kafka:start_link(Name, #endpoint{
|
{ok, Pid} = endpoint_kafka:start_link(Name, #endpoint{
|
||||||
id = 100,
|
id = 100,
|
||||||
@ -29,10 +29,10 @@ start_test() ->
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
Json = jiffy:encode(#{
|
Json = iolist_to_binary(json:encode(#{
|
||||||
<<"name">> => <<"anlicheng">>,
|
<<"name">> => <<"anlicheng">>,
|
||||||
<<"age">> => 30
|
<<"age">> => 30
|
||||||
}, [force_utf8]),
|
})),
|
||||||
|
|
||||||
endpoint:forward(Pid, Json),
|
endpoint:forward(Pid, Json),
|
||||||
ok.
|
ok.
|
||||||
@ -49,7 +49,7 @@ test_consumer() ->
|
|||||||
|
|
||||||
ok = brod:start_client(KafkaBootstrapEndpoints, client1, ClientConfig),
|
ok = brod:start_client(KafkaBootstrapEndpoints, client1, ClientConfig),
|
||||||
SubscriberCallbackFun = fun(_Partition, Msg, ShellPid = CallbackState) ->
|
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}
|
ShellPid ! Msg, {ok, ack, CallbackState}
|
||||||
end,
|
end,
|
||||||
Res = brod_topic_subscriber:start_link(client1, Topic, all,
|
Res = brod_topic_subscriber:start_link(client1, Topic, all,
|
||||||
@ -57,4 +57,4 @@ test_consumer() ->
|
|||||||
_CommittedOffsets=[], message, SubscriberCallbackFun,
|
_CommittedOffsets=[], message, SubscriberCallbackFun,
|
||||||
_CallbackState=self()),
|
_CallbackState=self()),
|
||||||
|
|
||||||
lager:debug("start subscriber res: ~p", [Res]).
|
logger:debug("start subscriber res: ~p", [Res]).
|
||||||
@ -67,23 +67,23 @@ init([]) ->
|
|||||||
{retry_interval, 5}
|
{retry_interval, 5}
|
||||||
],
|
],
|
||||||
|
|
||||||
lager:debug("[opts] is: ~p", [Opts]),
|
logger:debug("[opts] is: ~p", [Opts]),
|
||||||
case emqtt:start_link(Opts) of
|
case emqtt:start_link(Opts) of
|
||||||
{ok, ConnPid} ->
|
{ok, ConnPid} ->
|
||||||
%% 监听和host相关的全部事件
|
%% 监听和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),
|
{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),
|
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}};
|
{ok, #state{conn_pid = ConnPid}};
|
||||||
ignore ->
|
ignore ->
|
||||||
lager:debug("[iot_mqtt_subscriber] connect emqx get ignore"),
|
logger:debug("[iot_mqtt_subscriber] connect emqx get ignore"),
|
||||||
{stop, ignore};
|
{stop, ignore};
|
||||||
{error, Reason} ->
|
{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}
|
{stop, Reason}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
@ -116,19 +116,19 @@ handle_cast(_Request, State = #state{}) ->
|
|||||||
{noreply, NewState :: #state{}, timeout() | hibernate} |
|
{noreply, NewState :: #state{}, timeout() | hibernate} |
|
||||||
{stop, Reason :: term(), NewState :: #state{}}).
|
{stop, Reason :: term(), NewState :: #state{}}).
|
||||||
handle_info({disconnect, ReasonCode, Properties}, State = #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};
|
{stop, disconnected, State};
|
||||||
%% 必须要做到消息的快速分发,数据的json反序列需要在host进程进行
|
%% 必须要做到消息的快速分发,数据的json反序列需要在host进程进行
|
||||||
handle_info({publish, #{packet_id := _PacketId, payload := Payload, qos := Qos, topic := Topic}}, State = #state{conn_pid = _ConnPid}) ->
|
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进程去处理
|
%% 将消息分发到对应的host进程去处理
|
||||||
{noreply, State};
|
{noreply, State};
|
||||||
handle_info({puback, Packet = #{packet_id := _PacketId}}, State = #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};
|
{noreply, State};
|
||||||
|
|
||||||
handle_info(Info, State = #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}.
|
{noreply, State}.
|
||||||
|
|
||||||
%% @private
|
%% @private
|
||||||
@ -144,10 +144,10 @@ terminate(Reason, _State = #state{conn_pid = ConnPid}) when is_pid(ConnPid) ->
|
|||||||
{ok, _Props, _ReasonCode} = emqtt:unsubscribe(ConnPid, #{}, TopicNames),
|
{ok, _Props, _ReasonCode} = emqtt:unsubscribe(ConnPid, #{}, TopicNames),
|
||||||
|
|
||||||
ok = emqtt:disconnect(ConnPid),
|
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;
|
ok;
|
||||||
terminate(Reason, _State) ->
|
terminate(Reason, _State) ->
|
||||||
lager:debug("[iot_mqtt_subscriber] terminate with reason: ~p", [Reason]),
|
logger:debug("[iot_mqtt_subscriber] terminate with reason: ~p", [Reason]),
|
||||||
ok.
|
ok.
|
||||||
|
|
||||||
%% @private
|
%% @private
|
||||||
@ -13,13 +13,5 @@
|
|||||||
-export([test/0]).
|
-export([test/0]).
|
||||||
|
|
||||||
test() ->
|
test() ->
|
||||||
|
{error, disabled}.
|
||||||
{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">>}).
|
|
||||||
|
|
||||||
|
|
||||||
@ -12,7 +12,6 @@
|
|||||||
|
|
||||||
%% API
|
%% API
|
||||||
-export([rsa_encode/1]).
|
-export([rsa_encode/1]).
|
||||||
-export([insert_services/1]).
|
|
||||||
-export([test_influxdb/0]).
|
-export([test_influxdb/0]).
|
||||||
|
|
||||||
test_influxdb() ->
|
test_influxdb() ->
|
||||||
@ -29,34 +28,20 @@ test_influxdb() ->
|
|||||||
end)
|
end)
|
||||||
end, lists:seq(1, 100)).
|
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) ->
|
rsa_encode(Data) when is_binary(Data) ->
|
||||||
%% 读取相关配置
|
%% 读取相关配置
|
||||||
PublicPemFile = "/tmp/keys/public.pem",
|
PublicPemFile = "/tmp/keys/public.pem",
|
||||||
|
|
||||||
%% 私钥保存解析后的
|
%% 私钥保存解析后的
|
||||||
{ok, PubBin} = file:read_file(PublicPemFile),
|
{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),
|
[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),
|
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),
|
EncData = public_key:encrypt_public(Data, PubKey),
|
||||||
lager:debug("enc data is: ~p", [EncData]),
|
logger:debug("enc data is: ~p", [EncData]),
|
||||||
|
|
||||||
rsa_decode(EncData),
|
rsa_decode(EncData),
|
||||||
|
|
||||||
@ -68,13 +53,13 @@ rsa_decode(EncData) when is_binary(EncData) ->
|
|||||||
|
|
||||||
%% 私钥保存解析后的
|
%% 私钥保存解析后的
|
||||||
{ok, PubBin} = file:read_file(PublicPemFile),
|
{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),
|
[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),
|
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),
|
PlainData = public_key:decrypt_private(EncData, PubKey),
|
||||||
lager:debug("plain data is: ~p", [PlainData]),
|
logger:debug("plain data is: ~p", [PlainData]),
|
||||||
|
|
||||||
ok.
|
ok.
|
||||||
588
apps/iot/src/docker/docker_container_builder.erl
Normal file
588
apps/iot/src/docker/docker_container_builder.erl
Normal file
@ -0,0 +1,588 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @author
|
||||||
|
%%% @copyright (C) 2026, <COMPANY>
|
||||||
|
%%% @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) ->
|
||||||
|
<<HostPath/binary, ":", ContainerPath/binary, ":ro">>;
|
||||||
|
volume_bind(HostPath, ContainerPath, false) when is_binary(HostPath), is_binary(ContainerPath) ->
|
||||||
|
<<HostPath/binary, ":", ContainerPath/binary>>.
|
||||||
|
|
||||||
|
-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(<<Char, Rest/binary>>, Acc)
|
||||||
|
when (Char >= $0 andalso Char =< $9) orelse Char =:= $. ->
|
||||||
|
split_numeric_suffix(Rest, <<Acc/binary, Char>>);
|
||||||
|
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))).
|
||||||
@ -1,105 +0,0 @@
|
|||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author aresei
|
|
||||||
%%% @copyright (C) 2023, <COMPANY>
|
|
||||||
%%% @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
|
|
||||||
%%%===================================================================
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author aresei
|
|
||||||
%%% @copyright (C) 2023, <COMPANY>
|
|
||||||
%%% @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).
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author aresei
|
|
||||||
%%% @copyright (C) 2023, <COMPANY>
|
|
||||||
%%% @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
|
|
||||||
%%%===================================================================
|
|
||||||
@ -1,206 +0,0 @@
|
|||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author anlicheng
|
|
||||||
%%% @copyright (C) 2025, <COMPANY>
|
|
||||||
%%% @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).
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author anlicheng
|
|
||||||
%%% @copyright (C) 2024, <COMPANY>
|
|
||||||
%%% @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
|
|
||||||
%%%===================================================================
|
|
||||||
147
apps/iot/src/host/iot_container_task.erl
Normal file
147
apps/iot/src/host/iot_container_task.erl
Normal file
@ -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.
|
||||||
100
apps/iot/src/host/iot_container_task_sup.erl
Normal file
100
apps/iot/src/host/iot_container_task_sup.erl
Normal file
@ -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])).
|
||||||
458
apps/iot/src/host/iot_host.erl
Normal file
458
apps/iot/src/host/iot_host.erl
Normal file
@ -0,0 +1,458 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @author
|
||||||
|
%%% @copyright (C) 2023, <COMPANY>
|
||||||
|
%%% @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.
|
||||||
85
apps/iot/src/host/iot_host_sup.erl
Normal file
85
apps/iot/src/host/iot_host_sup.erl
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @author aresei
|
||||||
|
%%% @copyright (C) 2023, <COMPANY>
|
||||||
|
%%% @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']}.
|
||||||
239
apps/iot/src/http/container_handler.erl
Normal file
239
apps/iot/src/http/container_handler.erl
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @author licheng5
|
||||||
|
%%% @copyright (C) 2020, <COMPANY>
|
||||||
|
%%% @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.
|
||||||
@ -8,7 +8,7 @@
|
|||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-module(endpoint_handler).
|
-module(endpoint_handler).
|
||||||
-author("licheng5").
|
-author("licheng5").
|
||||||
-include("endpoint.hrl").
|
-include_lib("endpoint/include/endpoint.hrl").
|
||||||
|
|
||||||
%% API
|
%% API
|
||||||
-export([handle_request/4]).
|
-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)};
|
{ok, 200, iot_util:json_data(Statuses)};
|
||||||
|
|
||||||
handle_request("POST", "/endpoint/start", _, #{<<"id">> := Id}) when is_integer(Id) ->
|
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 ->
|
undefined ->
|
||||||
{ok, 200, iot_util:json_error(404, <<"endpoint not found">>)};
|
{ok, 200, iot_util:json_error(404, <<"endpoint not found">>)};
|
||||||
{ok, EndpointInfo} ->
|
{ok, EndpointInfo} ->
|
||||||
case endpoint:endpoint_record(EndpointInfo) of
|
case endpoint:endpoint_record(EndpointInfo) of
|
||||||
{ok, Endpoint = #endpoint{title = Title}} ->
|
{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, Pid} when is_pid(Pid) ->
|
||||||
{ok, 200, iot_util:json_data(<<"success">>)};
|
{ok, 200, iot_util:json_data(<<"success">>)};
|
||||||
{error, Reason} ->
|
{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">>)}
|
{ok, 200, iot_util:json_error(404, <<"start endpoint error">>)}
|
||||||
end;
|
end;
|
||||||
error ->
|
error ->
|
||||||
@ -49,21 +49,21 @@ handle_request("POST", "/endpoint/start", _, #{<<"id">> := Id}) when is_integer(
|
|||||||
end;
|
end;
|
||||||
|
|
||||||
handle_request("POST", "/endpoint/stop", _, #{<<"id">> := Id}) when is_integer(Id) ->
|
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 ->
|
undefined ->
|
||||||
{ok, 200, iot_util:json_error(404, <<"endpoint not found">>)};
|
{ok, 200, iot_util:json_error(404, <<"endpoint not found">>)};
|
||||||
{ok, _} ->
|
{ok, _} ->
|
||||||
case endpoint_sup:delete_endpoint(Id) of
|
case endpoint_adapter_sup:delete_endpoint(Id) of
|
||||||
ok ->
|
ok ->
|
||||||
{ok, 200, iot_util:json_data(<<"success">>)};
|
{ok, 200, iot_util:json_data(<<"success">>)};
|
||||||
{error, Reason} ->
|
{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">>)}
|
{ok, 200, iot_util:json_error(404, <<"stop endpoint error">>)}
|
||||||
end
|
end
|
||||||
end;
|
end;
|
||||||
|
|
||||||
handle_request("POST", "/endpoint/restart", _, #{<<"id">> := Id}) when is_integer(Id) ->
|
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 ->
|
undefined ->
|
||||||
{ok, 200, iot_util:json_error(404, <<"endpoint not found">>)};
|
{ok, 200, iot_util:json_error(404, <<"endpoint not found">>)};
|
||||||
{ok, EndpointInfo} ->
|
{ok, EndpointInfo} ->
|
||||||
@ -71,25 +71,25 @@ handle_request("POST", "/endpoint/restart", _, #{<<"id">> := Id}) when is_intege
|
|||||||
{ok, Endpoint = #endpoint{title = Title}} ->
|
{ok, Endpoint = #endpoint{title = Title}} ->
|
||||||
case endpoint:get_pid(Id) of
|
case endpoint:get_pid(Id) of
|
||||||
undefined ->
|
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, Pid} when is_pid(Pid) ->
|
||||||
{ok, 200, iot_util:json_data(<<"success">>)};
|
{ok, 200, iot_util:json_data(<<"success">>)};
|
||||||
{error, Reason} ->
|
{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">>)}
|
{ok, 200, iot_util:json_error(404, <<"restart endpoint error">>)}
|
||||||
end;
|
end;
|
||||||
Pid when is_pid(Pid) ->
|
Pid when is_pid(Pid) ->
|
||||||
case endpoint_sup:delete_endpoint(Id) of
|
case endpoint_adapter_sup:delete_endpoint(Id) of
|
||||||
ok ->
|
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, Pid0} when is_pid(Pid0) ->
|
||||||
{ok, 200, iot_util:json_data(<<"success">>)};
|
{ok, 200, iot_util:json_data(<<"success">>)};
|
||||||
{error, Reason} ->
|
{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">>)}
|
{ok, 200, iot_util:json_error(404, <<"restart endpoint error">>)}
|
||||||
end;
|
end;
|
||||||
{error, Reason} ->
|
{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">>)}
|
{ok, 200, iot_util:json_error(404, <<"stop endpoint error">>)}
|
||||||
end
|
end
|
||||||
end;
|
end;
|
||||||
@ -100,57 +100,23 @@ handle_request("POST", "/endpoint/restart", _, #{<<"id">> := Id}) when is_intege
|
|||||||
|
|
||||||
%% http接口的测试
|
%% http接口的测试
|
||||||
handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"http">>, <<"config">> := #{<<"url">> := Url, <<"pool_size">> := PoolSize}}) when is_integer(PoolSize), PoolSize > 0 ->
|
handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"http">>, <<"config">> := #{<<"url">> := Url, <<"pool_size">> := PoolSize}}) when is_integer(PoolSize), PoolSize > 0 ->
|
||||||
Body = <<"">>,
|
case endpoint_tester:test(#http_endpoint{url = Url, pool_size = PoolSize}) of
|
||||||
ContentType = "application/json",
|
ok ->
|
||||||
case httpc:request(post, {Url, [], ContentType, Body}, [], []) of
|
|
||||||
{ok, _} ->
|
|
||||||
{ok, 200, iot_util:json_data(<<"ok">>)};
|
{ok, 200, iot_util:json_data(<<"ok">>)};
|
||||||
{error, Reason} ->
|
{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">>)}
|
{ok, 200, iot_util:json_error(-1, <<"url failed">>)}
|
||||||
end;
|
end;
|
||||||
|
|
||||||
%% 测试mqtt
|
%% 测试mqtt
|
||||||
handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"mqtt">>, <<"config">> := Config}) ->
|
handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"mqtt">>, <<"config">> := Config}) ->
|
||||||
case endpoint:parse_config(<<"mqtt">>, Config) of
|
case endpoint:parse_config(<<"mqtt">>, Config) of
|
||||||
{ok, #mqtt_endpoint{host = Host, port = Port, username = Username, password = Password}} ->
|
{ok, MqttEndpoint = #mqtt_endpoint{}} ->
|
||||||
%% 用户测试的client
|
case endpoint_tester:test(MqttEndpoint) of
|
||||||
ClientId = "mqtt_client_test:" ++ iot_util:rand_bytes(16),
|
ok ->
|
||||||
Opts = [
|
{ok, 200, iot_util:json_data(<<"ok">>)};
|
||||||
{owner, self()},
|
{error, Reason} ->
|
||||||
{clientid, ClientId},
|
{ok, 200, iot_util:json_error(-1, Reason)}
|
||||||
{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">>)}
|
|
||||||
end;
|
end;
|
||||||
{error, Errors} ->
|
{error, Errors} ->
|
||||||
{ok, 200, iot_util:json_error(-1, Errors)}
|
{ok, 200, iot_util:json_error(-1, Errors)}
|
||||||
@ -159,33 +125,12 @@ handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"mqtt">>, <<"c
|
|||||||
%% 测试kafka
|
%% 测试kafka
|
||||||
handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"kafka">>, <<"config">> := Config}) ->
|
handle_request("POST", "/endpoint/test", _, #{<<"protocol">> := <<"kafka">>, <<"config">> := Config}) ->
|
||||||
case endpoint:parse_config(<<"kafka">>, Config) of
|
case endpoint:parse_config(<<"kafka">>, Config) of
|
||||||
{ok, #kafka_endpoint{sasl_config = SaslConfig, bootstrap_servers = BootstrapServers, topic = Topic}} ->
|
{ok, KafkaEndpoint = #kafka_endpoint{}} ->
|
||||||
BaseConfig = [
|
case endpoint_tester:test(KafkaEndpoint) of
|
||||||
{reconnect_cool_down_seconds, 5},
|
ok ->
|
||||||
{socket_options, [{keepalive, true}]}
|
{ok, 200, iot_util:json_data(<<"ok">>)};
|
||||||
],
|
{error, Reason} ->
|
||||||
|
{ok, 200, iot_util:json_error(-1, Reason)}
|
||||||
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">>)}
|
|
||||||
end;
|
end;
|
||||||
{error, Errors} ->
|
{error, Errors} ->
|
||||||
{ok, 200, iot_util:json_error(-1, 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) ->
|
handle_request("POST", "/endpoint/publish_metric", _, #{<<"route_key">> := RouteKey, <<"metric">> := Metric0}) when is_binary(RouteKey) ->
|
||||||
if
|
if
|
||||||
is_map(Metric0) orelse is_list(Metric0) ->
|
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),
|
endpoint_subscription:publish(RouteKey, Metric),
|
||||||
{ok, 200, iot_util:json_data(<<"ok">>)};
|
{ok, 200, iot_util:json_data(<<"ok">>)};
|
||||||
is_binary(Metric0) ->
|
is_binary(Metric0) ->
|
||||||
73
apps/iot/src/http/event_stream_handler.erl
Normal file
73
apps/iot/src/http/event_stream_handler.erl
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @author anlicheng
|
||||||
|
%%% @copyright (C) 2025, <COMPANY>
|
||||||
|
%%% @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">>}.
|
||||||
@ -18,7 +18,7 @@
|
|||||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||||
|
|
||||||
handle_request("GET", "/host/metric", #{<<"uuid">> := UUID}, _) ->
|
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
|
case iot_host:get_pid(UUID) of
|
||||||
undefined ->
|
undefined ->
|
||||||
{ok, 200, iot_util:json_error(404, <<"host not found">>)};
|
{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) ->
|
handle_request("POST", "/host/activate", _, #{<<"uuid">> := UUID, <<"auth">> := true}) when is_binary(UUID) ->
|
||||||
case iot_host_sup:ensured_host_started(UUID) of
|
case iot_host_sup:ensured_host_started(UUID) of
|
||||||
{error, Reason} ->
|
{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, 200, iot_util:json_error(400, <<"host not found">>)};
|
||||||
{ok, Pid} when is_pid(Pid) ->
|
{ok, Pid} when is_pid(Pid) ->
|
||||||
lager:debug("[host_handler] activate host_id: ~p, start", [UUID]),
|
logger:debug("[host_handler] activate host_id: ~p, start", [UUID]),
|
||||||
ok = iot_host:activate(Pid, true),
|
auth_response(iot_host:activate(Pid, true))
|
||||||
|
|
||||||
{ok, 200, iot_util:json_data(<<"success">>)}
|
|
||||||
end;
|
end;
|
||||||
|
|
||||||
%% 处理主机的授权的 取消激活
|
%% 处理主机的授权的 取消激活
|
||||||
handle_request("POST", "/host/activate", _, #{<<"uuid">> := UUID, <<"auth">> := false}) when is_binary(UUID) ->
|
handle_request("POST", "/host/activate", _, #{<<"uuid">> := UUID, <<"auth">> := false}) when is_binary(UUID) ->
|
||||||
case iot_host_sup:ensured_host_started(UUID) of
|
case iot_host_sup:ensured_host_started(UUID) of
|
||||||
{error, Reason} ->
|
{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, 200, iot_util:json_error(400, <<"host not found">>)};
|
||||||
{ok, Pid} when is_pid(Pid) ->
|
{ok, Pid} when is_pid(Pid) ->
|
||||||
lager:debug("[host_handler] activate host_id: ~p, start", [UUID]),
|
logger:debug("[host_handler] activate host_id: ~p, start", [UUID]),
|
||||||
ok = iot_host:activate(Pid, false),
|
auth_response(iot_host:activate(Pid, false))
|
||||||
|
|
||||||
{ok, 200, iot_util:json_data(<<"success">>)}
|
|
||||||
end;
|
end;
|
||||||
|
|
||||||
%% 主机事件发布
|
%% 主机事件发布
|
||||||
@ -85,11 +81,15 @@ handle_request("POST", "/host/pub", _, #{<<"uuid">> := UUID, <<"topic">> := Topi
|
|||||||
Qos = case Qos0 > 0 of true -> 1; false -> 0 end,
|
Qos = case Qos0 > 0 of true -> 1; false -> 0 end,
|
||||||
case iot_host_sup:ensured_host_started(UUID) of
|
case iot_host_sup:ensured_host_started(UUID) of
|
||||||
{error, Reason} ->
|
{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, 200, iot_util:json_error(400, <<"host not found">>)};
|
||||||
{ok, Pid} when is_pid(Pid) ->
|
{ok, Pid} when is_pid(Pid) ->
|
||||||
ok = iot_host:pub(Pid, Topic, Qos, Content),
|
case iot_host:pub(Pid, Topic, Qos, Content) of
|
||||||
{ok, 200, iot_util:json_data(<<"success">>)}
|
ok ->
|
||||||
|
{ok, 200, iot_util:json_data(<<"success">>)};
|
||||||
|
{error, Reason} ->
|
||||||
|
{ok, 200, iot_util:json_error(400, Reason)}
|
||||||
|
end
|
||||||
end;
|
end;
|
||||||
|
|
||||||
handle_request(_, Path, _, _) ->
|
handle_request(_, Path, _, _) ->
|
||||||
@ -99,3 +99,28 @@ handle_request(_, Path, _, _) ->
|
|||||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||||
%% helper methods
|
%% helper methods
|
||||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||||
|
|
||||||
|
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])).
|
||||||
@ -12,16 +12,28 @@
|
|||||||
%% API
|
%% API
|
||||||
-export([init/2]).
|
-export([init/2]).
|
||||||
|
|
||||||
|
-define(MAX_BODY_BYTES, 10 * 1024 * 1024).
|
||||||
|
|
||||||
init(Req0, Opts = [Mod|_]) ->
|
init(Req0, Opts = [Mod|_]) ->
|
||||||
|
ok = iot_log:set_metadata(),
|
||||||
Method = binary_to_list(cowboy_req:method(Req0)),
|
Method = binary_to_list(cowboy_req:method(Req0)),
|
||||||
Path = binary_to_list(cowboy_req:path(Req0)),
|
Path = binary_to_list(cowboy_req:path(Req0)),
|
||||||
GetParams0 = cowboy_req:parse_qs(Req0),
|
GetParams0 = cowboy_req:parse_qs(Req0),
|
||||||
GetParams = maps:from_list(GetParams0),
|
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
|
try Mod:handle_request(Method, Path, GetParams, PostParams) of
|
||||||
{ok, StatusCode, Resp} ->
|
{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]),
|
[Path, GetParams, PostParams, Resp]),
|
||||||
AcceptEncoding = cowboy_req:header(<<"accept-encoding">>, Req1, <<>>),
|
AcceptEncoding = cowboy_req:header(<<"accept-encoding">>, Req1, <<>>),
|
||||||
Req2 = case iolist_size(Resp) >= 1024 andalso supported_gzip(AcceptEncoding) of
|
Req2 = case iolist_size(Resp) >= 1024 andalso supported_gzip(AcceptEncoding) of
|
||||||
@ -45,7 +57,7 @@ init(Req0, Opts = [Mod|_]) ->
|
|||||||
}, ErrResp, Req1),
|
}, ErrResp, Req1),
|
||||||
{ok, Req2, Opts};
|
{ok, Req2, Opts};
|
||||||
_:Error:Stack ->
|
_: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, #{
|
Req2 = cowboy_req:reply(500, #{
|
||||||
<<"Content-Type">> => <<"text/html;charset=utf-8">>
|
<<"Content-Type">> => <<"text/html;charset=utf-8">>
|
||||||
}, <<"Internal Server Error">>, Req1),
|
}, <<"Internal Server Error">>, Req1),
|
||||||
@ -60,17 +72,18 @@ parse_body(Req0) ->
|
|||||||
ContentType = cowboy_req:header(<<"content-type">>, Req0),
|
ContentType = cowboy_req:header(<<"content-type">>, Req0),
|
||||||
case ContentType of
|
case ContentType of
|
||||||
<<"application/json", _/binary>> ->
|
<<"application/json", _/binary>> ->
|
||||||
{ok, Body, Req1} = read_body(Req0),
|
case read_body(Req0) of
|
||||||
case Body =/= <<"">> of
|
{ok, Body, Req1} ->
|
||||||
true ->
|
decode_json_body(Body, Req1);
|
||||||
{ok, catch jiffy:decode(Body, [return_maps]), Req1};
|
{error, payload_too_large, Req1} ->
|
||||||
false ->
|
{error, 413, iot_util:json_error(413, <<"payload too large">>), Req1}
|
||||||
{ok, #{}, Req1}
|
|
||||||
end;
|
end;
|
||||||
<<"application/x-www-form-urlencoded">> ->
|
<<"application/x-www-form-urlencoded">> ->
|
||||||
{ok, PostParams0, Req1} = cowboy_req:read_urlencoded_body(Req0),
|
case cowboy_req:read_urlencoded_body(Req0) of
|
||||||
PostParams = maps:from_list(PostParams0),
|
{ok, PostParams0, Req1} ->
|
||||||
{ok, PostParams, Req1};
|
PostParams = maps:from_list(PostParams0),
|
||||||
|
{ok, PostParams, Req1}
|
||||||
|
end;
|
||||||
_ ->
|
_ ->
|
||||||
{ok, #{}, Req0}
|
{ok, #{}, Req0}
|
||||||
end.
|
end.
|
||||||
@ -81,7 +94,33 @@ read_body(Req) ->
|
|||||||
read_body(Req, AccData) ->
|
read_body(Req, AccData) ->
|
||||||
case cowboy_req:read_body(Req) of
|
case cowboy_req:read_body(Req) of
|
||||||
{ok, Data, Req1} ->
|
{ok, Data, Req1} ->
|
||||||
{ok, <<AccData/binary, Data/binary>>, Req1};
|
NAccData = <<AccData/binary, Data/binary>>,
|
||||||
|
case byte_size(NAccData) =< ?MAX_BODY_BYTES of
|
||||||
|
true ->
|
||||||
|
{ok, NAccData, Req1};
|
||||||
|
false ->
|
||||||
|
{error, payload_too_large, Req1}
|
||||||
|
end;
|
||||||
{more, Data, Req1} ->
|
{more, Data, Req1} ->
|
||||||
read_body(Req1, <<AccData/binary, Data/binary>>)
|
NAccData = <<AccData/binary, Data/binary>>,
|
||||||
|
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.
|
end.
|
||||||
@ -1,309 +0,0 @@
|
|||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author licheng5
|
|
||||||
%%% @copyright (C) 2020, <COMPANY>
|
|
||||||
%%% @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.
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author anlicheng
|
|
||||||
%%% @copyright (C) 2025, <COMPANY>
|
|
||||||
%%% @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.
|
|
||||||
@ -6,17 +6,14 @@
|
|||||||
{applications,
|
{applications,
|
||||||
[
|
[
|
||||||
sync,
|
sync,
|
||||||
|
endpoint,
|
||||||
emqtt,
|
emqtt,
|
||||||
eredis,
|
eredis,
|
||||||
ranch,
|
ranch,
|
||||||
cowboy,
|
cowboy,
|
||||||
lager,
|
|
||||||
jiffy,
|
|
||||||
brod,
|
brod,
|
||||||
parse_trans,
|
|
||||||
hackney,
|
hackney,
|
||||||
poolboy,
|
poolboy,
|
||||||
mysql,
|
|
||||||
gproc,
|
gproc,
|
||||||
% gpb,
|
% gpb,
|
||||||
mnesia,
|
mnesia,
|
||||||
|
|||||||
@ -9,34 +9,41 @@
|
|||||||
-export([start/2, stop/1]).
|
-export([start/2, stop/1]).
|
||||||
|
|
||||||
start(_StartType, _StartArgs) ->
|
start(_StartType, _StartArgs) ->
|
||||||
|
ok = iot_log:set_metadata(),
|
||||||
io:setopts([{encoding, unicode}]),
|
io:setopts([{encoding, unicode}]),
|
||||||
%% 加速内存的回收
|
%% 加速内存的回收
|
||||||
erlang:system_flag(fullsweep_after, 16),
|
erlang:system_flag(fullsweep_after, 16),
|
||||||
%% 启动mnesia数据库
|
|
||||||
start_mnesia(),
|
|
||||||
|
|
||||||
%% 启动http服务
|
iot_mnesia:safe_start(),
|
||||||
|
|
||||||
|
%% 启动http服务。当前 supervisor 初始化依赖本机 simulator API,因此保留此顺序。
|
||||||
start_http_server(),
|
start_http_server(),
|
||||||
|
|
||||||
%% 启动tcp服务
|
%% 启动endpoints的相关依赖
|
||||||
start_tcp_server(),
|
start_endpoints(),
|
||||||
|
|
||||||
|
%% 启动ssl服务
|
||||||
|
start_ssl_server(),
|
||||||
|
|
||||||
iot_sup:start_link().
|
iot_sup:start_link().
|
||||||
|
|
||||||
stop(_State) ->
|
stop(_State) ->
|
||||||
|
stop_started_services(),
|
||||||
ok.
|
ok.
|
||||||
|
|
||||||
%% internal functions
|
%% internal functions
|
||||||
|
|
||||||
%% 启动内存数据库
|
start_endpoints() ->
|
||||||
start_mnesia() ->
|
EndpointInfos = iot_api_client:get_all_endpoints(),
|
||||||
ok = ensure_mnesia_schema(),
|
Endpoints = lists:filtermap(fun(EndpointInfo) ->
|
||||||
%% 启动数据库
|
case endpoint:endpoint_record(EndpointInfo) of
|
||||||
ok = mnesia:start(),
|
error ->
|
||||||
Tables = mnesia:system_info(tables),
|
false;
|
||||||
lager:debug("[iot_app] tables: ~p", [Tables]),
|
{ok, Endpoint} ->
|
||||||
%% 创建数据库表
|
{true, Endpoint}
|
||||||
ok.
|
end
|
||||||
|
end, EndpointInfos),
|
||||||
|
ok = endpoint_adapter_sup:start_endpoints(Endpoints).
|
||||||
|
|
||||||
start_http_server() ->
|
start_http_server() ->
|
||||||
{ok, Props} = application:get_env(iot, http_server),
|
{ok, Props} = application:get_env(iot, http_server),
|
||||||
@ -50,6 +57,7 @@ start_http_server() ->
|
|||||||
{"/host/[...]", http_protocol, [host_handler]},
|
{"/host/[...]", http_protocol, [host_handler]},
|
||||||
{"/container/[...]", http_protocol, [container_handler]},
|
{"/container/[...]", http_protocol, [container_handler]},
|
||||||
{"/endpoint/[...]", http_protocol, [endpoint_handler]},
|
{"/endpoint/[...]", http_protocol, [endpoint_handler]},
|
||||||
|
{"/simulator/[...]", http_protocol, [simulator_api_handler]},
|
||||||
{"/event_stream", event_stream_handler, []}
|
{"/event_stream", event_stream_handler, []}
|
||||||
]}
|
]}
|
||||||
]),
|
]),
|
||||||
@ -65,41 +73,36 @@ start_http_server() ->
|
|||||||
},
|
},
|
||||||
{ok, Pid} = cowboy:start_clear(http_listener, TransOpts, #{env => #{dispatch => Dispatcher}}),
|
{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服务
|
%% 启动ssl服务
|
||||||
start_tcp_server() ->
|
start_ssl_server() ->
|
||||||
{ok, Props} = application:get_env(iot, tcp_server),
|
{ok, Props} = application:get_env(iot, ssl_server),
|
||||||
Acceptors = proplists:get_value(acceptors, Props, 50),
|
Acceptors = proplists:get_value(acceptors, Props, 50),
|
||||||
MaxConnections = proplists:get_value(max_connections, Props, 10240),
|
MaxConnections = proplists:get_value(max_connections, Props, 10240),
|
||||||
Backlog = proplists:get_value(backlog, Props, 1024),
|
Backlog = proplists:get_value(backlog, Props, 1024),
|
||||||
Port = proplists:get_value(port, Props),
|
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 = #{
|
TransOpts = #{
|
||||||
max_connections => MaxConnections,
|
max_connections => MaxConnections,
|
||||||
num_acceptors => Acceptors,
|
num_acceptors => Acceptors,
|
||||||
shutdown => brutal_kill,
|
shutdown => brutal_kill,
|
||||||
socket_opts => [
|
socket_opts => [
|
||||||
{nodelay, false},
|
{nodelay, true},
|
||||||
{backlog, Backlog},
|
{backlog, Backlog},
|
||||||
{port, Port}
|
{port, Port},
|
||||||
|
{certfile, CertFile},
|
||||||
|
{keyfile, KeyFile}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{ok, _} = ranch:start_listener(tcp_server, ranch_tcp, TransOpts, tcp_channel, []),
|
{ok, _} = ranch:start_listener(ssl_server, ranch_ssl, TransOpts, ssl_channel, []),
|
||||||
lager:debug("[iot_app] the tcp server start at: ~p", [Port]).
|
logger:debug("[iot_app] the ssl server start at: ~p", [Port]).
|
||||||
|
|
||||||
-spec ensure_mnesia_schema() -> any().
|
stop_started_services() ->
|
||||||
ensure_mnesia_schema() ->
|
_ = cowboy:stop_listener(http_listener),
|
||||||
case mnesia:system_info(use_dir) of
|
_ = ranch:stop_listener(ssl_server),
|
||||||
true ->
|
_ = iot_mnesia:stop(),
|
||||||
ok;
|
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.
|
|
||||||
|
|||||||
@ -1,33 +0,0 @@
|
|||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author aresei
|
|
||||||
%%% @copyright (C) 2023, <COMPANY>
|
|
||||||
%%% @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(<<Salt/binary, "!", PreToken/binary, "!", UUID/binary, "!", BinTimestamp/binary>>) =:= Token
|
|
||||||
end;
|
|
||||||
false ->
|
|
||||||
false
|
|
||||||
end.
|
|
||||||
@ -1,390 +0,0 @@
|
|||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author
|
|
||||||
%%% @copyright (C) 2023, <COMPANY>
|
|
||||||
%%% @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
|
|
||||||
}.
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author aresei
|
|
||||||
%%% @copyright (C) 2023, <COMPANY>
|
|
||||||
%%% @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']}.
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author licheng5
|
|
||||||
%%% @copyright (C) 2023, <COMPANY>
|
|
||||||
%%% @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.
|
|
||||||
10
apps/iot/src/iot_log.erl
Normal file
10
apps/iot/src/iot_log.erl
Normal file
@ -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]}).
|
||||||
@ -29,21 +29,21 @@ init([]) ->
|
|||||||
|
|
||||||
Specs = [
|
Specs = [
|
||||||
#{
|
#{
|
||||||
id => 'iot_event_stream_observer',
|
id => 'iot_container_task_sup',
|
||||||
start => {'iot_event_stream_observer', start_link, []},
|
start => {'iot_container_task_sup', start_link, []},
|
||||||
restart => permanent,
|
|
||||||
shutdown => 2000,
|
|
||||||
type => worker,
|
|
||||||
modules => ['iot_event_stream_observer']
|
|
||||||
},
|
|
||||||
|
|
||||||
#{
|
|
||||||
id => endpoint_sup_sup,
|
|
||||||
start => {'endpoint_sup_sup', start_link, []},
|
|
||||||
restart => permanent,
|
restart => permanent,
|
||||||
shutdown => 2000,
|
shutdown => 2000,
|
||||||
type => supervisor,
|
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']
|
||||||
},
|
},
|
||||||
|
|
||||||
#{
|
#{
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author aresei
|
|
||||||
%%% @copyright (C) 2023, <COMPANY>
|
|
||||||
%%% @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, <<Len:16, HostUUID:Len/binary>>} ->
|
|
||||||
Pid = iot_host:get_pid(HostUUID),
|
|
||||||
iot_host:heartbeat(Pid),
|
|
||||||
loop(Transport, Peer);
|
|
||||||
{datagram, Server, _} ->
|
|
||||||
exit(normal)
|
|
||||||
end.
|
|
||||||
@ -10,15 +10,15 @@
|
|||||||
-author("licheng5").
|
-author("licheng5").
|
||||||
|
|
||||||
%% API
|
%% 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([timestamp/0, number_format/2, current_time/0, timestamp_of_seconds/0, float_to_binary/2, int_format/2]).
|
||||||
-export([step/3, chunks/2, rand_bytes/1, uuid/0, md5/1, parse_mapper/1]).
|
-export([step/3, rand_bytes/1, uuid/0, md5/1, parse_mapper/1]).
|
||||||
-export([json_data/1, json_error/2, is_json/1]).
|
-export([json_data/1, json_error/2, is_json/1]).
|
||||||
-export([queue_limited_in/3, assert_call/2, assert/2]).
|
-export([queue_limited_in/3, assert_call/2, assert/2]).
|
||||||
-export([sha256/1]).
|
-export([sha256/1]).
|
||||||
|
|
||||||
-spec is_json(Json :: term()) -> boolean().
|
-spec is_json(Json :: term()) -> boolean().
|
||||||
is_json(Json) when is_binary(Json) ->
|
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) ->
|
Result when is_list(Result) orelse is_map(Result) ->
|
||||||
true;
|
true;
|
||||||
_ ->
|
_ ->
|
||||||
@ -61,33 +61,18 @@ step(Start, End, Step, Acc) when Start < End ->
|
|||||||
step(_, _, _, Acc) ->
|
step(_, _, _, Acc) ->
|
||||||
lists:reverse(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) ->
|
json_data(Data) ->
|
||||||
jiffy:encode(#{
|
iolist_to_binary(json:encode(#{
|
||||||
<<"result">> => Data
|
<<"result">> => Data
|
||||||
}, [force_utf8]).
|
})).
|
||||||
|
|
||||||
json_error(ErrCode, ErrMessage) when is_integer(ErrCode) ->
|
json_error(ErrCode, ErrMessage) when is_integer(ErrCode) ->
|
||||||
jiffy:encode(#{
|
iolist_to_binary(json:encode(#{
|
||||||
<<"error">> => #{
|
<<"error">> => #{
|
||||||
<<"code">> => ErrCode,
|
<<"code">> => ErrCode,
|
||||||
<<"message">> => ErrMessage
|
<<"message">> => ErrMessage
|
||||||
}
|
}
|
||||||
}, [force_utf8]).
|
})).
|
||||||
|
|
||||||
uuid() ->
|
uuid() ->
|
||||||
rand_bytes(16).
|
rand_bytes(16).
|
||||||
@ -132,18 +117,9 @@ hex(N) ->
|
|||||||
|
|
||||||
%% 转换映射器
|
%% 转换映射器
|
||||||
-spec parse_mapper(Mapper :: binary() | string()) -> error | {ok, F :: fun((binary(), any()) -> any())}.
|
-spec parse_mapper(Mapper :: binary() | string()) -> error | {ok, F :: fun((binary(), any()) -> any())}.
|
||||||
parse_mapper(Mapper) when is_binary(Mapper) ->
|
parse_mapper(_Mapper) ->
|
||||||
parse_mapper(binary_to_list(Mapper));
|
%% Dynamic Erlang eval is intentionally disabled in runtime code.
|
||||||
parse_mapper(Mapper) when is_list(Mapper) ->
|
error.
|
||||||
{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.
|
|
||||||
|
|
||||||
-spec float_to_binary(Num :: number(), integer()) -> binary().
|
-spec float_to_binary(Num :: number(), integer()) -> binary().
|
||||||
float_to_binary(V, _) when is_integer(V) ->
|
float_to_binary(V, _) when is_integer(V) ->
|
||||||
@ -158,13 +134,3 @@ assert(false, F) when is_function(F) ->
|
|||||||
F();
|
F();
|
||||||
assert(false, Msg) ->
|
assert(false, Msg) ->
|
||||||
throw(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.
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author anlicheng
|
|
||||||
%%% @copyright (C) 2025, <COMPANY>
|
|
||||||
%%% @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),
|
|
||||||
<<MessageType, Bin/binary>>.
|
|
||||||
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(<<PacketType:8, Packet/binary>>) ->
|
|
||||||
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) ->
|
|
||||||
<<?I8, Field:8>>;
|
|
||||||
marshal(?I16, Field) when is_integer(Field) ->
|
|
||||||
<<?I16, Field:16>>;
|
|
||||||
marshal(?I32, Field) when is_integer(Field) ->
|
|
||||||
<<?I32, Field:32>>;
|
|
||||||
marshal(?Bytes, Field) when is_binary(Field) ->
|
|
||||||
Len = byte_size(Field),
|
|
||||||
<<?Bytes, Len:16, Field/binary>>.
|
|
||||||
|
|
||||||
-spec unmarshal(Bin :: binary()) -> {ok, Components :: [any()]} | error.
|
|
||||||
unmarshal(Bin) when is_binary(Bin) ->
|
|
||||||
unmarshal(Bin, []).
|
|
||||||
unmarshal(<<>>, Acc) ->
|
|
||||||
{ok, lists:reverse(Acc)};
|
|
||||||
unmarshal(<<?I8, F:8, Rest/binary>>, Acc) ->
|
|
||||||
unmarshal(Rest, [F|Acc]);
|
|
||||||
unmarshal(<<?I16, F:16, Rest/binary>>, Acc) ->
|
|
||||||
unmarshal(Rest, [F|Acc]);
|
|
||||||
unmarshal(<<?I32, F:32, Rest/binary>>, Acc) ->
|
|
||||||
unmarshal(Rest, [F|Acc]);
|
|
||||||
unmarshal(<<?Bytes, Len:16, F:Len/binary, Rest/binary>>, Acc) ->
|
|
||||||
unmarshal(Rest, [F|Acc]);
|
|
||||||
unmarshal(_, _) ->
|
|
||||||
error.
|
|
||||||
154
apps/iot/src/mnesia/efka_client_store.erl
Normal file
154
apps/iot/src/mnesia/efka_client_store.erl
Normal file
@ -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.
|
||||||
74
apps/iot/src/mnesia/iot_mnesia.erl
Normal file
74
apps/iot/src/mnesia/iot_mnesia.erl
Normal file
@ -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.
|
||||||
@ -1,48 +0,0 @@
|
|||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author aresei
|
|
||||||
%%% @copyright (C) 2018, <COMPANY>
|
|
||||||
%%% @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).
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author aresei
|
|
||||||
%%% @copyright (C) 2018, <COMPANY>
|
|
||||||
%%% @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)}.
|
|
||||||
156
apps/iot/src/simulator/simulator_api_handler.erl
Normal file
156
apps/iot/src/simulator/simulator_api_handler.erl
Normal file
@ -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.
|
||||||
@ -1,195 +0,0 @@
|
|||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author licheng5
|
|
||||||
%%% @copyright (C) 2021, <COMPANY>
|
|
||||||
%%% @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, <<?PACKET_CAST, EncPub/binary>>),
|
|
||||||
{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, <<?PACKET_CAST, EncCommand/binary>>),
|
|
||||||
{noreply, State};
|
|
||||||
|
|
||||||
%% 推送消息
|
|
||||||
handle_cast({jsonrpc_call, ReceiverPid, Ref, CallBin}, State = #state{transport = Transport, socket = Socket, packet_id = PacketId, inflight = Inflight}) ->
|
|
||||||
Transport:send(Socket, <<?PACKET_REQUEST, PacketId:32, CallBin/binary>>),
|
|
||||||
{noreply, State#state{packet_id = PacketId + 1, inflight = maps:put(PacketId, {ReceiverPid, Ref}, Inflight)}}.
|
|
||||||
|
|
||||||
%% auth验证
|
|
||||||
handle_info({tcp, Socket, <<?PACKET_REQUEST, PacketId:32, RequestBin/binary>>}, 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, <<?PACKET_RESPONSE, PacketId:32, AuthReplyBin/binary>>),
|
|
||||||
|
|
||||||
{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, <<?PACKET_RESPONSE, PacketId:32, AuthReplyBin/binary>>),
|
|
||||||
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, <<?PACKET_RESPONSE, PacketId:32, AuthReplyBin/binary>>),
|
|
||||||
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, <<?PACKET_CAST, CastBin/binary>>}, 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, <<?PACKET_PING, PingData/binary>>}, 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, <<?PACKET_RESPONSE, PacketId:32, ResponseBin/binary>>}, 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}.
|
|
||||||
369
apps/iot/src/transport/ssl_channel.erl
Normal file
369
apps/iot/src/transport/ssl_channel.erl
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @author licheng5
|
||||||
|
%%% @copyright (C) 2021, <COMPANY>
|
||||||
|
%%% @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.
|
||||||
@ -1,19 +1,22 @@
|
|||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
%%% @author anlicheng
|
%%% @author anlicheng
|
||||||
%%% @copyright (C) 2025, <COMPANY>
|
%%% @copyright (C) 2026, <COMPANY>
|
||||||
%%% @doc
|
%%% @doc
|
||||||
%%%
|
%%%
|
||||||
%%% @end
|
%%% @end
|
||||||
%%% Created : 26. 9月 2025 12:19
|
%%% Created : 09. 5月 2026 18:02
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-module(iot_event_stream_observer).
|
-module(udp_server).
|
||||||
-author("anlicheng").
|
-author("anlicheng").
|
||||||
|
|
||||||
-behaviour(gen_server).
|
-behaviour(gen_server).
|
||||||
|
|
||||||
|
-define(HEARTBEAT_VERSION, 1).
|
||||||
|
-define(HEARTBEAT_NONCE_BYTES, 16).
|
||||||
|
-define(HEARTBEAT_MAC_BYTES, 32).
|
||||||
|
|
||||||
%% API
|
%% API
|
||||||
-export([start_link/0]).
|
-export([start_link/0]).
|
||||||
-export([add_listener/2, stream_data/3, stream_close/2]).
|
|
||||||
|
|
||||||
%% gen_server callbacks
|
%% gen_server callbacks
|
||||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
|
||||||
@ -21,25 +24,13 @@
|
|||||||
-define(SERVER, ?MODULE).
|
-define(SERVER, ?MODULE).
|
||||||
|
|
||||||
-record(state, {
|
-record(state, {
|
||||||
listeners = #{}
|
socket :: gen_udp:socket()
|
||||||
}).
|
}).
|
||||||
|
|
||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
%%% API
|
%%% 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)
|
%% @doc Spawns the server and registers the local name (unique)
|
||||||
-spec(start_link() ->
|
-spec(start_link() ->
|
||||||
{ok, Pid :: pid()} | ignore | {error, Reason :: term()}).
|
{ok, Pid :: pid()} | ignore | {error, Reason :: term()}).
|
||||||
@ -56,7 +47,12 @@ start_link() ->
|
|||||||
{ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} |
|
{ok, State :: #state{}} | {ok, State :: #state{}, timeout() | hibernate} |
|
||||||
{stop, Reason :: term()} | ignore).
|
{stop, Reason :: term()} | ignore).
|
||||||
init([]) ->
|
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
|
%% @private
|
||||||
%% @doc Handling call messages
|
%% @doc Handling call messages
|
||||||
@ -68,9 +64,8 @@ init([]) ->
|
|||||||
{noreply, NewState :: #state{}, timeout() | hibernate} |
|
{noreply, NewState :: #state{}, timeout() | hibernate} |
|
||||||
{stop, Reason :: term(), Reply :: term(), NewState :: #state{}} |
|
{stop, Reason :: term(), Reply :: term(), NewState :: #state{}} |
|
||||||
{stop, Reason :: term(), NewState :: #state{}}).
|
{stop, Reason :: term(), NewState :: #state{}}).
|
||||||
handle_call({add_listener, ListenerPid, TaskId}, _From, State = #state{listeners = Listeners}) ->
|
handle_call(_Request, _From, State = #state{}) ->
|
||||||
erlang:monitor(process, ListenerPid),
|
{reply, ok, State}.
|
||||||
{reply, ok, State#state{listeners = maps:put(TaskId, ListenerPid, Listeners)}}.
|
|
||||||
|
|
||||||
%% @private
|
%% @private
|
||||||
%% @doc Handling cast messages
|
%% @doc Handling cast messages
|
||||||
@ -78,21 +73,7 @@ handle_call({add_listener, ListenerPid, TaskId}, _From, State = #state{listeners
|
|||||||
{noreply, NewState :: #state{}} |
|
{noreply, NewState :: #state{}} |
|
||||||
{noreply, NewState :: #state{}, timeout() | hibernate} |
|
{noreply, NewState :: #state{}, timeout() | hibernate} |
|
||||||
{stop, Reason :: term(), NewState :: #state{}}).
|
{stop, Reason :: term(), NewState :: #state{}}).
|
||||||
handle_cast({stream_data, TaskId, Type, Stream}, State = #state{listeners = Listeners}) ->
|
handle_cast(_Request, State = #state{}) ->
|
||||||
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,
|
|
||||||
{noreply, State}.
|
{noreply, State}.
|
||||||
|
|
||||||
%% @private
|
%% @private
|
||||||
@ -101,9 +82,12 @@ handle_cast({stream_close, TaskId, Reason}, State = #state{listeners = Listeners
|
|||||||
{noreply, NewState :: #state{}} |
|
{noreply, NewState :: #state{}} |
|
||||||
{noreply, NewState :: #state{}, timeout() | hibernate} |
|
{noreply, NewState :: #state{}, timeout() | hibernate} |
|
||||||
{stop, Reason :: term(), NewState :: #state{}}).
|
{stop, Reason :: term(), NewState :: #state{}}).
|
||||||
handle_info({'DOWN', _Ref, process, Pid, _Reason}, State = #state{listeners = Listeners}) ->
|
handle_info({udp, Socket, Ip, Port, Packet}, State = #state{socket = Socket}) ->
|
||||||
NListeners = maps:filter(fun(_, ListenerPid) -> ListenerPid /= Pid end, Listeners),
|
handle_heartbeat_packet(Packet, {Ip, Port}),
|
||||||
{noreply, State#state{listeners = NListeners}}.
|
{noreply, State};
|
||||||
|
handle_info(Info, State = #state{}) ->
|
||||||
|
logger:warning("[udp_server] ignore unknown info: ~p", [Info]),
|
||||||
|
{noreply, State}.
|
||||||
|
|
||||||
%% @private
|
%% @private
|
||||||
%% @doc This function is called by a gen_server when it is about to
|
%% @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.
|
%% with Reason. The return value is ignored.
|
||||||
-spec(terminate(Reason :: (normal | shutdown | {shutdown, term()} | term()),
|
-spec(terminate(Reason :: (normal | shutdown | {shutdown, term()} | term()),
|
||||||
State :: #state{}) -> term()).
|
State :: #state{}) -> term()).
|
||||||
terminate(_Reason, _State = #state{}) ->
|
terminate(_Reason, _State = #state{socket = Socket}) ->
|
||||||
|
gen_udp:close(Socket),
|
||||||
ok.
|
ok.
|
||||||
|
|
||||||
%% @private
|
%% @private
|
||||||
@ -126,3 +111,31 @@ code_change(_OldVsn, State = #state{}, _Extra) ->
|
|||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
%%% Internal functions
|
%%% 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 = <<?HEARTBEAT_VERSION:8, UUIDLen:16, UUID:UUIDLen/binary,
|
||||||
|
Timestamp:64/unsigned-big, _Nonce:?HEARTBEAT_NONCE_BYTES/binary, _Mac:?HEARTBEAT_MAC_BYTES/binary>>)
|
||||||
|
when UUIDLen > 0 ->
|
||||||
|
PayloadSize = byte_size(Packet) - ?HEARTBEAT_MAC_BYTES,
|
||||||
|
<<Payload:PayloadSize/binary, Mac:?HEARTBEAT_MAC_BYTES/binary>> = Packet,
|
||||||
|
{ok, UUID, Timestamp, Payload, Mac};
|
||||||
|
decode_heartbeat_packet(_Packet) ->
|
||||||
|
error.
|
||||||
@ -1,34 +0,0 @@
|
|||||||
%%%-------------------------------------------------------------------
|
|
||||||
%%% @author anlicheng
|
|
||||||
%%% @copyright (C) 2025, <COMPANY>
|
|
||||||
%%% @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.
|
|
||||||
@ -2,114 +2,75 @@
|
|||||||
{iot, [
|
{iot, [
|
||||||
{http_server, [
|
{http_server, [
|
||||||
{port, 18090},
|
{port, 18090},
|
||||||
{acceptors, 500},
|
{acceptors, 5},
|
||||||
{max_connections, 10240},
|
{max_connections, 1024},
|
||||||
{backlog, 10240}
|
{backlog, 1024}
|
||||||
]},
|
]},
|
||||||
|
|
||||||
{tcp_server, [
|
{ssl_server, [
|
||||||
{port, 18092},
|
{port, 1443},
|
||||||
{acceptors, 500},
|
{acceptors, 5},
|
||||||
{max_connections, 10240},
|
{max_connections, 1024},
|
||||||
{backlog, 10240}
|
{backlog, 1024}
|
||||||
]},
|
|
||||||
|
|
||||||
{redis_server, [
|
|
||||||
{port, 16379},
|
|
||||||
{acceptors, 500},
|
|
||||||
{max_connections, 10240},
|
|
||||||
{backlog, 10240}
|
|
||||||
]},
|
]},
|
||||||
|
|
||||||
{udp_server, [
|
{udp_server, [
|
||||||
{port, 18080}
|
{port, 18080}
|
||||||
]},
|
]},
|
||||||
|
|
||||||
{api_url, "http://100.123.0.4/api/v1"},
|
{api_url, "http://127.0.0.1:18090/simulator"}
|
||||||
|
]},
|
||||||
|
|
||||||
|
{endpoint, [
|
||||||
%% 支持的协议
|
%% 支持的协议
|
||||||
{endpoints, [
|
{endpoints, [
|
||||||
|
{root_dir, "/usr/local/code/database/"},
|
||||||
{support_protocols, [
|
{support_protocols, [
|
||||||
http
|
http
|
||||||
]}
|
]}
|
||||||
]},
|
]},
|
||||||
|
|
||||||
%% 目标服务器地址
|
{endpoint_log, [
|
||||||
{emqx_server, [
|
{path, "${endpoint_root}/endpoint_log/unmatched_publish.log"},
|
||||||
{host, {39, 98, 184, 67}},
|
{max_bytes, 10485760},
|
||||||
{port, 1883},
|
{max_files, 10}
|
||||||
{tcp_opts, []},
|
|
||||||
{username, "test"},
|
|
||||||
{password, "test1234"},
|
|
||||||
{keepalive, 86400},
|
|
||||||
{retry_interval, 5}
|
|
||||||
]},
|
|
||||||
|
|
||||||
%% 权限检验时的预埋token
|
|
||||||
{pre_tokens, [
|
|
||||||
{<<"test">>, <<"iot2023">>}
|
|
||||||
]}
|
]}
|
||||||
|
|
||||||
%{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, 支持日志按日期自动分割
|
{handler, disk, logger_disk_log_h,
|
||||||
{lager, [
|
#{
|
||||||
{colored, true},
|
level => debug,
|
||||||
%% Whether to write a crash log, and where. Undefined means no crash logger.
|
filter_default => stop,
|
||||||
{crash_log, "trade_hub.crash.log"},
|
filters => [
|
||||||
%% Maximum size in bytes of events in the crash log - defaults to 65536
|
{iot_domain, {fun logger_filters:domain/2, {log, sub, [iot]}}},
|
||||||
{crash_log_msg_size, 65536},
|
{endpoint_domain, {fun logger_filters:domain/2, {log, sub, [endpoint]}}}
|
||||||
%% Maximum size of the crash log in bytes, before its rotated, set
|
],
|
||||||
%% to 0 to disable rotation - default is 0
|
config => #{
|
||||||
{crash_log_size, 10485760},
|
file => "log/debug.log",
|
||||||
%% What time to rotate the crash log - default is no time
|
max_no_files => 10,
|
||||||
%% rotation. See the README for a description of this format.
|
max_no_bytes => 524288000
|
||||||
{crash_log_date, "$D0"},
|
},
|
||||||
%% Number of rotated crash logs to keep, 0 means keep only the
|
formatter => {logger_formatter, #{template => [time, " [", level, "] ", msg, "\n"]}}
|
||||||
%% current one - default is 0
|
}
|
||||||
{crash_log_count, 5},
|
}
|
||||||
%% Whether to redirect error_logger messages into lager - defaults to true
|
|
||||||
{error_logger_redirect, true},
|
|
||||||
|
|
||||||
%% 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}]}
|
|
||||||
]}
|
]}
|
||||||
]}
|
]}
|
||||||
|
|
||||||
|
|||||||
@ -2,83 +2,74 @@
|
|||||||
{iot, [
|
{iot, [
|
||||||
{http_server, [
|
{http_server, [
|
||||||
{port, 18080},
|
{port, 18080},
|
||||||
{acceptors, 500},
|
{acceptors, 5},
|
||||||
{max_connections, 10240},
|
{max_connections, 1024},
|
||||||
{backlog, 10240}
|
{backlog, 1024}
|
||||||
]},
|
]},
|
||||||
|
|
||||||
{redis_server, [
|
{ssl_server, [
|
||||||
{port, 16379},
|
{port, 1443},
|
||||||
{acceptors, 500},
|
{acceptors, 5},
|
||||||
{max_connections, 10240},
|
{max_connections, 1024},
|
||||||
{backlog, 10240}
|
{backlog, 1024}
|
||||||
]},
|
]},
|
||||||
|
|
||||||
{udp_server, [
|
{udp_server, [
|
||||||
{port, 18080}
|
{port, 18080}
|
||||||
]},
|
]},
|
||||||
|
|
||||||
%% 权限检验时的预埋token
|
{api_url, "https://lgsiot.njau.edu.cn/api/v1/taskLog"}
|
||||||
{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}
|
|
||||||
% ]
|
|
||||||
% }
|
|
||||||
|
|
||||||
%]}
|
|
||||||
|
|
||||||
]},
|
]},
|
||||||
|
|
||||||
|
{endpoint, [
|
||||||
|
{endpoints, [
|
||||||
|
{root_dir, "/var/lib/endpoint/database/"},
|
||||||
|
{support_protocols, [
|
||||||
|
http
|
||||||
|
]}
|
||||||
|
]},
|
||||||
|
|
||||||
%% 系统日志配置,系统日志为lager, 支持日志按日期自动分割
|
{endpoint_log, [
|
||||||
{lager, [
|
{path, "/var/lib/endpoint/endpoint_log/unmatched_publish.log"},
|
||||||
{colored, true},
|
{max_bytes, 10485760},
|
||||||
%% Whether to write a crash log, and where. Undefined means no crash logger.
|
{max_files, 10}
|
||||||
{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},
|
|
||||||
|
|
||||||
%% How big the gen_event mailbox can get before it is switched into sync mode
|
%% 系统日志配置,使用 OTP logger
|
||||||
{async_threshold, 20},
|
{kernel, [
|
||||||
%% Switch back to async mode, when gen_event mailbox size decrease from `async_threshold'
|
%% 设置 Logger 的 primary log level
|
||||||
%% to async_threshold - async_threshold_window
|
{logger_level, debug},
|
||||||
{async_threshold_window, 5},
|
{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}]}
|
|
||||||
]}
|
]}
|
||||||
]}
|
]}
|
||||||
|
|
||||||
|
|||||||
77
config/sys-test.config
Normal file
77
config/sys-test.config
Normal file
@ -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"]}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
]}
|
||||||
|
]}
|
||||||
|
|
||||||
|
].
|
||||||
@ -5,7 +5,7 @@
|
|||||||
+K true
|
+K true
|
||||||
+A30
|
+A30
|
||||||
|
|
||||||
-mnesia dir '"/usr/local/var/mnesia/iot"'
|
-mnesia dir '"/var/lib/iot/mnesia/"'
|
||||||
-mnesia dump_log_write_threshold 50000
|
-mnesia dump_log_write_threshold 50000
|
||||||
-mnesia dc_dump_limit 40
|
-mnesia dc_dump_limit 40
|
||||||
|
|
||||||
|
|||||||
670
docs/container_deploy_request.md
Normal file
670
docs/container_deploy_request.md
Normal file
@ -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=<host_uuid>&task_id=<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 超时。 |
|
||||||
268
docs/efka_iot_protocol.md
Normal file
268
docs/efka_iot_protocol.md
Normal file
@ -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=<host_uuid>&task_id=<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()` 生成的值。
|
||||||
|
|
||||||
|
如果需要滚动升级,应先增加临时兼容分支或引入协议版本协商。
|
||||||
@ -1,11 +1,44 @@
|
|||||||
## 心跳机制
|
## 心跳机制
|
||||||
* 边缘主机通过心跳机制来判断主机是否存活(解决弱网环境下websocket链接会经常断开的问题)
|
* 边缘主机通过心跳机制来判断主机是否存活(解决弱网环境下websocket链接会经常断开的问题)
|
||||||
* 边缘主机每隔5秒发送一次心跳包,服务端每隔2分钟检测一下,判断是否有收到心跳包;如果没有收到则认为主机离线
|
* 边缘主机定期发送 UDP 心跳包,服务端按照 `iot_host` 的心跳检测周期判断是否有收到心跳包。
|
||||||
|
* 如果 UDP 心跳丢失但 SSL channel 仍然存在,服务端不会把 host 标记为离线。
|
||||||
|
* 如果 UDP 心跳丢失且 SSL channel 也不存在,服务端会把 host 标记为离线。
|
||||||
|
|
||||||
### udp服务器
|
### udp服务器
|
||||||
* 端口: 18080
|
* 端口: 18080
|
||||||
|
|
||||||
### 心跳包格式
|
### 心跳包格式
|
||||||
* <<Len:2, HostUUID/binary>>
|
旧格式 `<<Len:16, HostUUID/binary>>` 已不再接受。
|
||||||
* 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 = <<Version:8, UuidLen:16, UUID:UuidLen/binary, Timestamp:64/unsigned-big, Nonce:16/binary>>,
|
||||||
|
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。
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
```markdown
|
```markdown
|
||||||
# 📘 IoT API 接口文档
|
# 📘 IoT API 接口文档
|
||||||
|
|
||||||
> 模块:`iot_api`
|
> 模块:`iot_api_client`
|
||||||
> 作者:**anlicheng**
|
> 作者:**anlicheng**
|
||||||
> 创建时间:2023-12-24
|
> 创建时间:2023-12-24
|
||||||
> 数据格式:`application/json`
|
> 数据格式:`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=<id>
|
|||||||
|
|
||||||
## 🔧 设备(Device)相关接口
|
## 🔧 设备(Device)相关接口
|
||||||
|
|
||||||
### 6. 获取设备详情
|
### 7. 获取设备详情
|
||||||
|
|
||||||
**接口:**
|
**接口:**
|
||||||
|
|
||||||
@ -190,7 +219,7 @@ GET /get_device_by_uuid?device_uuid=<uuid>
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 7. 修改设备状态
|
### 8. 修改设备状态
|
||||||
|
|
||||||
**接口:**
|
**接口:**
|
||||||
|
|
||||||
@ -219,7 +248,7 @@ POST /change_device_status
|
|||||||
|
|
||||||
## 🌐 Endpoint(数据终端)相关接口
|
## 🌐 Endpoint(数据终端)相关接口
|
||||||
|
|
||||||
### 8. 获取所有 Endpoint
|
### 9. 获取所有 Endpoint
|
||||||
|
|
||||||
**接口:**
|
**接口:**
|
||||||
|
|
||||||
@ -312,7 +341,7 @@ GET /get_all_endpoints
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 9. 获取指定 Endpoint 信息
|
### 10. 获取指定 Endpoint 信息
|
||||||
|
|
||||||
**接口:**
|
**接口:**
|
||||||
|
|
||||||
|
|||||||
23
rebar.config
23
rebar.config
@ -1,4 +1,7 @@
|
|||||||
{erl_opts, [debug_info]}.
|
{erl_opts, [debug_info]}.
|
||||||
|
|
||||||
|
{project_app_dirs, ["apps/*"]}.
|
||||||
|
|
||||||
{deps, [
|
{deps, [
|
||||||
{poolboy, ".*", {git, "https://github.com/devinus/poolboy.git", {tag, "1.5.1"}}},
|
{poolboy, ".*", {git, "https://github.com/devinus/poolboy.git", {tag, "1.5.1"}}},
|
||||||
{hackney, ".*", {git, "https://github.com/benoitc/hackney.git", {tag, "1.25.0"}}},
|
{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"}}},
|
{cowboy, ".*", {git, "https://github.com/ninenines/cowboy.git", {tag, "2.14.0"}}},
|
||||||
{ranch, ".*", {git, "https://github.com/ninenines/ranch.git", {tag, "2.2.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"}}},
|
{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"}}},
|
{eredis, ".*", {git, "https://github.com/wooga/eredis.git", {tag, "v1.2.0"}}},
|
||||||
{emqtt, ".*", {git, "https://gitea.s5s8.com/anlicheng/emqtt.git", {branch, "main"}}},
|
{emqtt, ".*", {git, "https://gitea.s5s8.com/anlicheng/emqtt.git", {tag, "v1.2"}}},
|
||||||
{gproc, ".*", {git, "https://github.com/uwiger/gproc.git", {tag, "0.9.1"}}},
|
{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"}}}
|
|
||||||
]}.
|
]}.
|
||||||
|
|
||||||
{relx, [{release, {iot, "0.1.0"},
|
{relx, [{release, {iot, "0.1.0"},
|
||||||
[iot,
|
[iot,
|
||||||
|
endpoint,
|
||||||
sasl]},
|
sasl]},
|
||||||
|
|
||||||
{mode, dev},
|
{include_erts, true},
|
||||||
|
{mode, prod},
|
||||||
%{mode, prod},
|
%{mode, prod},
|
||||||
|
|
||||||
%% automatically picked up if the files
|
%% automatically picked up if the files
|
||||||
%% exist but can be set manually, which
|
%% exist but can be set manually, which
|
||||||
%% is required if the names aren't exactly
|
%% is required if the names aren't exactly
|
||||||
%% sys.config and vm.args
|
%% sys.config and vm.args
|
||||||
{sys_config, "./config/sys.config"},
|
{sys_config, "./config/sys-dev.config"},
|
||||||
{vm_args, "./config/vm.args"}
|
{vm_args, "./config/vm.args"}
|
||||||
|
|
||||||
%% the .src form of the configuration files do
|
%% the .src form of the configuration files do
|
||||||
@ -39,14 +40,12 @@
|
|||||||
[%% prod is the default mode when prod
|
[%% prod is the default mode when prod
|
||||||
%% profile is used, so does not have
|
%% profile is used, so does not have
|
||||||
%% to be explicitly included like this
|
%% to be explicitly included like this
|
||||||
{mode, prod}
|
{mode, prod},
|
||||||
|
{sys_config, "./config/sys-prod.config"}
|
||||||
|
|
||||||
%% use minimal mode to exclude ERTS
|
%% use minimal mode to exclude ERTS
|
||||||
%% {mode, minimal}
|
%% {mode, minimal}
|
||||||
]
|
]
|
||||||
}]}]}.
|
}]}]}.
|
||||||
|
|
||||||
{erl_opts, [{parse_transform,lager_transform}]}.
|
|
||||||
|
|
||||||
{rebar_packages_cdn, "https://hexpm.upyun.com"}.
|
{rebar_packages_cdn, "https://hexpm.upyun.com"}.
|
||||||
|
|
||||||
|
|||||||
24
rebar.lock
24
rebar.lock
@ -15,14 +15,13 @@
|
|||||||
{<<"crc32cer">>,{pkg,<<"crc32cer">>,<<"1.0.3">>},2},
|
{<<"crc32cer">>,{pkg,<<"crc32cer">>,<<"1.0.3">>},2},
|
||||||
{<<"emqtt">>,
|
{<<"emqtt">>,
|
||||||
{git,"https://gitea.s5s8.com/anlicheng/emqtt.git",
|
{git,"https://gitea.s5s8.com/anlicheng/emqtt.git",
|
||||||
{ref,"5111914a9b1b92b0b497f825c77bdd365e3989b0"}},
|
{ref,"c5a52dcb57cd23a4318e342b705f9f726e8d67a1"}},
|
||||||
0},
|
0},
|
||||||
{<<"eredis">>,
|
{<<"eredis">>,
|
||||||
{git,"https://github.com/wooga/eredis.git",
|
{git,"https://github.com/wooga/eredis.git",
|
||||||
{ref,"9ad91f149310a7d002cb966f62b7e2c3330abb04"}},
|
{ref,"9ad91f149310a7d002cb966f62b7e2c3330abb04"}},
|
||||||
0},
|
0},
|
||||||
{<<"fs">>,{pkg,<<"fs">>,<<"6.1.1">>},1},
|
{<<"fs">>,{pkg,<<"fs">>,<<"6.1.1">>},1},
|
||||||
{<<"goldrush">>,{pkg,<<"goldrush">>,<<"0.1.9">>},1},
|
|
||||||
{<<"gproc">>,
|
{<<"gproc">>,
|
||||||
{git,"https://github.com/uwiger/gproc.git",
|
{git,"https://github.com/uwiger/gproc.git",
|
||||||
{ref,"4ca45e0a97722a418a31eb1753f4e3b953f7fb1d"}},
|
{ref,"4ca45e0a97722a418a31eb1753f4e3b953f7fb1d"}},
|
||||||
@ -32,25 +31,10 @@
|
|||||||
{ref,"8c00789e411d7c09a9808d720232098da1f19d69"}},
|
{ref,"8c00789e411d7c09a9808d720232098da1f19d69"}},
|
||||||
0},
|
0},
|
||||||
{<<"idna">>,{pkg,<<"idna">>,<<"6.1.1">>},1},
|
{<<"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},
|
{<<"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},
|
{<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},1},
|
||||||
{<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.4.0">>},1},
|
{<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.4.0">>},1},
|
||||||
{<<"mysql">>,
|
{<<"parse_trans">>,{pkg,<<"parse_trans">>,<<"3.4.1">>},1},
|
||||||
{git,"https://github.com/mysql-otp/mysql-otp",
|
|
||||||
{ref,"caf5ff96c677a8fe0ce6f4082bc036c8fd27dd62"}},
|
|
||||||
0},
|
|
||||||
{<<"parse_trans">>,
|
|
||||||
{git,"https://github.com/uwiger/parse_trans",
|
|
||||||
{ref,"6f3645afb43c7c57d61b54ef59aecab288ce1013"}},
|
|
||||||
0},
|
|
||||||
{<<"poolboy">>,
|
{<<"poolboy">>,
|
||||||
{git,"https://github.com/devinus/poolboy.git",
|
{git,"https://github.com/devinus/poolboy.git",
|
||||||
{ref,"3bb48a893ff5598f7c73731ac17545206d259fac"}},
|
{ref,"3bb48a893ff5598f7c73731ac17545206d259fac"}},
|
||||||
@ -70,22 +54,22 @@
|
|||||||
{<<"certifi">>, <<"0E6E882FCDAAA0A5A9F2B3DB55B1394DBA07E8D6D9BCAD08318FB604C6839712">>},
|
{<<"certifi">>, <<"0E6E882FCDAAA0A5A9F2B3DB55B1394DBA07E8D6D9BCAD08318FB604C6839712">>},
|
||||||
{<<"crc32cer">>, <<"AD0E42BED8603F2C72DE2A00F1B5063FFE12D5988615CAD984096900431D1C1A">>},
|
{<<"crc32cer">>, <<"AD0E42BED8603F2C72DE2A00F1B5063FFE12D5988615CAD984096900431D1C1A">>},
|
||||||
{<<"fs">>, <<"9D147B944D60CFA48A349F12D06C8EE71128F610C90870BDF9A6773206452ED0">>},
|
{<<"fs">>, <<"9D147B944D60CFA48A349F12D06C8EE71128F610C90870BDF9A6773206452ED0">>},
|
||||||
{<<"goldrush">>, <<"F06E5D5F1277DA5C413E84D5A2924174182FB108DABB39D5EC548B27424CD106">>},
|
|
||||||
{<<"idna">>, <<"8A63070E9F7D0C62EB9D9FCB360A7DE382448200FBBD1B106CC96D3D8099DF8D">>},
|
{<<"idna">>, <<"8A63070E9F7D0C62EB9D9FCB360A7DE382448200FBBD1B106CC96D3D8099DF8D">>},
|
||||||
{<<"kafka_protocol">>, <<"6F53B15CD6F6A12C1D0010DB074B4A15985C71BC7F594BC2D67D9837B3B378A1">>},
|
{<<"kafka_protocol">>, <<"6F53B15CD6F6A12C1D0010DB074B4A15985C71BC7F594BC2D67D9837B3B378A1">>},
|
||||||
{<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>},
|
{<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>},
|
||||||
{<<"mimerl">>, <<"3882A5CA67FBBE7117BA8947F27643557ADEC38FA2307490C4C4207624CB213B">>},
|
{<<"mimerl">>, <<"3882A5CA67FBBE7117BA8947F27643557ADEC38FA2307490C4C4207624CB213B">>},
|
||||||
|
{<<"parse_trans">>, <<"6E6AA8167CB44CC8F39441D05193BE6E6F4E7C2946CB2759F015F8C56B76E5FF">>},
|
||||||
{<<"ssl_verify_fun">>, <<"354C321CF377240C7B8716899E182CE4890C5938111A1296ADD3EC74CF1715DF">>},
|
{<<"ssl_verify_fun">>, <<"354C321CF377240C7B8716899E182CE4890C5938111A1296ADD3EC74CF1715DF">>},
|
||||||
{<<"unicode_util_compat">>, <<"BC84380C9AB48177092F43AC89E4DFA2C6D62B40B8BD132B1059ECC7232F9A78">>}]},
|
{<<"unicode_util_compat">>, <<"BC84380C9AB48177092F43AC89E4DFA2C6D62B40B8BD132B1059ECC7232F9A78">>}]},
|
||||||
{pkg_hash_ext,[
|
{pkg_hash_ext,[
|
||||||
{<<"certifi">>, <<"B147ED22CE71D72EAFDAD94F055165C1C182F61A2FF49DF28BCC71D1D5B94A60">>},
|
{<<"certifi">>, <<"B147ED22CE71D72EAFDAD94F055165C1C182F61A2FF49DF28BCC71D1D5B94A60">>},
|
||||||
{<<"crc32cer">>, <<"08FDCD5CE51ACD839A12E98742F0F0EDA19A2A679FC9FBFAF6AAB958310FB70E">>},
|
{<<"crc32cer">>, <<"08FDCD5CE51ACD839A12E98742F0F0EDA19A2A679FC9FBFAF6AAB958310FB70E">>},
|
||||||
{<<"fs">>, <<"EF94E95FFE79916860649FED80AC62B04C322B0BB70F5128144C026B4D171F8B">>},
|
{<<"fs">>, <<"EF94E95FFE79916860649FED80AC62B04C322B0BB70F5128144C026B4D171F8B">>},
|
||||||
{<<"goldrush">>, <<"99CB4128CFFCB3227581E5D4D803D5413FA643F4EB96523F77D9E6937D994CEB">>},
|
|
||||||
{<<"idna">>, <<"92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA">>},
|
{<<"idna">>, <<"92376EB7894412ED19AC475E4A86F7B413C1B9FBB5BD16DCCD57934157944CEA">>},
|
||||||
{<<"kafka_protocol">>, <<"1D5E9597AD3C0776C86DC5E08D3BAAEA7DB805A52E5FD35E3F071AAD7789FC4C">>},
|
{<<"kafka_protocol">>, <<"1D5E9597AD3C0776C86DC5E08D3BAAEA7DB805A52E5FD35E3F071AAD7789FC4C">>},
|
||||||
{<<"metrics">>, <<"69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16">>},
|
{<<"metrics">>, <<"69B09ADDDC4F74A40716AE54D140F93BEB0FB8978D8636EADED0C31B6F099F16">>},
|
||||||
{<<"mimerl">>, <<"13AF15F9F68C65884ECCA3A3891D50A7B57D82152792F3E19D88650AA126B144">>},
|
{<<"mimerl">>, <<"13AF15F9F68C65884ECCA3A3891D50A7B57D82152792F3E19D88650AA126B144">>},
|
||||||
|
{<<"parse_trans">>, <<"620A406CE75DADA827B82E453C19CF06776BE266F5A67CFF34E1EF2CBB60E49A">>},
|
||||||
{<<"ssl_verify_fun">>, <<"FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8">>},
|
{<<"ssl_verify_fun">>, <<"FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8">>},
|
||||||
{<<"unicode_util_compat">>, <<"25EEE6D67DF61960CF6A794239566599B09E17E668D3700247BC498638152521">>}]}
|
{<<"unicode_util_compat">>, <<"25EEE6D67DF61960CF6A794239566599B09E17E668D3700247BC498638152521">>}]}
|
||||||
].
|
].
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|
||||||
|
|
||||||
```
|
|
||||||
Loading…
x
Reference in New Issue
Block a user