news 2026/7/2 1:46:07

moby-dockerd-启动流程详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
moby-dockerd-启动流程详解
┌────────────────────────────────────────────────────────────────────────┐ │ 用户在 shell 里敲: $ dockerd │ └──────────────────────────────┬─────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────────────┐ │ cmd/dockerd/main.go: main() │ │ 1. reexec.Init() ← 判断是不是被"自身重执行"拉起的子进程 │ │ 2. signal.Ignore(SIGPIPE) │ │ 3. term.StdStreams() │ │ 4. command.NewDaemonRunner(stdout, stderr) ────┐ │ │ 5. r.Run(ctx) │ │ └────────────────────────────────────────────────────┼───────────────────┘ │ ┌────────────────────────────────────┘ ▼ ┌────────────────────────────────────────────────────────────────────────┐ │ daemon/command/docker.go: NewDaemonRunner / daemonRunner.Run │ │ - 设置日志格式 (text) │ │ - initLogging │ │ - newDaemonCommand() → cobra 命令树 + 注册 flag │ │ - configureGRPCLog │ │ - cmd.ExecuteContext(ctx) ────┐ │ └─────────────────────────────────┼──────────────────────────────────────┘ │ ┌─────────────────┘ ▼ ┌────────────────────────────────────────────────────────────────────────┐ │ daemon/command/docker.go: cobra RunE 闭包 │ │ - newDaemonCLI(opts): 合并默认值 + daemon.json + flag │ │ - if --validate: 打印 "configuration OK" 返回 │ │ - runDaemon(ctx, cli) ────┐ │ └──────────────────────────────┼────────────────────────────────────────┘ │ ┌──────────────┘ ▼ ┌────────────────────────────────────────────────────────────────────────┐ │ daemon/command/docker_unix.go (Linux/macOS) / docker_windows.go │ │ runDaemon → cli.start(ctx) │ └──────────────────────────────┬─────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────────────────────────────────────────┐ │ daemon/command/daemon.go: cli.start() ★ 主戏台 ★ │ │ 13 个阶段(详见第 4 章) │ │ 阻塞在 httpServer.Serve,等信号 │ └────────────────────────────────────────────────────────────────────────┘

第 1 章 整体架构:三层洋葱

dockerd启动逻辑的代码组织,可以想成三层洋葱,从外向内逐层装配:

文件位置

角色

行数级别

进程层

cmd/dockerd/main.go

进程入口、信号屏蔽、reexec 判定

~40 行

CLI 装配层

daemon/command/docker.go

cobra 命令树、flag 注册、Runner 接口

~150 行

服务层

daemon/command/daemon.go

真正的 daemon 启动流程(13 阶段)

~300+ 行

设计原则

1. 入口保持极简。Moby 故意把main.go控制在几十行内:只做"进程级最小准备"(信号、终端、reexec),然后立刻交给daemon/command包。这让入口逻辑便于跨平台、便于测试。

2. 装配与执行分离。NewDaemonRunner()只负责"装配"(构造 cobra 对象),不执行任何业务。执行发生在调用r.Run(ctx)时。这种分离让 main.go 可以用统一接口(Runner)启动 daemon,便于在测试里替换。

3. "一个二进制 + 多种执行模式"。Docker 通过reexec.Init()实现这个技巧:同一个dockerd二进制,可以作为主 daemon 启动,也可以作为容器内的 init 进程、或作为 runc 调用者被自己拉起。判据就是argv[0]或环境标记。

4. 业务逻辑与可执行入口解耦。真正复杂的启动逻辑(cli.start())写在daemon/command包里,不写在cmd/dockerd下。这意味着同样一份启动代码可以被测试代码、其他工具复用,不需要 fork 整个 main。

第 2 章 进程入口:main.go

位置cmd/dockerd/main.go

