Elixir 实战指南:用 Doctest、模式匹配与 `with` 构建命令解析与 TCP 服务
编程语言编译器标准库语言运行时并发编程【免费下载链接】elixirSimple from zero to scale项目地址https://gitcode.com/GitHub_Trending/el/elixir点击查看免费下载本文是 Elixir 官方入门教程《Mix and OTP》系列中的一章源自 lib/elixir/pages/mix-and-otp/docs-tests-and-with.md面向正在构建一个基于 TCP 的键值存储服务Bucket 服务的开发者。你将学会如何用doctest文档即测试驱动命令解析器KV.Command.parse/1的编写用模式匹配优雅地解析CREATE / PUT / GET / DELETE命令用with构造替代层层嵌套的case来串联服务端逻辑并最终通过集成测试打通 TCP 服务器到 Bucket 的完整链路。读完本文你将能独立实现一个可运行、可测试的命令解析与分发模块。目标解析并分发键值存储命令在前面章节中我们设计了一个通过 TCP 交互的键值存储服务。本章要实现它的命令层客户端通过telnet发送如下形式的文本命令服务器逐行解析并执行CREATE shopping OK PUT shopping milk 1 OK PUT shopping eggs 3 OK GET shopping milk 1 OK DELETE shopping eggs OK命令按空格分词CREATE后跟桶名bucketPUT后跟桶名、键、值GET和DELETE后跟桶名、键。解析完成后服务器要把解析出的命令分发到对应的 Bucket 进程上执行。用 Doctest 驱动开发命令解析器Elixir 一直强调“文档是一等公民”这一点在mix help、IEx 中的h Enum等处都有体现。本章更进一步用 doctest 让文档中的代码示例直接变成可运行的测试从而保证文档里的示例始终准确。创建带 doctest 的解析器骨架在lib/kv/command.ex中创建KV.Command模块先写一个带文档和 doctest 的桩实现defmodule KV.Command do doc ~S Parses the given line into a command. ## Examples iex KV.Command.parse(CREATE shopping\r\n) {:ok, {:create, shopping}} def parse(_line) do :not_implemented end end这里有两个关键点doctest 的书写格式在文档字符串中示例以四个空格缩进以iex提示符开头表达式跨多行时用...续行与 IEx 一致。期望结果必须紧跟在iex/...行的下一行并以换行或新的iex前缀结束。源码层面ExUnit 通过ExUnit.DocTest提取这些示例并编译成断言见 lib/ex_unit/lib/ex_unit/doc_test.ex 中的extract_tests/4、adjust_text/5等函数其iex_prompt、dot_prompt常量正是[iex, iex(]与[..., ...(]见 doc_test.ex#L707-L709。~Ssigil 的作用文档字符串用doc ~S包裹~S让\r\n保持字面形式、不做转义替换直到 doctest 真正求值时才被处理为回车与换行。注册 doctest 并看到失败创建测试文件test/kv/command_test.exs在测试用例中调用doctest宏defmodule KV.CommandTest do use ExUnit.Case, async: true doctest KV.Command end运行测试套件doctest 会失败并给出清晰的诊断信息1) doctest KV.Command.parse/1 (1) (KV.CommandTest) test/kv/command_test.exs:3 Doctest failed doctest: iex KV.Command.parse(CREATE shopping\r\n) {:ok, {:create, shopping}} code: KV.Command.parse CREATE shopping\r\n {:ok, {:create, shopping}} left: :not_implemented right: {:ok, {:create, shopping}} stacktrace: lib/kv/command.ex:7: KV.Command (module)从失败信息可以直观看出 doctest 的断言机制它把示例编译为表达式 期望值的相等断言对应源码中ExUnit.DocTest.__test__/8的实现见 doc_test.ex#L517-L533。注意这里的模块、路径、端口、文件名均以本仓库实际结构为准示例中的lib/kv/command.ex是教程项目kv 应用内的路径在你的项目中会相应替换。让 doctest 通过一次case完成解析实现parse/1的第一个版本def parse(line) do case String.split(line) do [CREATE, bucket] - {:ok, {:create, bucket}} end endString.split/1按空白切分字符串因此我们的命令天然对空白不敏感前导/尾随空白、单词之间的连续空格都不影响解析结果String.split/1的默认行为可参见 lib/elixir/lib/string.ex#L516 及其文档。接着补充更多 doctest覆盖剩余命令和错误情况doc ~S Parses the given line into a command. ## Examples iex KV.Command.parse CREATE shopping\r\n {:ok, {:create, shopping}} iex KV.Command.parse CREATE shopping \r\n {:ok, {:create, shopping}} iex KV.Command.parse PUT shopping milk 1\r\n {:ok, {:put, shopping, milk, 1}} iex KV.Command.parse GET shopping milk\r\n {:ok, {:get, shopping, milk}} iex KV.Command.parse DELETE shopping eggs\r\n {:ok, {:delete, shopping, eggs}} Unknown commands or commands with the wrong number of arguments return an error: iex KV.Command.parse UNKNOWN shopping eggs\r\n {:error, :unknown_command} iex KV.Command.parse GET shopping\r\n {:error, :unknown_command} 接下来由你尝试让这些 doctest 全部通过。参考解法如下def parse(line) do case String.split(line) do [CREATE, bucket] - {:ok, {:create, bucket}} [GET, bucket, key] - {:ok, {:get, bucket, key}} [PUT, bucket, key, value] - {:ok, {:put, bucket, key, value}} [DELETE, bucket, key] - {:ok, {:delete, bucket, key}} _ - {:error, :unknown_command} end end可以看到借助模式匹配我们优雅地完成了“校验命令名 校验参数个数”两件事完全没有if/else式的命令名判断与参数长度检查。理解 doctest 的分割规则细心的读者可能注意到测试套件现在报告共有7 个 doctest。这是因为 ExUnit 把下面这种写法视为两个独立的 doctest两个示例之间有空行iex KV.Command.parse(UNKNOWN shopping eggs\r\n) {:error, :unknown_command} iex KV.Command.parse(GET shopping\r\n) {:error, :unknown_command}而如果示例之间没有空行ExUnit 会把它们编译进同一个 doctestiex KV.Command.parse(UNKNOWN shopping eggs\r\n) {:error, :unknown_command} iex KV.Command.parse(GET shopping\r\n) {:error, :unknown_command}空行作为 doctest 边界这一行为正对应ExUnit.DocTest中chunk_tests/3的test_finished?/1——它把空行视为一个测试块的结束见 doc_test.ex#L810-L825。最后要强调doctest 的本质是“先是文档后是测试”。它的目标不是取代单元测试而是让文档中的示例始终与实际行为一致。关于 doctest 的更多语法多行表达式、...续行、异常断言、#Name...不透明类型、:except/:only选项等可阅读 lib/ex_unit/lib/ex_unit/doc_test.ex 的模块文档。用with串联服务端的读、解析、执行解析器就绪后开始实现命令执行逻辑。先在KV.Command中加一个桩defmodule KV.Command do doc Runs the given command. def run(command, socket) do :gen_tcp.send(socket, OK\r\n) :ok end end同时之前read_line/1在客户端关闭连接时会崩溃正好一并修复。打开lib/kv/server.ex把原来的serve/1、read_line/1、write_line/2defp serve(socket) do socket | read_line() | write_line(socket) serve(socket) end defp read_line(socket) do {:ok, data} :gen_tcp.recv(socket, 0) data end defp write_line(line, socket) do :gen_tcp.send(socket, line) end替换为下面这种“显式处理错误”的版本defp serve(socket) do msg case read_line(socket) do {:ok, data} - case KV.Command.parse(data) do {:ok, command} - KV.Command.run(command, socket) {:error, _} err - err end {:error, _} err - err end write_line(socket, msg) serve(socket) end defp read_line(socket) do :gen_tcp.recv(socket, 0) end defp write_line(_socket, :ok) do :ok end defp write_line(socket, {:error, :unknown_command}) do # Known error; write to the client :gen_tcp.send(socket, UNKNOWN COMMAND\r\n) end defp write_line(_socket, {:error, :closed}) do # The connection was closed, exit politely exit(:shutdown) end defp write_line(socket, {:error, error}) do # Unknown error; write to the client and exit :gen_tcp.send(socket, ERROR\r\n) exit(error) end end此时启动服务器并用telnet 127.0.0.1 4040交互可以看到已知命令返回OK未知命令返回UNKNOWN COMMAND$ telnet 127.0.0.1 4040 Trying 127.0.0.1... Connected to localhost. Escape character is ^]. CREATE shopping OK HELLO UNKNOWN COMMAND功能方向正确但嵌套的case不够优雅。这正是with构造的用武之地。with的工作原理with允许用一串匹配子句取代嵌套的case它取-右侧表达式的返回值与左侧模式匹配匹配则进入下一步一旦任何一步不匹配with立即中止并原样返回那个不匹配的值。把它与普通匹配操作符对比理解会更清晰不匹配时会抛出MatchError而-不匹配时只是中止整条链并返回该值with/1的完整文档见 lib/elixir/lib/kernel/special_forms.ex#L1552-L1713。用with重写serve/1defp serve(socket) do msg with {:ok, data} - read_line(socket), {:ok, command} - KV.Command.parse(data), do: KV.Command.run(command, socket) write_line(socket, msg) serve(socket) end每一步都对应原先case中的一个分支read_line/1返回{:ok, data}则继续KV.Command.parse/1返回{:ok, command}则执行run/2一旦某一步返回{:error, _}等不匹配值with直接返回该错误值给write_line/2处理。with还支持else分支、普通匹配子句注意此时不匹配仍会抛出MatchError以及变量作用域限制with内绑定的变量在外部不可见这些都是生产代码中经常用到的特性详见上文提到的with/1文档。实现命令执行分发到 Bucket最后实现KV.Command.run/2把解析出的命令分发到对应的 Bucket 进程doc Runs the given command. def run(command, socket) def run({:create, bucket}, socket) do KV.create_bucket(bucket) :gen_tcp.send(socket, OK\r\n) :ok end def run({:get, bucket, key}, socket) do lookup(bucket, fn pid - value KV.Bucket.get(pid, key) :gen_tcp.send(socket, #{value}\r\nOK\r\n) :ok end) end def run({:put, bucket, key, value}, socket) do lookup(bucket, fn pid - KV.Bucket.put(pid, key, value) :gen_tcp.send(socket, OK\r\n) :ok end) end def run({:delete, bucket, key}, socket) do lookup(bucket, fn pid - KV.Bucket.delete(pid, key) :gen_tcp.send(socket, OK\r\n) :ok end) end defp lookup(bucket, callback) do if bucket KV.lookup_bucket(bucket) do callback.(bucket) else {:error, :not_found} end end几个要点无主体的函数头def run(command, socket)只有函数头、没有函数体。在前面《Modules and Functions》章节见 modules-and-functions.md 的“默认参数”部分我们学过用函数头声明多子句函数的默认参数这里则用函数头来承载参数文档说明。注意原文中该章节链接原为../getting-started/modules-and-functions.md#default-arguments在本仓库中应解析为 lib/elixir/pages/getting-started/modules-and-functions.md。私有辅助函数lookup/2封装“查桶并取回 pid”的公共逻辑——桶存在则对其执行回调不存在则返回{:error, :not_found}。注意if bucket KV.lookup_bucket(bucket)中在布尔上下文里绑定变量并做真值判断这是 Elixir 中常见且合法的写法。由于现在可能出现{:error, :not_found}还需在KV.Server的write_line/2中补上对应分支defp write_line(socket, {:error, :not_found}) do :gen_tcp.send(socket, NOT FOUND\r\n) end至此服务器功能基本完成只差测试。集成测试从 TCP 服务器到 Bucket 的完整链路KV.Command.run/2直接调用KV模块而KV用本地注册表registry给进程命名。这意味着如果两个测试向同一个桶发消息测试会互相冲突很可能失败。有人会想到用 mock 等技术隔离测试但这类手段往往让测试环境离生产环境太远反而埋下隐患。本指南一直沿用的技巧在这里同样适用只要每个测试使用唯一的名字依赖本地注册表是没问题的。用“测试模块名 测试名”组合出来的名字就足够保证唯一性。创建test/kv/server_test.exsdefmodule KV.ServerTest do use ExUnit.Case, async: true socket_options [:binary, packet: :line, active: false] setup config do {:ok, socket} :gen_tcp.connect(~clocalhost, 4040, socket_options) test_name config.test | Atom.to_string() | String.replace( , -) %{socket: socket, name: #{config.module}-#{test_name}} end test server interaction, %{socket: socket, name: name} do # CREATE assert send_and_recv(socket, CREATE #{name}\r\n) OK\r\n # PUT assert send_and_recv(socket, PUT #{name} eggs 3\r\n) OK\r\n # GET assert send_and_recv(socket, GET #{name} eggs\r\n) 3\r\n assert send_and_recv(socket, ) OK\r\n # DELETE assert send_and_recv(socket, DELETE #{name} eggs\r\n) OK\r\n # GET assert send_and_recv(socket, GET #{name} eggs\r\n) \r\n assert send_and_recv(socket, ) OK\r\n end test unknown command, %{socket: socket} do assert send_and_recv(socket, WHATEVER\r\n) UNKNOWN COMMAND\r\n end test unknown bucket, %{socket: socket} do assert send_and_recv(socket, GET whatever eggs\r\n) NOT FOUND\r\n end defp send_and_recv(socket, command) do :ok :gen_tcp.send(socket, command) {:ok, data} :gen_tcp.recv(socket, 0, 1000) data end end运行mix test所有测试应通过。注意请先终止可能正在运行的iex -S mix会话——当前测试与开发环境共用 4040 端口端口冲突问题将在下一章解决。三个测试的解读第一个测试覆盖了大部分桶操作CREATE→PUT→GET→DELETE→ 再GET。注意每次GET之后都要再发送一次空命令来读取紧随其后的OK\r\n这正是协议中“值之后跟OK”的设计。后两个测试分别覆盖UNKNOWN COMMAND与NOT FOUND两种错误路径。大量共享设置建立 TCP 连接、生成唯一桶名通过setup/2宏提取成样板代码。setup接收与测试相同的测试上下文test context为每个测试开启一条客户端 TCP 连接并用模块名与测试名拼出唯一桶名测试名中的空格替换为-避免干扰命令解析。在每个测试中我们通过模式匹配从测试上下文中提取需要的socket或name这与之前在test/kv/bucket_test.exs中的test stores values by key on a named process, config do写法相似——只不过当时匹配了整个config这次只匹配所需字段。至此本章完成从 doctest 驱动的命令解析器到with简化的服务器主循环再到run/2的桶分发与完整集成测试。下一章将用“一点配置 一行代码改动”让系统走向分布式。赞分享编程语言编译器标准库语言运行时并发编程【免费下载链接】elixirSimple from zero to scale项目地址https://gitcode.com/GitHub_Trending/el/elixir点击查看免费下载相关推荐Tornado TCP Echo 实战用 TCPServer 与 TCPClient 构建异步流式服务Tornado TCP Echo 实战用 TCPServer 与 TCPClient 构建异步流式服务 导读 Tornado 不仅是 Web 框架其底层还提后端Web框架异步编程WebSocketDiceDB 的 KEYS 命令完全指南通配符模式匹配、源码实现与实战用法DiceDB 的 KEYS 命令完全指南通配符模式匹配、源码实现与实战用法 本指南以 DiceDB 官方命令文档 docs/src/content/docs/数据库缓存后端攻克Elixir测试痛点doctest异常消息匹配全解析攻克Elixir测试痛点doctest异常消息匹配全解析 你是否在Elixir项目中遇到过这样的困境精心编写的doctest在验证异常时频频失效错误消息明编程语言编译器标准库语言运行时并发编程上一篇AliceMind安全指南企业级AI模型部署的安全最佳实践下一篇Ryujinx模拟器在PC上完美运行Switch游戏的终极解决方案创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
上一篇/下一篇内容由系统自动关联
返回资讯列表 →