CLI-Anything:用统一命令行入口调度所有工具与自动化流程
发布时间:2026/9/29 19:26:12来源:尧图网络
我是一个记性很差的人所以我对命令行的依赖反而特别深。新装一台机器要敲十几条命令部署一个服务要记住一长串环境变量想查日志还得先回忆路径和参数——这些事情单看都不难但攒在一起每天都在消耗注意力。后来我耐心做了一件小事把所有反复要做的事情统一收进同一个入口通过一个命令去调度所有工具和流程。这套东西我管它叫CLI-Anything。CLI-Anything 不是一个特定公司的商业产品也不是某一门语言的官方框架它更像是一种人人都能自建的轻量方案凡是你能用命令完成的事都收进同一个 CLI凡是重复操作都通过子命令一键触发凡是需要记忆的细节都交给配置文件和适配器去管理。适合谁呢适合运维、后端、前端、测试以及任何一个每天要跟终端打交道的人。下面我把整个设计思路、核心机制和踩过的坑一次性讲清楚。1. CLI-Anything 是什么把万事万物变成命令行的整体方案我先解释一下这个项目名的含义。Anything 不是夸张它指的是只要能通过参数、脚本、API 或配置文件描述的逻辑都能注册成一个命令。常见的终端工具各自只解决一个问题curl管请求git管版本ssh管远程而 CLI-Anything 的定位是站在这些工具之上做一层统一调度壳。很多团队其实已经有类似的内部系统只是形态各不相同有人用 Makefile有人用 Shell 脚本集合有人用 npm scripts。它们都能用但各有各的局限。Makefile 对缩进敏感写复杂逻辑时像在跟 Tab 搏斗Shell 脚本复用性差一个脚本里堆满了if和fornpm scripts 绑死了 Node 生态。CLI-Anything 想做的是一种与运行环境解耦、以任务为中心的命令组织方式。我把它设计成三个层次层次作用举例入口层统一启动器负责参数解析和任务分发clia deploy prod任务层声明任务清单描述做什么、怎么做、依赖什么YAML/JSON 中的任务定义执行层真正干活的人可以是脚本、二进制、API 调用或 Python 函数适配器这个三层结构的核心价值在于使用者只需要记得入口命令不需要关心底层是 Shell 还是 Python也不需要关心目标机器在哪。这个思路特别适合那些工具太多、记不住参数的人。我第一次把 20 多个日常命令收敛成一个clia入口之后最大的变化不是敲字少了而是脑子里的负担明显减轻了。在设计具体形态时我坚持了一个原则CLI-Anything 不重新发明命令执行方式它只是把你自己写的逻辑和现有工具串起来。也就是说底层能用的命令继续用能调的库继续调它只负责调度、编排、参数校验和统一输出。这样做的另一个好处是门槛低——你不需要为了使用它去学一门新语言。2. 任务清单驱动的核心机制从人背命令到配置驱动CLI-Anything 最核心的设计是所有可执行动作都围绕任务清单来组织。任务清单本质上是一个描述性数据结构告诉程序有哪些命令、每个命令接收什么参数、执行时调用哪个适配器、成功和失败分别怎么处理。2.1 顶层入口的设计逻辑入口层只做三件事解析全局参数、定位子命令、找到并执行对应的适配器。我建议入口本身尽量保持精简所有业务逻辑都不要写在这里。它就像一个路由器收到请求后只负责转发。下面的代码是用 Python 写的入口我刻意控制了依赖只用标准库加一个 YAML 解析#!/usr/bin/env python3 import argparse import yaml import importlib import sys from pathlib import Path CONFIG_PATH Path.home() / .clia / tasks.yaml def load_config(): with open(CONFIG_PATH, r, encodingutf-8) as f: return yaml.safe_load(f) def main(): parser argparse.ArgumentParser(progclia, descriptionCLI-Anything entry) parser.add_argument(--verbose, actionstore_true, helpshow debug logs) subparsers parser.add_subparsers(desttask, requiredTrue) config load_config() for task_name, task_def in config.get(tasks, {}).items(): sub subparsers.add_parser(task_name, helptask_def.get(help, )) for arg in task_def.get(args, []): sub.add_argument(arg[name], **arg.get(options, {})) args parser.parse_args() task_def config[tasks][args.task] module_path task_def[adapter][module] func_name task_def[adapter][function] module importlib.import_module(module_path) func getattr(module, func_name) func(args) if __name__ __main__: main()这段代码虽然只有 30 来行但已经构成了一个可用的调度骨架。你只需要维护 YAML 里的任务定义就能不断扩展新的子命令而入口代码几乎不用再改。使用argparse的原因很直接它自带子命令支持、参数校验和--help生成不需要额外维护一份命令文档。这比手写sys.argv解析要稳得多。你可能会问为什么不用 Click 或 Typer因为我想保持零第三方框架依赖只需要一个pyyaml就能跑起来这样在任意 Linux 机器上都能快速部署。2.2 声明式任务配置参数、适配器与错误策略任务清单我放在~/.clia/tasks.yaml里。每个任务包含四个关键字段名称、帮助信息、参数列表、适配器。下面是一个实际例子tasks: check: help: Check system status args: - name: --host options: required: false default: localhost - name: --timeout options: type: int default: 5 adapter: module: tasks.sys_check function: run_check deploy: help: Deploy a service to remote host args: - name: --env options: choices: [dev, staging, prod] required: true - name: --tag options: required: false default: latest adapter: module: tasks.deploy function: run_deploy把参数声明放在 YAML 里而不是写在代码里最大的好处是非开发人员也能安全地新增命令不需要碰 Python 源码。它把命令入口变成了配置文件的一部分格式清晰review 也方便。我特别建议给每个任务的参数都声明类型和取值范围。比如--timeout声明为int--env限定choices。这样入口层就能提前拦截大部分无效输入而不是等适配器执行到一半才发现类型错误。这个习惯在我后来接入十几个任务后节省了大量排错时间。2.3 参数解析与分发引擎的边界分发引擎的边界很重要它只负责把参数正确传给适配器绝不在入口层做业务判断。有人会把任务逻辑的一部分放进tasks.yaml比如当参数是 x 时执行 y我强烈不建议这样做。配置一旦开始承担逻辑职责就会逐渐失控最后变成一门谁也看不懂的 DSL。我在早期版本里犯过这个错在 YAML 里加入了when和then条件分支结果配置文件比代码还复杂调试时要在两层逻辑之间来回切换。后来全部改回配置只描述参数和适配器业务逻辑全部进适配器代码问题立刻少了大半。分发引擎还有一个小细节值得强调参数解析完成后要统一注入一个上下文对象。这个对象至少包含全局参数比如--verbose、子命令参数、配置文件路径、当前工作目录。适配器函数签名统一接收这个上下文避免每个适配器各写一套参数获取逻辑。3. 接插件架构把脚本、API、LLM、文件操作都变成子命令要让 CLI-Anything 真正配得上 Anything 这个名字关键是实现一套能随时扩展的执行层。我把它叫做接插件架构每个能力单元就是一个适配器适配器之间互不感知只遵守同一个调用约定。3.1 适配器模式为什么用约定而不是继承适配器的核心约定很简单一个可导入的函数接收上下文对象返回执行结果。只要你愿意任何一段可以被 Python 调用的逻辑都可以成适配器。这意味着内部会统一规范外部能接入的方式非常灵活。我见过两种扩展方式一种是定义抽象基类要求所有适配器继承它并实现execute方法另一种是只约定函数签名。我最终选择了后者原因很实际继承会增加概念负担而且不够灵活。你写一个 Shell 脚本包装器和写一个 OpenAI API 调用包装器二者没有充分的共同抽象基础强行做一个统一基类最后基类里只能放日志这类边缘能力。约定函数签名的写法在 Python 里不需要额外引入接口类我只需要在文档里写清楚参数结构和返回值结构然后在装载时用callable()检查一下目标是不是函数就够了。3.2 动态加载目录扫描这里有一个真正的提升点任务可以不写死在tasks.yaml里还能按目录自动扫描注册。我实现的机制是启动时扫描~/.clia/tasks/目录下的所有 Python 文件读取每个模块里的register函数让它返回任务定义。这样每个任务自带注册信息增删任务时不需要改主配置文件目录里多一个文件就多一个命令。def autoload_plugins(plugin_dir: str): plugins {} plugin_path Path(plugin_dir) if not plugin_path.exists(): return plugins for py_file in plugin_path.glob(*.py): if py_file.name.startswith(_): continue spec importlib.util.spec_from_file_location(py_file.stem, py_file) module importlib.util.module_from_spec(spec) spec.loader.exec_module(module) if hasattr(module, register): task_name, task_def module.register() plugins[task_name] task_def return plugins每次启动时动态加载的代价是启动时间会随着插件数量增加而变长。实测下来几十个插件时影响很小基本在几十毫秒的量级完全感觉不到。但如果到了上百个插件我会建议做一层缓存把模块名和文件时间戳存下来文件没变就跳过重新导入。3.3 实际接插件示例Shell 脚本适配器、HTTP 请求适配器、文件操作适配器接口约定定了剩下的就是写适配器。我给三个最常见的场景各写了一个示例。第一个是 Shell 脚本适配器职责是安全地执行外部命令并捕获输出def run_shell(ctx): import subprocess cmd ctx.args.command shell ctx.args.shell or False result subprocess.run(cmd, shellshell, capture_outputTrue, textTrue, timeoutctx.args.timeout) if ctx.verbose: print(result.stdout) if result.returncode ! 0: raise RuntimeError(fCommand failed: {result.stderr}) return result.stdout注意我把shellTrue做成显式开关而不是默认值。原因后面会专门讲这里先说结论除非你真的需要管道符和通配符展开否则不要开启 Shell 模式这会直接影响安全性。第二个是 HTTP 请求适配器把某个接口封装成语义清晰的子命令。比如查服务健康状态def check_health(ctx): import requests url f{ctx.args.base_url}/health resp requests.get(url, timeoutctx.args.timeout) data resp.json() for name, status in data.get(services, {}).items(): print(f{name}: {status}) return data这段代码的价值在于把记住 health 接口地址这件事彻底省略了。团队里任何人只要敲clia health --base-url http://xxx就能得到格式化后的服务状态。第三个是文件操作适配器把繁琐的备份清理动作收敛成一个命令def prune_backups(ctx): import shutil from pathlib import Path backup_dir Path(ctx.args.backup_dir) keep ctx.args.keep backups sorted(backup_dir.glob(backup_*), reverseTrue) for old in backups[keep:]: shutil.rmtree(old) print(fRemoved {old})这个适配器看起来极其简单但实际用起来非常顺手。运维同学每天跑一次clia prune-backups --keep 7本质就是几条 Python 语句但配上统一入口后不需要每个人都记住这串 find 和 rm 组合。4. 权限、安全与错误处理CLI 工具最容易翻车的三件事我见过很多内部工具功能没问题但一碰到权限和错误处理就全露馅。CLI-Anything 在设计时把这三件事当成了和功能同等重要的模块。4.1 权限模型区分当前用户能做什么和这个命令要求什么不少 CLI 工具默认用当前用户身份直接执行命令这在个人电脑上问题不大但在服务器上就埋了雷。我的做法是给敏感命令增加require_role字段在任务配置里声明该命令最低需要的角色。例如deploy: require_role: operator然后在入口层加一道关卡读取当前用户所属角色如果角色不满足要求直接拒绝执行。角色判断优先级从高到低依次是管理员、操作员、普通用户。默认情况下只有只读类命令对普通用户开放。我刻意没有把权限做得很复杂没有引入 RBAC 系统也没有联数据库。因为 CLI-Anything 的定位是轻量工具不是身份认证平台。在真实环境里我一般用它配合已有的权限系统CLI 只做一次本地校验真正的安全边界仍然由目标服务器或云平台保证。4.2 敏感信息处理把密钥挡在配置和日志之外命令行工具有一个非常常见的漏洞就是密钥容易出现在三个地方进程参数、配置文件、日志输出。进程参数这个最隐蔽因为ps aux可以直接看到所有进程的完整命令行。如果你把密码放在--password xxx这样的参数里在同一台机器上有权限的人随时能看到。我在 CLI-Anything 里做了两个规定第一所有敏感参数优先从环境变量读取而不是从命令行参数读取第二日志输出时会自动过滤掉一组关键词对应的值。适配器如果需要密码统一从一个凭据存储模块里拿而不是从ctx.args里取。def get_secret(key: str) - str: value os.environ.get(key) if not value: # 还可以从系统的钥匙串读取 raise RuntimeError(fSecret {key} is not set) return value同时日志模块里维护一个敏感词表打印任何内容之前先做一次替换SENSITIVE_KEYS [password, token, secret, api_key] def safe_log(message: str) - str: for key in SENSITIVE_KEYS: placeholder f{key} message message.replace(key, placeholder) return message这层处理不复杂但能有效防止因为手滑把调试信息贴到聊天群里而引发的安全事故。有一次我在本地调试时适配器把完整的 API 请求头打到了终端里幸好敏感词过滤已经生效否则 token 就会顺着终端历史被带出去。4.3 错误分级与输出规范让失败现场可以被快速定位CLI 工具的输出不只是给人看的很多时候还要被 CI 系统、监控脚本和日志采集器消费。所以我把错误分成四级提示、警告、错误、致命。每级对应不同的退出码和输出格式。级别含义退出码输出位置INFO正常提示0stdoutWARNING可恢复但不建议忽略0stderrERROR操作失败但程序还能处理1stderrFATAL无法继续执行2stderr统一错误信息格式是一件收益很高的事情。我定义了错误输出模板[时间] [级别] [任务名] [错误码] [可读信息]。这样不管是看终端还是把日志灌进 ELK都能快速过滤出某任务最近失败的频率。耗时较长的任务建议在适配器内部使用进度反馈。我不推荐直接print一堆点号更好的做法是用\r刷新同一行显示进度百分比或者把进度写到临时文件由入口层统一读取展示。这样既不会刷屏也能在 CI 日志里留下可追踪的进度证据。5. 从零到一一个最小可用的 CLI-Anything 实现前面讲了整套设计思路现在我把完整的实现路径走一遍。你不用照抄但照着这个骨架走一遍就会明白每个模块为什么要存在。5.1 项目骨架与依赖准备我的项目结构如下cli-anything/ ├── clia.py # 入口 ├── tasks/ # 业务适配器 │ ├── __init__.py │ ├── sys_check.py │ ├── deploy.py │ └── report.py ├── core/ │ ├── __init__.py │ ├── config.py │ ├── dispatcher.py │ └── security.py ├── requirements.txt # 默认只依赖 pyyaml └── ~/.clia/tasks.yaml # 实际运行时使用的配置依赖我只保留了pyyaml。其他的都尽量用 Python 标准库。这样在任何一台有 Python 3.8 以上的机器上都能跑不需要先搭一套虚拟环境才能用。我见过太多内部工具埋在依赖泥潭里装个工具要先解决版本冲突这本身就违背了 CLI 工具的初衷。5.2 核心引擎代码首先看core/config.py负责加载并校验配置from pathlib import Path import yaml DEFAULT_CONFIG_PATH Path.home() / .clia / tasks.yaml class ConfigLoader: def __init__(self, path: Path DEFAULT_CONFIG_PATH): self.path path def load(self): if not self.path.exists(): raise FileNotFoundError(fConfig file not found: {self.path}) with open(self.path, r, encodingutf-8) as f: data yaml.safe_load(f) tasks data.get(tasks, {}) if not isinstance(tasks, dict): raise ValueError(tasks must be a mapping) return data然后是core/dispatcher.py负责按任务名找到适配器并执行import importlib import inspect class Dispatcher: def __init__(self, config: dict): self.config config def dispatch(self, task_name: str, args): task_def self.config[tasks].get(task_name) if not task_def: raise KeyError(funknown task: {task_name}) module_path task_def[adapter][module] function_name task_def[adapter][function] module importlib.import_module(module_path) fn getattr(module, function_name) if not callable(fn): raise TypeError(f{module_path}.{function_name} is not callable) # 统一把入口参数包成上下文对象 ctx SimpleNamespace(argsargs, verboseargs.verbose, configself.config) return fn(ctx)这里SimpleNamespace是一个很方便的工具它可以让你动态地给上下文挂属性不需要单独写一个 Context 类。实际上我在代码里还会挂一些别的属性比如当前用户、启动时间、日志对象等。5.3 第一个可运行命令环境检查我写了第一个适配器tasks/sys_check.py。它的功能很朴素查看当前系统的 CPU 负载、内存使用、磁盘占用然后按统一格式打印出来。import os import platform import shutil def run_check(ctx): host platform.node() print(fHost: {host}) print(fSystem: {platform.system()} {platform.release()}) # 计算 CPU 负载Linux 下取 /proc/loadavg if os.path.exists(/proc/loadavg): with open(/proc/loadavg) as f: fields f.read().split() load fields[0] print(fLoad: {load}) total, used, free shutil.disk_usage(/) gb 1024 ** 3 print(fDisk: total{total / gb:.1f}GB used{used / gb:.1f}GB free{free / gb:.1f}GB) return {host: host, disk_free_gb: round(free / gb, 2)}这时候只要在 YAML 里注册好任务运行流程就是python3 clia.py check --verbose从输入到输出的链路已经完整入口读配置、注册子命令、解析参数、Dispatch 到 adapter、执行并输出。这就是一个最小可用的 CLI-Anything。你可以在本地把它跑通然后尝试加第二个、第三个适配器。我建议第一个适配器选一个你每天都会手动敲的操作这样你才能真实感受到命令收敛带来的效率变化而不是停留在概念验证的层面。6. 用 CLI-Anything 改造日常工作流三个真实案例光有框架没有说话服力。我把自己日常工作中最常见的三件事完整地用 CLI-Anything 做了一遍每一个都持续跑了两个月以上。下面讲它们是怎么落地的。6.1 案例一多主机环境检查命令收敛以前检查一批服务器的负载、磁盘和关键服务状态我用的是一段循环 Shell 脚本每次都要改 IP 列表和用户名。后来我把主机清单挪到了 YAML 配置里把检查逻辑写进一个适配器hosts: web-01: 192.168.1.10 web-02: 192.168.1.11 db-01: 192.168.1.20适配器做的事情很简单遍历主机清单用 paramiko 或 sshpass 执行同一段远程脚本把结果汇总成表格。用 CLI 命令表达就是从打开编辑器改脚本、再 bash xxx.sh、再盯着输出翻页变成了clia hosts check --group web这个案例给我最大的启发是CLI-Anything 并不负责怎么远程执行命令这个技术难题它负责的是把已有的远程执行能力包装成语义清晰的命令。技术上没有任何新东西但使用体验完全变了。6.2 案例二自动化部署流程串接我们的部署流程包括构建、打包、上传、远程执行迁移、重启服务、健康检查六步。以前部署一次要开三个终端盯着好几个命令的输出。用 CLI-Anything 改造后我把每一步写成一个内层函数再用外层适配器把它们串起来def run_deploy(ctx): env ctx.args.env tag ctx.args.tag steps [ (build, build_image, {tag: tag}), (push, push_image, {tag: tag}), (migrate, run_migrations, {env: env}), (restart, restart_service, {env: env}), (health, health_check, {env: env, timeout: ctx.args.timeout}), ] for name, fn, params in steps: print(f {name}) fn(**params) print(Deploy finished.)每一步都可以单独被 CLI 子命令调用也可以在deploy里按顺序执行。这样既保留了细粒度的调试能力又提供了一条龙的一键入口。这里没有用任何任务编排框架只靠函数列表和 for 循环就解决了问题。如果你的部署流程只有几个步骤这完全可以替代一个重量级的 CI 流水线至少在日常开发和灰度阶段足够了。6.3 案例三定时生成日报和报表还有一个频率很高的工作是和业务方同步数据日报。以前我每天手动跑一段 SQL导出 CSV再复制到群里。我把这个动作做成了report daily命令并使用系统定时任务每天自动触发0 9 * * * cd /home/me python3 clia.py report daily --dateyesterday /var/log/clia/report.log 21适配器内部做的事情包括从数据库读数据、聚合计算、生成 Markdown 和 CSV、调用 Webhook 发送到指定群。整个过程没有任何人工干预。这个案例说明CLI-Anything 并不只服务交互式终端它同样适合无人值守场景。关键在于适配器要养成分工清晰的函数结构把读数据算指标生成文件发送消息拆成独立函数。这样你既能在 CLI 里一键跑全流程也可以只跑其中一段用于本地调试。这个习惯让我调试报表问题的速度提升了不少否则每次都要伪造完整上下文才能跑起来效率很低。7. 踩坑记录与调优心得任何项目做完后回头看真正有价值的东西都藏在坑里。我把 CLI-Anything 实现和运行过程中遇到的主要问题整理出来也把排查思路写出来能帮你少走一些弯路。7.1 跨平台问题同一个命令在 Windows 和 macOS 上的行为完全不同CLI 工具最大的隐性成本之一就是跨平台兼容。很多时候你写的时候用的是自己的电脑后来才发现同事的 macOS 和 Windows 上表现完全不同。比如subprocess.run([echo, hello])在 Windows 上可能正常但换一段grep命令就全线崩溃因为 Windows 默认没有 grep。我的经验是能用 Python 标准库完成的事情就不要借助外部命令。遍历文件用pathlib解析文本用字符串方法或正则压缩备份用shutil.make_archive远程执行则保留 ssh 作为显式依赖并在文档里写清楚前提条件。如果实在要用外部命令在适配器启动时先做一次依赖探测并输出友好提示。我还遇到过一个隐蔽问题Windows 上路径分隔符是反斜杠如果直接在适配器里拼接路径字符串很可能造出一个不存在的路径。统一做法是使用Path对象而不是字符串相加。这个习惯一旦养成就不会再犯。7.2 命令执行超时适配器卡死导致整个 CLI 悬挂有一次我还原一个数据库命令执行到一半连接断了适配器没有设置超时结果整个终端挂在那儿按 CtrlC 都没反应。后来我统一给所有可能执行外部命令的适配器加上了超时参数。subprocess.run有一个timeout参数它会在超时后抛出TimeoutExpired异常。正确做法是捕获它然后给出明确的错误提示而不是让异常直接冒到入口层摔出一个大堆栈。HTTP 请求那里也一样一定要设置连接超时和读超时。标准库requests的timeout参数支持传入一个元组比如(3.05, 30)第一个是连接超时第二个是读超时。初版我忘了设置结果某个 API 服务无响应时整个clia health命令要等默认的 120 秒才报错。加上超时之后反馈时间从两分钟降到了三秒体验差距非常大。7.3 输出格式化的细节机器可读比人眼漂亮更重要CLI 工具的输出很容易被忽略但它其实是不可妥协的部分。我一开始用各种颜色高亮和表格框线看起来挺炫但当我想把结果用管道传给jq或写入监控系统时发现解析这些带颜色的文本非常麻烦。后来我定了一条规矩命令默认输出人类可读的纯文本不加颜色不加艺术边框如果需要在脚本环境下消费增加--output json选项把所有关键数据以 JSON 格式输出。颜色高亮只在终端连接的情况下自动开启。这样兼顾了交互体验和可编程性。还有一个和输出强相关的问题是流和缓冲。当 CLI 命令被 CI 系统调用时Python 的 stdout 默认是块缓冲而不是行缓冲导致日志没有及时刷新CI 日志里看起来好像命令卡住了。解决办法是运行时加上python3 -u或者在代码里调用sys.stdout.reconfigure(line_bufferingTrue)。这个细节卡了我半天很多人没意识到。7.4 性能优化延迟导入和缓存CLI 命令对启动速度很敏感。如果每次执行都要等三两秒人的耐心很快就会被磨掉。我刚接入十几个适配器时入口模块一股脑导入了所有依赖导致clia --help都要等一秒多。优化方案是延迟导入只有在真正执行某个适配器时才去importlib加载对应的模块。另一个优化点是重复读取配置。早期每次子命令执行都要重新读一遍 YAML虽然单次开销不大但批量循环调用时会被放大。我用一个简单的模块级缓存文件时间戳没变就直接用内存里的配置对象。这也让整套系统在循环调用和定时任务场景下稳定了不少。还有一个容易忽略的性能问题插件扫描时如果某个插件模块有段落代码执行延迟会拖慢整个启动流程。为此我在检测插件时增加了一个超时保护超过阈值就视作插件加载失败并打印警告。毕竟 CLI 工具不能因为一个边缘插件的问题而让所有命令都瘫痪。技术方向和使用层面的事到这里基本已经完整了。回到最开始那个问题——为什么我要维护这样一个命令行统一入口。我的实际感受是它带来的最大收益不是省了几次敲键盘的时间而是把我脑子里那堆怎么操作某个工具的记忆清单卸载掉了。系统变得复杂时人需要的不只是更多工具而是一个更少心智负担的统一入口。CLI-Anything 就是我的那个入口。每次打开终端我只需要知道一个词clia。剩下的交给任务清单和适配器去解决。
网站建设高端定制企业官网