func main() { if reexec.Init() { return } // [1] 自身重执行判定 ctx := context.Background() // [2] 根 context signal.Ignore(syscall.SIGPIPE) // [3] 屏蔽 SIGPIPE _, stdout, stderr := term.StdStreams() // [4] 终端适配 r, err := command.NewDaemonRunner(stdout, stderr) // [5] 装配 Runner if err != nil { /* ... os.Exit(1) */ } if err := r.Run(ctx); err != nil { // [6] 执行 /* ... os.Exit(1) */ } }

关键点逐条解释

[1]reexec.Init()—— 自身重执行机制

这是 Moby 自己的github.com/moby/sys/reexec包提供的。

为什么需要?Docker 在容器生命周期中会"把自己作为子进程再执行一次",比如:

  • 容器 init 进程(容器内的 PID 1 由 dockerd 派生)
  • 某些 runc 调用路径
  • 嵌套容器场景

怎么区分身份?通过argv[0](程序名)来标记。每次 fork+exec 自己时设置一个特殊名字,子进程启动时调用reexec.Init(),它会用argv[0]去查注册表:

  • 命中已注册的子命令 → 执行它,返回truemain直接return不走 daemon 启动流程
  • 没命中(用户直接敲dockerd)→ 返回false,继续后面。

这就是为什么 main 的第一行就是它:必须最早判断,否则后面创建文件、绑定端口等动作都不对。

[2] 顶层 context

context.Background()作为整个 daemon 的根 context。这里没有显式 cancel 或超时——真正接管它的是后续 cobra 的ExecuteContext,再后面由cli.start派生出多个子 context 给后台 goroutine。

[3] 屏蔽 SIGPIPE

注释里的 issue #19728:当 dockerd 在 systemd 下运行、journald重启时,往已关闭的日志管道写会触发 SIGPIPE,默认处理是终止进程。这会让 dockerd 被无辜干掉,所以这里signal.Ignore掉。

[4] 终端适配

term.StdStreams()在 Windows 上做 ANSI 转义到 Win32 控制台的转换;Unix 上几乎透传。返回的 stdout/stderr 后面用作日志和错误输出。

[5][6] 装配 + 执行
NewDaemonRunner(stdout, stderr) → Runner 接口 r.Run(ctx) → 真正启动

为什么用接口而不直接*cobra.Command解耦。main.go不直接依赖 cobra,便于在测试里 mock 一个 Runner。

错误处理为什么用fmt.Fprintln + os.Exit(1)而不是log.Fatal因为此时日志系统可能还没初始化(NewDaemonRunner内部才初始化)。

代码索引

函数/符号

文件

main()

cmd/dockerd/main.go

Windows 资源嵌入

cmd/dockerd/main_windows.go

reexec.Init()实现

vendor/github.com/moby/sys/reexec/


第 3 章 CLI 装配层:docker.go

位置daemon/command/docker.go

这一层的产物是一个 cobra 命令对象。它做完三件事就把控制权交还给 main:

NewDaemonRunner() ──▶ 设置日志格式 ──▶ initLogging(把 logger 接到 stderr/stdout) ──▶ newDaemonCommand() ← cobra 命令树 + flag 注册

返回的Runner是个包装了*cobra.CommanddaemonRunner结构。

3.1newDaemonCommand做了什么

cmd := &cobra.Command{ Use: "dockerd [OPTIONS]", RunE: func(cmd, args) error { cli, err := newDaemonCLI(opts) // ← 合并配置 if opts.Validate { return nil } // ← --validate 模式 return runDaemon(ctx, cli) // ← 进入下一层 }, } SetupRootCommand(cmd) flags := cmd.Flags() opts.installFlags(flags) // 注册 --debug / --host / TLS 等 installConfigFlags(opts.daemonConfig, flags) // 把 daemon.json 字段也作为 flag 暴露 installServiceFlags(flags) // Windows 服务相关

cobra 的RunE闭包是关键——它定义了"用户敲 dockerd 之后到底执行什么"。注意它捕获了opts,这是 flag 注册和执行之间共享数据的桥梁。

3.2 配置三层合并

newDaemonCLI(opts)里调用的loadDaemonCliConfig实现了 Moby 的"配置三层合并":

默认值 (config.New()) │ 被覆盖 ▼ daemon.json (--config-file, 默认 /etc/docker/daemon.json) │ 被覆盖 ▼ 命令行 flag (最高优先级)

为什么这么设计?三层都允许配置同一件事,让运维既能写默认配置文件,又能在调优时临时用 flag 覆盖。Moby 把所有 daemon.json 字段都镜像成了 flag(installConfigFlags),用户两种风格都能用。

--validate模式值得一提:它只是校验配置文件能否正确解析(类似nginx -t),打印 "configuration OK" 后退出,不启动 daemon。这是给运维和 CI 用的安全网。

3.3daemonRunner.Run—— 执行入口

func (d daemonRunner) Run(ctx context.Context) error { configureGRPCLog(ctx) // 抑制 grpc 的噪声日志 return d.ExecuteContext(ctx) // cobra 接管 }

ExecuteContext是 cobra 的方法,它会:

  1. 解析os.Args
  1. 触发对应命令的RunE
  1. ctx传下去

代码索引

函数

文件

NewDaemonRunner

daemon/command/docker.go

newDaemonCommand

daemon/command/docker.go

newDaemonCLI

daemon/command/daemon.go

loadDaemonCliConfig

daemon/command/daemon.go

daemonRunner.Run

daemon/command/docker.go

configureGRPCLog

daemon/command/grpclog.go

installFlags(flag 注册)

daemon/command/options.go


第 4 章 真正的启动:cli.start 的 13 个阶段

位置daemon/command/daemon.go中的(*daemonCLI).start()

这是整个启动流程的重头戏,~300 行的方法。按执行顺序划分为 13 个阶段。每一阶段的"做什么 / 为什么这一步在这里"如下。

流程速查表

阶段

做什么

关键产物 / 副作用

1

启动前置检查 + 环境/日志配置

内核/cgroup 自检;日志格式设置

2

文件系统准备

/var/lib/docker/var/run/docker、PID 文件

3

建立 API 监听器

每个-H一个net.Listener

4

containerd 初始化

复用系统 containerd 或自起一个

5

HTTP Server 框架 + 信号 Trap

*http.Servercli.stop协调机制

6

可观测性(OTel)

tracer provider、systemd notify

7

插件与设备(CDI/GPU)

CDI driver、GPU hooks

8

API 中间件

experimental / version / authz

9

核心:daemon.NewDaemon

容器/镜像/网络/卷状态机全部还原

10

metrics + Swarm 集群

/metrics端点、Swarm Raft

11

BuildKit 初始化

builder backend

12

组装 HTTP 路由 + Handler

REST 路由 + gRPC + httpServer.Handler

13

实际对外服务 + 等待关闭

阻塞在apiWG.Wait()

阶段 1:启动前置检查 + 环境/日志配置

daemon.CheckSystem() // 内核 / cgroup / OS 版本检查 configureProxyEnv(...) // 把 daemon.json 里的代理设置写回环境变量 configureDaemonLogs(...) // 设置日志格式 (text/json) 和级别

这一阶段还做几个"轻量但致命"的检查:

  • --debug模式开启内置 debug 服务器(pprof)
  • RootlessKit 自检(如果检测到 RootlessKit 但配置没开 rootless,直接报错)
  • Linux 上:非 root 又不在 rootless 模式 → 友好错误
  • 重置 umask,避免从父进程继承到奇怪的掩码

为什么把日志配置放在这么早?后面所有步骤都依赖日志能正常输出。

阶段 2:文件系统准备

daemon.CreateDaemonRoot(cli.Config) // /var/lib/docker,设 ACL(Windows 尤为重要) os.MkdirAll(cli.Config.ExecRoot, 0o700) // /var/run/docker if cli.Pidfile != "" { pidfile.Write(cli.Pidfile, os.Getpid()) // PID 文件 defer os.Remove(cli.Pidfile) // 退出时清理 }

注意顺序:CreateDaemonRoot必须在所有其他文件创建之前做,因为 Windows 上要给目录设 ACL。

PID 文件的作用是给 systemd 之类的进程管理器追踪 dockerd,也防止 dockerd 多开(启动时会失败)。

阶段 3:建立 API 监听器

lss, hosts, err := loadListeners(cli.Config, cli.apiTLSConfig)

为每个-H选项创建对应的监听器:

  • unix:///var/run/docker.sock→ Unix domain socket
  • tcp://0.0.0.0:2375→ TCP(如果没启用 TLS,会输出大量安全告警 + 强制 sleep 15s 防呆)
  • npipe:////./pipe/docker_engine(Windows)→ Named pipe

TCP 没 TLS 时为什么会强制 sleep?因为这是一个严重的安全风险——任何能访问该端口的人都能拿到 root 权限。Moby 用这种方式强迫用户注意到这个问题。

阶段 4:containerd 初始化

ctx, cancel := context.WithCancel(ctx) waitForContainerDShutdown, err := cli.initContainerd(ctx) defer cancel()

initContainerd的策略:

  • 检测系统的/run/containerd/containerd.sock是否存在
  • 存在 →直接复用,不另起
  • 不存在 →supervisor.Start把 containerd 作为子进程拉起

返回的waitForContainerDShutdown是个关闭函数,defer在 daemon 退出时调用,给 containerd 10 秒优雅退出。

阶段 5:HTTP Server 框架 + 信号 Trap

这一步创建了几个关键的协调原语:

httpServer := &http.Server{ReadHeaderTimeout: 5 * time.Minute} // 防 Slowloris trap.Trap(cli.stop) // SIGINT/SIGTERM → cli.stop() go func() { <-cli.apiShutdown // 等 cli.stop() 触发 httpServer.Shutdown(apiShutdownCtx) close(apiShutdownDone) }()

cli.stop()的实现:

func (cli *daemonCLI) stop() { cli.stopOnce.Do(func() { close(cli.apiShutdown) }) }

幂等(stopOnce保护)—— 即使被多次调用也只 close 一次。这个机制贯穿整个关闭流程,第 5 章会详细讲

注意:这一步只创建http.Server骨架,Handler 在阶段 12 才填。中间这一段时间(阶段 6-11)服务器还不会响应请求。

阶段 6:可观测性(OpenTelemetry)

preNotifyReady() // sd_notify: 还在启动中 setOTLPProtoDefault() // OTLP 协议默认改 http/protobuf otel.SetTextMapPropagator(...) // W3C TraceContext + Baggage tp, otelShutdown := otelutil.NewTracerProvider(...) otel.SetTracerProvider(tp) log.G(ctx).Logger.AddHook(tracing.NewLogrusHook()) // 日志 ↔ trace 关联 opencensus.InstallTraceBridge() // hcsshim 用的是 OpenCensus,桥接过来

为什么有opencensus.InstallTraceBridge?因为 Windows 的 hcsshim 库还用着老的 OpenCensus API,而 daemon 主线用 OpenTelemetry,需要桥接才能让两边的 trace 串起来。

阶段 7:插件与设备(CDI / GPU)

pluginStore := plugin.NewStore() if cdiEnabled(cli.Config) { cdiCache = daemon.RegisterCDIDriver(cli.Config.CDISpecDirs...) } daemon.RegisterGPUDeviceDrivers(cdiCache)

CDI(Container Device Interface)必须在daemon.NewDaemon之前注册——否则还原依赖 CDI 设备的容器会失败(比如带 GPU 的容器)。GPU 驱动 hooks 也是同理。

阶段 8:API 中间件

authz, err := initMiddlewares(ctx, &apiServer, cli.Config, pluginStore) cli.authzMiddleware = authz

注册三个中间件:

  • Experimental:实验特性网关
  • Version:在/version返回的版本信息注入
  • Authorization:鉴权插件链(可热重载)

authz句柄保存到cli是为了SIGHUP热重载配置时能更新插件列表。

阶段 9:核心 ——daemon.NewDaemon

d, err := daemon.NewDaemon(ctx, cli.Config, pluginStore, cli.authzMiddleware) d.StoreHosts(hosts) validateAuthzPlugins(...) cli.d = d

整个文件最重的一行NewDaemon内部会:

  • 加载镜像层存储(layerDB 或 containerd snapshotter)
  • 还原所有现存容器状态(从/var/lib/docker/containers/读元数据)
  • 初始化网络控制器(bridge / overlay / macvlan / ipvlan ...)
  • 初始化卷驱动(local / NFS / 卷插件)
  • 加载已启用的插件
  • 启动 healthcheck / events / stream 等后台 goroutine

validateAuthzPlugins必须在 NewDaemon之后做,因为这时插件才被还原到pluginStore

阶段 10:metrics + Swarm 集群

startMetricsServer(cfg.MetricsAddress) // Prometheus /metrics 端点 c, err := createAndStartCluster(d, cfg) // Swarm 集群(Raft) d.RestartSwarmContainers() // 重启依赖 Swarm endpoint 的自启动容器

createAndStartCluster启动 Swarm 的 Raft、manager、worker 角色。即使节点不在 Swarm 模式下,cluster 对象也会被创建(处于 inactive 状态)。

阶段 11:BuildKit 初始化

b, shutdownBuildKit, err := initBuildkit(ctx, d, cdiCache)

initBuildkit做四件事:

  1. session.NewManager()—— 镜像构建会话管理(docker build上下文传输用)
  1. dockerfile.NewBuildManager—— 经典 Dockerfile 解析器
  1. buildkit.New(...)—— 集成 BuildKit(更强的构建引擎,可并行、缓存友好)
  1. buildbackend.NewBackend—— 把上面两个统一封装

返回的shutdownBuildKit在函数尾部 defer 调用,确保 daemon 关闭时 BuildKit 也优雅退出。

阶段 12:组装 HTTP 路由 + gRPC + Handler

var p http.Protocols p.SetHTTP1(true); p.SetHTTP2(true); p.SetUnencryptedHTTP2(true) routers := buildRouters(routerOptions{daemon: d, cluster: c, builder: b, ...}) gs := newGRPCServer(ctx) b.backend.RegisterGRPC(gs) httpServer.Handler = newHTTPHandler(ctx, gs, apiServer.CreateMux(ctx, routers...)) go d.ProcessClusterNotifications(ctx, c.GetWatchStream()) cli.setupConfigReloadTrap() // SIGHUP → reloadConfig

buildRouters注册了完整的 REST API 表:

Router

路径前缀

对应功能

container

/containers

容器生命周期

image

/images

镜像管理

system

/system,/info,/version

系统信息

volume

/volumes

卷管理

build

/build

镜像构建

swarm

/swarm

Swarm 集群管理

network

/networks

网络管理

plugin

/plugins

插件管理

distribution

/distribution

registry 交互

checkpoint

/containers/{id}/checkpoints

容器检查点

debug

/debug

debug 端点(pprof)

每个 router 对应一个daemon/server/router/<name>包。

setupConfigReloadTrap让用户能通过SIGHUP信号热重载daemon.json的部分配置项(不会重启 daemon)。

阶段 13:实际对外服务 + 等待关闭

apiStartWG.Add(len(lss)) for _, ls := range lss { apiWG.Go(func() { log.G(ctx).Infof("API listen on %s", ls.Addr()) apiStartWG.Done() httpServer.Serve(ls) // 阻塞 }) } apiStartWG.Wait() // 等所有 listener 就绪 notifyReady() // sd_notify READY=1(systemd) apiWG.Wait() // ★★★ 主阻塞点 ★★★

apiWG.Wait()是 daemon 的"主阻塞点"——dockerd 进程正常运行期间就停在这里。直到所有httpServer.Serve调用返回(即httpServer被关闭)才继续往下走。

notifyReady()的意义:告诉 systemd "我准备好了",systemd 才会认为服务启动成功。


第 5 章 信号处理与优雅关闭

优雅关闭是个独立的话题,值得单独讲一章。整个机制的核心是cli.stop()+cli.apiShutdownchannel。

关闭触发路径

用户按 Ctrl+C 或 systemctl stop docker │ ▼ 内核发送 SIGINT / SIGTERM │ ▼ trap.Trap 注册的处理器被调用 → cli.stop() │ ▼ cli.stopOnce.Do(close(cli.apiShutdown)) ← 幂等 │ ▼ 阶段 5 起的后台 goroutine 收到 <-cli.apiShutdown 信号 │ ▼ httpServer.Shutdown(ctx) ← 优雅关闭:处理完手上的请求再退 │ ▼ 所有 httpServer.Serve(ls) 返回 http.ErrServerClosed │ ▼ apiWG.Wait() 解除阻塞 │ ▼ 进入关闭流程(c.Cleanup → shutdownDaemon → shutdownBuildKit → cancel → otelShutdown) │ ▼ return nil → main.go 退出

关闭顺序为什么是这样

步骤

为什么这个顺序

先关 HTTP Server

拒绝新请求,避免关闭过程中又产生新工作

再关 Swarm cluster

cluster 会触发容器调度,要在 API 关闭后做

再关 daemon (shutdownDaemon)

停止容器、清理网络、卸载卷

再关 BuildKit

BuildKit 依赖 daemon 的镜像服务,必须在 daemon 之后

最后 cancel ctx + otelShutdown

取消所有后台 goroutine,flush trace 数据

shutdownDaemon自身带超时:

func shutdownDaemon(ctx context.Context, d *daemon.Daemon) { timeout := d.ShutdownTimeout() ctx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) go func() { defer cancel(); d.Shutdown(ctx) }() <-ctx.Done() if errors.Is(ctx.Err(), context.DeadlineExceeded) { log.G(ctx).Error("Force shutdown daemon") // 超时强制结束 } }

防止某个容器拒绝退出导致整个 daemon 卡死。

defer 链

cli.start注册了多个 defer,按 LIFO 顺序执行:

defer otelShutdown(...) ← 最后执行 defer cancel() defer shutdownBuildKit() (shutdownDaemon 显式调用,不是 defer) defer pidfile.Remove(...) defer waitForContainerDShutdown(10s) defer httpServer.Close()/Shutdown()

设计上很巧妙:即使 daemon 在阶段 9 失败退出,前面阶段注册的清理 defer 也会按相反顺序触发,不会泄露资源。


第 6 章 跨平台差异

dockerd同时支持 Linux / macOS / Windows,但实现细节有差异。

6.1 文件级差异

功能

Linux/macOS

Windows

入口资源嵌入

main.go

main.go+main_windows.go(嵌入图标等资源)

runDaemon

docker_unix.go

docker_windows.go(多一层 SCM 服务处理)

initLogging

docker_unix.go(输出到 stderr)

docker_windows.go(输出到 stdout + ETW hook)

平台特定选项

daemon_unix.gosetDefaultUmask、cgroup 等)

daemon_windows.go

6.2 Windows 的服务模式

docker_windows.gorunDaemon多了initService步骤:

stop, runAsService, err := initService(ctx, cli) if stop { return nil } // 注册/注销服务后立即退出 if runAsService { cli.Config.Pidfile = "" } // SCM 托管时不写 PID err = cli.start(ctx)

支持三种用法:

  1. dockerd --register-service→ 注册 Windows 服务后立刻退出
  1. dockerd --unregister-service→ 注销服务后立刻退出
  1. 由 SCM 启动的服务模式 → 正常跑 daemon,但日志走事件日志

6.3 监听器差异

协议

Linux

Windows

默认监听

unix:///var/run/docker.sock

npipe:////./pipe/docker_engine

TCP

都支持

都支持

Unix socket

Named pipe

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/2 1:43:18

OpenAI-compatible API 接入前必须检查的 5 个配置

为什么只改 base URL 还会报错 很多 OpenAI-compatible API 接入问题&#xff0c;不是 SDK 不能用&#xff0c;而是 base URL、API Key 和模型 ID 来自不同平台。 接入前的 5 项检查 检查 base URL&#xff1a;确认协议、域名以及 /v1 路径完整。检查 API Key&#xff1a;必须使…

作者头像 李华
网站建设 2026/7/2 1:42:21

Mega安汇:长期观察者更在意的信息透明度,这里做个逻辑盘点

对多数外汇相关用户来说&#xff0c;判断平台并不需要复杂术语&#xff0c;关键在于信息能否被快速理解、关键提示是否容易找到、服务体验是否稳定一致。以Mega安汇为例&#xff0c;这里聚焦这些更贴近实际使用的亮点与细节。外汇相关信息更新频繁&#xff0c;平台将关键提示与…

作者头像 李华
网站建设 2026/7/2 1:40:30

无锡幼小衔接哪家靠谱

对于即将步入小学的孩子来说&#xff0c;幼小衔接是成长中关键的一步。很多家长都在问&#xff1a;无锡幼小衔接哪家靠谱&#xff1f; 在经开区&#xff0c;有一家以“因材施教&#xff0c;助力成长”为理念的本地机构——童乐托管&#xff0c;正凭借精细化服务和科学衔接课程&…

作者头像 李华
网站建设 2026/7/2 1:39:14

匿名内部类和实验四

public class AnonyDemo { public static void main(String}TOC 实验四 Java Swing 小学生算术练习 难度选择&#xff08;简单整数/中等小数/进阶分数&#xff09;随机四则运算题目&#xff08; - * /&#xff09;输入答案点击确认/回车按键校验对错实时统计正确/错误数量界面…

作者头像 李华