在 Erlang 中使用 ANSI 转义码 —— io:format、\e、cf、OTP logger
Erlang 字符串字面量接受本站记录的所有语言中最广泛的 ESC 形式集合 —— `"\e"`(命名 ESC)、`"\x1b"`(十六进制)、`"\033"`(八进制 —— Erlang 的 `\NNN` 是八进制,与 C 相同),以及 `"\^["`(脱字号控制字符记法 —— `^A` 到 `^Z` 映射到 ASCII 1..26,`^[` 映射到 ASCII 27 = ESC)。Erlang 中字符串是 charlist(整数列表);二进制 `<<"...">>` 也是一等公民。`io:format("\e[1;31merror:\e[0m ~s~n", [Msg])` 是规范的 Erlang 单行写法 —— `~s` 插入第二个参数列表、`~n` 是平台换行符、ANSI 字节通过 `io:format/2` 原样传给调用进程的 `group_leader`(shell 附着的代码下即用户终端)。 SGR 辅助选 **`cf`**(project-fifo/cf —— "Erlang Colour Format")—— 扩展 `io:format` 的 `~` 指令语法加入 `~!r`(红)、`~!g`(绿)、`~!b`(蓝)、`~!_`(粗体)、`~!!`(reset)等。`cf:format("~!rerror:~!! permission denied~n")` 比原始字节形式更简洁。结构化日志选 OTP 标准库的 **`logger`** 模块(OTP 21+ 内置 —— 2018 年 3 月)—— `logger:info("started", #{port => 8080})` 在默认处理器附着 TTY 时产生带颜色的级别前缀,重定向时产生 JSON。生产中仍能见到的 OTP-21 之前的代码库选 **`lager`**(basho 的结构化日志库 —— logger 之前的规范选择,每级别 ANSI 着色前缀,基于 sink 的每应用过滤)。 **OTP release 模式注意**(本页主要 SERP 差异化):每个 Erlang 进程都有一个 `group_leader` 处理其 I/O。交互式 shell 或 escript 中,`group_leader` 就是用户终端 —— ANSI 透传。当你构建 OTP release(`rebar3 release` → `bin/myapp daemon` 或 `foreground`),`group_leader` 被重新分配:`daemon` 模式下它是写入 `log/erlang.log.N` 的 logger 进程,ANSI 字节作为 `\e[31m...` 文本**字面**出现在日志文件中。修复方法:使用 `logger` 模块的着色 handler(当其输出目标不是 TTY 时去除 ANSI —— 通过 `io:rows()` 返回 `{error, enotsup}` 自动检测),或通过 `application:get_env(kernel, standard_io_handler)` 检测并显式门控 `io:format` 颜色调用。 能力门控:`io:rows()` 在 stdout 是 TTY 时返回 `{ok, Rows}`,重定向时返回 `{error, enotsup}`。搭配 `os:getenv("NO_COLOR")` 处理通用退出。现代 Erlang shell(`erl`,OTP 25+)在所有平台包括 Windows 上原生解析 `io:format` 输出中的 ANSI(BEAM 模拟器在首次 ANSI 字节时处理 `SetConsoleMode`)。
推荐库
- io / io_lib (stdlib)
OTP 标准库的 printf —— `io:format(Format, Args)` 写入调用进程的 `group_leader`,`io_lib:format/2` 返回 charlist。格式指令:`~s`(字符串 / charlist)、`~p`(term 检查)、`~w`(原始 term)、`~B`(十进制整数)、`~.16B`(十六进制)、`~n`(平台换行符)。嵌在格式字符串里的 ANSI 字节原样传递。`io:format("\e[31m~s\e[0m~n", [Msg])` 是规范的 Erlang 颜色单行写法。
- cf
"Erlang Colour Format" —— 扩展 `io:format` 的 `~` 指令语法加入 `~!r`(红)、`~!g`(绿)、`~!b`(蓝)、`~!y`(黄)、`~!m`(紫)、`~!c`(青)、`~!w`(白)、`~!_`(粗体)、`~!!`(reset)。`cf:format("~!rerror:~!! ~s~n", [Msg])` 比原始转义字节更简洁。`cf:format/1,2` 返回格式化的 charlist;`cf:print/1,2` 写入 standard_io。stdout 非 TTY 时自动禁用。
- logger (stdlib)
OTP 21+ 标准库结构化日志库 —— 替代 `error_logger`。`logger:info("started", #{port => 8080})` 发出结构化事件;默认 handler(`logger_std_h`)在 stdout 是 TTY 时把 `[INFO]` 格式化为绿色,重定向时输出纯 JSON。通过 `application:set_env(kernel, logger_level, debug)` 设置每模块日志级别。任何新 Erlang 代码的正确选择;OTP-21 之前的代码库用 `lager`。
- lager
Basho 的结构化日志库 —— OTP-21 之前的规范 Erlang 日志库,仍广泛用于较老的代码库(Riak、RabbitMQ、lasse、vmq_server)。`lager:info("started on port ~p", [Port])` 在控制台后端检测到 TTY 时产生带颜色的 `[info]` 前缀。基于 sink 的每应用过滤、异步批处理、文件滚动内置。维护未迁移到 `logger` 的 2018 年前 OTP 代码库时选它。
常用写法
%% Erlang accepts FOUR ESC forms in "..." literals.
%% All compile to the same single byte (0x1b = 27).
%%
%% "\e" — named ESC escape (most readable)
%% "\033" — \NNN is OCTAL (same as C — \033 = decimal 27)
%% "\x1b" — hex (exactly 2 digits)
%% "\^[" — caret-notation control character (^A..^Z map
%% to ASCII 1..26; ^[ maps to ASCII 27 = ESC)
%%
%% Erlang follows C's octal convention (unlike Nim and OCaml
%% which use decimal). When porting from C, "\033" works
%% unchanged. When porting from Nim/OCaml, "\27" silently
%% mis-compiles to the wrong byte — use "\e" or "\x1b".
-module(ansi_demo).
-export([main/0]).
main() ->
io:format("\e[1;31merror:\e[0m permission denied~n"),
io:format("\033[33mwarn:\033[0m deprecated flag~n"),
io:format("\x1b[32mok:\x1b[0m 142 tests passed~n"),
io:format("\^[[36minfo:\^[[0m skipping cache~n"),
%% Truecolor — 38;2;R;G;B
io:format("\e[38;2;255;128;0morange truecolor\e[0m~n"),
%% 256-palette — 38;5;n
io:format("\e[38;5;196mbright red\e[0m~n"),
%% String interpolation via ~s — passes the list through:
Msg = "permission denied",
io:format("\e[1;31merror:\e[0m ~s~n", [Msg]),
%% io_lib:format returns the formatted charlist
%% instead of writing — handy for building log lines:
Line = io_lib:format("\e[32mok:\e[0m ~B tests passed~n", [142]),
io:put_chars(Line).
%% Reusable helper — pure Erlang:
esc(Sgr, Text) ->
io_lib:format("\e[~sm~s\e[0m", [Sgr, Text]).%% rebar.config:
%% {deps, [{cf, "0.3.1"}]}.
%%
%% Then `rebar3 compile` and use:
-module(cf_demo).
-export([main/0]).
main() ->
%% cf:format extends ~ directives with colour:
%% ~!r — red ~!g — green ~!b — blue
%% ~!y — yellow ~!m — magenta ~!c — cyan
%% ~!w — white ~!_ — bold ~!! — reset
cf:print("~!rerror:~!! permission denied~n"),
cf:print("~!gok:~!! 142 tests passed~n"),
cf:print("~!yawarn:~!! deprecated flag~n"),
%% Capital letter = background:
cf:print("~!_~!Y ~!K~!RCAUTION~!! brakes wet~n"),
%% ~!_ = bold start; ~!Y = bg yellow; ~!K = fg black; ~!R = fg red
%% Combine with regular io:format directives:
Port = 8080,
cf:print("~!c[INFO]~!! started on port ~p~n", [Port]),
Tests = 142, Failed = 3,
cf:print(
"~!grun:~!! ~B tests, ~!r~B failed~!!~n",
[Tests, Failed]
),
%% cf:format/1,2 returns the formatted iolist
%% (use cf:print/1,2 to write to standard_io):
Line = cf:format("~!_~!cFATAL~!!: ~s~n", ["server crashed"]),
io:put_chars(Line),
%% Auto-disables when stdout is not a TTY — try:
%% rebar3 shell --script script.escript | cat
%% cf detects via io:rows() and emits plain text when
%% the call returns {error, enotsup}.
ok.%% OTP 21+ stdlib — no dependency needed.
%% sys.config (or application:set_env at boot):
%%
%% [{kernel, [
%% {logger_level, info},
%% {logger, [
%% {handler, default, logger_std_h,
%% #{config => #{type => standard_io},
%% formatter => {logger_formatter,
%% #{template =>
%% [time, " [", level, "] ",
%% msg, "\n"],
%% single_line => true,
%% legacy_header => false}}}}
%% ]}
%% ]}].
-module(logger_demo).
-export([main/0]).
main() ->
%% Plain structured logging — coloured level prefix
%% on TTY, plain text on redirect, JSON if you swap
%% the formatter for logger_logstash_formatter:
logger:debug("trace: entering handler"),
logger:info("started on port ~p", [8080]),
logger:warning("slow query: ~p ms", [1240]),
logger:error("auth failed for user ~p", [42]),
%% Structured form with a metadata map:
logger:info("request received",
#{method => "GET", path => "/users/42"}),
%% Per-module level — set at runtime:
logger:set_module_level(?MODULE, debug),
logger:debug("now visible after set_module_level"),
%% Per-handler config — change formatting / destination
%% without touching call sites:
logger:update_handler_config(default, #{
formatter => {logger_formatter,
#{template => [time, " ", level, ": ", msg, "\n"],
single_line => true}}
}),
ok.%% Capability gating in Erlang:
%% io:rows() — {ok, Rows} when TTY,
%% {error, enotsup} when redirected
%% os:getenv("NO_COLOR") — universal opt-out env var
%% os:type() — {unix, _} | {win32, _}
%%
%% OTP RELEASE-MODE CAVEAT (page's primary SERP differentiator):
%% Every Erlang process has a group_leader handling its I/O.
%% In a shell or escript, group_leader IS the user's terminal —
%% ANSI passes through. In an OTP release run as a daemon
%% (rebar3 release → bin/myapp daemon), group_leader is
%% reassigned to a logger process writing to log/erlang.log.N,
%% and ANSI bytes appear LITERALLY in the log file as
%% "\e[31m...". The fix: use the OTP logger module with a
%% coloured handler (auto-strips ANSI when not on a TTY), or
%% gate your io:format colour calls explicitly.
-module(cap_demo).
-export([ansi_capable/0, styled/2, main/0]).
ansi_capable() ->
case os:getenv("NO_COLOR") of
false ->
case os:getenv("FORCE_COLOR") of
false ->
%% io:rows() returns {ok, _} only on TTY:
case io:rows() of
{ok, _} -> true;
{error, _} -> false
end;
_ -> true
end;
_ -> false
end.
styled(Text, Sgr) ->
case ansi_capable() of
true -> io_lib:format("\e[~sm~s\e[0m", [Sgr, Text]);
false -> Text
end.
main() ->
%% Plain io:format with embedded ANSI works fine
%% from the shell. In a release daemon, those bytes
%% would land literally in log/erlang.log.N.
io:format("~s~n", [styled("OK", "32")]),
io:format("~s~n", [styled("FAIL", "1;31")]),
%% Detect release-mode group_leader explicitly:
GL = group_leader(),
case erlang:process_info(GL, registered_name) of
{registered_name, logger_std_h_default} ->
io:format("(running under release logger — "
"colour calls would land in log file)~n");
_ ->
io:format("(running under shell group_leader — "
"colour passes through to terminal)~n")
end,
%% Force-emit via 'standard_error' which retains the
%% terminal even under some release configurations:
io:format(standard_error, "~s ~s~n",
[styled("DEBUG", "36"), "checkpoint hit"]).