Apache Tomcat 是应用最广泛的 Java Servlet 容器,其 Manager 管理控制台提供服务器状态监控、Web 应用部署管理、日志查看等核心运维能力。本文记录在鸿蒙 PC 平台上基于 Electron 壳方案实现 Tomcat Manager 控制台的完整过程——与常见的"界面模拟"方案不同,本次适配在 Electron 主进程内嵌了一个真实运行的 HTTP 服务器:真实端口监听、真实请求统计、真实 JSESSIONID 会话跟踪,通过 Manager 界面部署的应用拥有真实路由,可以用设备自带浏览器直接访问。

欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_tomcat

一、技术架构分析

1.1 平台限制与方案选型

Apache Tomcat 本体是 Java 应用,运行必须依赖 JVM。鸿蒙 PC(ARM aarch64 架构)上目前没有可用的 OpenJDK 运行时,直接移植 Tomcat 二进制不可行。可选方案对比如下:

方案思路可行性
真实移植在设备上运行 OpenJDK + Tomcat不可行,无 aarch64 鸿蒙 JVM
远程连接Manager 界面连接局域网内真实 Tomcat依赖外部服务器环境,无网络时不可用
界面模拟本地 JSON 数据 + 随机数模拟指标界面可用,但所有数据都是假的
内嵌 HTTP 服务器主进程用 Node 内置 http 模块起真实服务本文方案

本文方案的关键决策:Electron 主进程本身就是 Node.js 环境,天然具备 http.createServer 能力。与其用 Math.random 伪造 JVM 内存曲线,不如把"服务器"做真——UI 看到的每一个数字,都来自真实的 HTTP 请求与真实的进程运行时。方案边界同样明确:HTTP 服务层是真实的(端口、路由、会话、统计、日志),但 Servlet 容器语义(WAR 解压、JSP 编译)不在范围内,服务标识中明确标注 Manager on Node.js。

1.2 整体架构

┌──────────────────── 鸿蒙 PC(Electron 壳应用) ────────────────────┐
│                                                                    │
│   渲染进程(Manager UI)                    主进程(Node.js)        │
│   ┌────────────────────┐   HTTP 请求   ┌──────────────────────┐   │
│   │ 服务器状态 / 应用管理 │ ──────────▶ │  server.js           │   │
│   │ 部署 / 日志 / 信息   │ ◀── JSON ── │  真实 HTTP 服务器      │   │
│   └────────────────────┘               │  监听 127.0.0.1:8080  │   │
│                                        │  · Manager JSON API  │   │
│   设备自带浏览器                          │  · 应用真实路由        │   │
│   ┌────────────────────┐   HTTP 请求   │  · JSESSIONID 会话    │   │
│   │ http://127.0.0.1   │ ──────────▶ │  · 真实统计与访问日志   │   │
│   │    :8080/myapp     │ ◀─ HTML 页 ─└──────────────────────┘   │
│   └────────────────────┘                        ▲                   │
│                                     IPC(端口查询/重启)              │
│                                     └── 渲染进程 ◀──┘               │
└────────────────────────────────────────────────────────────────────┘

三条通道各司其职:

  • HTTP 通道(渲染进程 → 服务器):状态轮询、应用操作、部署、日志、停止服务器
  • HTTP 通道(设备浏览器 → 服务器):访问已部署应用的真实路由,与 Manager UI 看到的是同一个真实服务
  • IPC 通道(渲染进程 → 主进程):查询实际监听端口;服务器停止后 HTTP 通道已关闭,重启必须走本地 IPC

1.3 Manager JSON API 设计

HTTP 方法路径用途
GET/manager/api/status聚合状态(内存/统计/会话/应用列表)
GET/manager/api/logs?level=INFO访问日志(支持级别过滤)
POST/manager/api/apps部署应用(服务端校验 + 注册真实路由)
POST/manager/api/apps/stop停止应用
POST/manager/api/apps/start启动应用
POST/manager/api/apps/reload重载应用
POST/manager/api/apps/undeploy卸载应用
POST/manager/api/logs/clear清空日志
POST/manager/api/server/stop停止服务器(真实关闭监听)
GET/ 及任意 Context Path已部署应用的真实路由(欢迎页)

1.4 核心功能清单

功能模块具体能力
服务器状态真实进程内存(V8 堆/RSS)、当前并发、累计请求、错误数、流量字节数、活动会话、事件循环延迟
应用管理部署/启动/停止/重载/卸载、关键字搜索、真实请求计数与真实会话计数
部署应用服务端校验、重名 Context Path 先卸载再部署、部署即注册真实 HTTP 路由
服务器信息真实运行时(Node/Electron/V8/操作系统/PID/监听地址)
日志查看真实访问日志(方法/路径/状态码/字节/耗时)+ 级别过滤 + 生命周期事件
服务器启停HTTP 通道真实停止(连接被拒绝)+ IPC 通道真实重启(重新 listen)
持久化应用列表/日志/统计数据落盘 JSON,重启后恢复

二、环境准备

2.1 开发环境要求

项目版本/信息
操作系统Windows 10/11
核心框架Electron (Node.js + Chromium)
技术栈HTML5/CSS3/Vanilla JavaScript + Node 内置 http 模块
依赖项零外部依赖(http/fs/path/os/url/v8/perf_hooks 均为 Node 内置)
目标设备鸿蒙 PC
目标架构arm64-v8a
开发工具DevEco Studio(鸿蒙官方 IDE)

2.2 项目结构

ohos_hap/
├── electron-apps/
│   └── Tomcat/                        # Tomcat Manager 源码(开发目录)
│       ├── main.js                     # 主进程(窗口 + 启动服务器 + 控制 IPC)
│       ├── server.js                   # 真实 HTTP 服务器(核心模块)
│       ├── renderer.js                 # 渲染进程(HTTP 客户端 + 5 视图渲染)
│       ├── index.html                  # HTML 页面结构
│       ├── package.json                # 项目配置
│       └── styles/
│           └── tomcat.css              # Catppuccin Mocha 深色主题
├── web_engine/                         # 鸿蒙 web_engine 模块
│   └── src/main/resources/
│       └── resfile/resources/app/      # 部署目录(构建时打包进 HAP)
└── electron/                           # Electron 原生库
    └── libs/arm64-v8a/
        ├── libelectron.so              # Electron 核心库
        ├── libadapter.so               # 鸿蒙适配层库
        └── libffmpeg.so                # 多媒体库

开发流程:在 electron-apps/Tomcat/ 中开发,每次修改后同步到 web_engine/src/main/resources/resfile/resources/app/ 部署目录。

三、主进程适配(main.js)

主进程职责收敛为三件事:创建窗口、启动内嵌 HTTP 服务器、提供本地控制 IPC。数据读写全部下沉到 server.js,主进程不再碰业务。以下为完整真实代码:

// Apache Tomcat Manager - 主进程
// 职责:创建窗口 + 启动内嵌真实 HTTP 服务器(server.js)+ 本地控制 IPC
// 网络通道:HTTP(127.0.0.1:port/manager/api/*)
// 本地控制通道:IPC(端口查询、服务器重启——服务器停止后 HTTP 不可用)
const { app, BrowserWindow, ipcMain, screen } = require('electron');
const path = require('path');
const { createTomcatServer } = require('./server');

// 防 GPU 白屏:禁用硬件加速
app.disableHardwareAcceleration();

let mainWindow = null;
let tomcat = null;

function getDataFilePath() {
  return path.join(app.getPath('userData'), 'tomcat-manager-data.json');
}

// 创建窗口
function createWindow() {
  try {
    const display = screen.getPrimaryDisplay();
    const { width, height } = display.workAreaSize;

    // 防 XComponent 崩溃:frame: true + transparent: false + resizable: true
    mainWindow = new BrowserWindow({
      width: Math.floor(width * 0.9),
      height: Math.floor(height * 0.85),
      frame: true,
      transparent: false,
      resizable: true,
      webPreferences: {
        nodeIntegration: true,
        contextIsolation: false
      }
    });

    mainWindow.loadFile('index.html');
  } catch (e) {
    console.warn('[Tomcat] 创建窗口失败:', e.message);
  }
}

app.whenReady().then(() => {
  // 启动真实 HTTP 服务器(8080 优先,占用自动递增)
  tomcat = createTomcatServer({ dataFile: getDataFilePath() });
  tomcat.start(function(port) {
    console.log('[Tomcat] Manager HTTP server listening on 127.0.0.1:' + port);
  });
  createWindow();
});

app.on('window-all-closed', () => {
  if (tomcat) tomcat.stop();
  app.quit();
});

// ========== 本地控制 IPC ==========

// 渲染进程查询服务器配置(实际监听端口)
ipcMain.handle('data:getConfig', () => {
  return { success: true, port: tomcat ? tomcat.getPort() : 0 };
});

// 服务器已通过 HTTP 停止后,通过 IPC 重新启动(真实 listen)
ipcMain.handle('data:serverStart', () => {
  if (!tomcat) return { success: false };
  return new Promise(function(resolve) {
    tomcat.start(function(port) {
      resolve({ success: port > 0, port: port });
    });
  });
});

两个 IPC 通道的设计意图:

  • data:getConfig:服务器启动时会优先尝试 8080,被占用则自动换端口。渲染进程必须通过此通道拿到实际端口,绝不能写死 8080。
  • data:serverStart:服务器一旦停止,HTTP 通道就断了——重启服务器的请求本身无法通过已关闭的通道发送,所以重启必须走本地 IPC,由主进程直接重新调用 listen。

四、真实 HTTP 服务器实现(server.js 核心)

server.js 是整个适配的核心,514 行纯 Node 模块,零外部依赖。它是一个可独立测试的工厂函数:createTomcatServer 接收数据文件路径,返回 start/stop/getPort 等方法。以下按功能拆解真实代码。

4.1 端口监听与 EADDRINUSE 自动重试

真实 Tomcat 的 server.xml 里 Connector 绑定 8080;设备上若 8080 已被占用,我们的服务器需要像运维手工改配置一样自动换端口,同时保证 UI 能拿到真实端口:

// ========== 启停 ==========
  function start(cb) {
    if (running && server) { if (cb) cb(port); return; }
    var candidates = port ? [port].concat(PORT_CANDIDATES) : PORT_CANDIDATES.slice();
    var tried = {};

    function attempt(i) {
      if (i >= candidates.length) {
        console.error('[Tomcat] 无可用端口');
        if (cb) cb(0);
        return;
      }
      var p = candidates[i];
      if (tried[p]) { attempt(i + 1); return; }
      tried[p] = true;
      var s = http.createServer(handler);
      s.on('error', function(err) {
        if (err.code === 'EADDRINUSE') {
          s = null;
          attempt(i + 1);
        } else {
          console.error('[Tomcat] 服务器错误:', err.message);
        }
      });
      s.listen(p, '127.0.0.1', function() {
        server = s;
        port = s.address().port;
        running = true;
        startedAt = Date.now();
        addLog('INFO', 'Starting service [Catalina]');
        addLog('INFO', 'Starting ProtocolHandler ["http-nio-' + port + '"]');
        addLog('INFO', 'Server startup in [' + (Date.now() - bootT0) + '] ms');
        persistNow();
        if (cb) cb(port);
      });
    }
    attempt(0);

    if (!sessionTimer) {
      sessionTimer = setInterval(purgeSessions, 60 * 1000);
    }
  }

端口候选序列为 8080、8081、8082、8888、18080。监听成功后写入 Tomcat 风格的启动日志,并把真实端口回传给调用方。绑定 127.0.0.1 而非 0.0.0.0 是刻意的安全决策:管理接口不应暴露给局域网。

4.2 会话跟踪:JSESSIONID 双通道

真实 Tomcat 支持两种会话保持方式:Cookie 与 URL 重写(;jsessionid= 后缀)。本实现完整复刻双通道——因为渲染进程页面通过 file:// 加载,Cookie 语义不完全可靠,URL 重写通道保证了 UI 轮询始终复用同一会话。主请求处理器的会话跟踪与统计部分如下(末尾 … 为省略的路由分发逻辑):

// ========== 主请求处理器 ==========
  function handler(req, res) {
    var t0 = Date.now();
    var parsed = url.parse(req.url, true);
    var rawPath = parsed.pathname || '/';

    // 会话跟踪:URL 重写(;jsessionid=)与 Cookie 双通道(真实 Tomcat 均支持)
    var sessionId = null;
    var semi = rawPath.indexOf(';');
    if (semi >= 0) {
      var param = rawPath.substring(semi + 1);
      rawPath = rawPath.substring(0, semi);
      if (param.indexOf('jsessionid=') === 0) sessionId = param.substring(11);
    }
    if (!sessionId && req.headers.cookie) {
      var cm = /(?:^|;\s*)JSESSIONID=([^;]+)/.exec(req.headers.cookie);
      if (cm) sessionId = cm[1];
    }
    var sess = getOrCreateSession(sessionId);
    req._tcSession = sess;

    reqTotal++;
    activeRequests++;
    bytesIn += Number(req.headers['content-length'] || 0);

    function finish(code) {
      activeRequests--;
      var ms = Date.now() - t0;
      if (code >= 400) errTotal++;
      addLog(code >= 500 ? 'ERROR' : code >= 400 ? 'WARN' : 'INFO',
        req.method + ' ' + rawPath + ' ' + code + ' ' + bytesIn + '+' + bytesOut + ' bytes ' + ms + ' ms');
      persistSoon();
    }
    ...

会话存储与超时清理,超时时长与真实 Tomcat 默认值一致(30 分钟):

// ========== 会话(真实 HTTP 会话跟踪) ==========
  function getOrCreateSession(id) {
    if (id && sessions.has(id)) {
      var s = sessions.get(id);
      s.lastAccess = Date.now();
      return s;
    }
    var ns = { id: newSessionId(), apps: new Set(), lastAccess: Date.now() };
    sessions.set(ns.id, ns);
    return ns;
  }
  function purgeSessions() {
    var now = Date.now();
    var removed = 0;
    sessions.forEach(function(s, id) {
      if (now - s.lastAccess > SESSION_TIMEOUT_MS) { sessions.delete(id); removed++; }
    });
    if (removed > 0) addLog('INFO', removed + ' expired session(s) unloaded');
  }
  function activeSessionCount() { purgeSessions(); return sessions.size; }
  function appSessionCount(appPath) {
    var n = 0;
    sessions.forEach(function(s) { if (s.apps.has(appPath)) n++; });
    return n;
  }

每个会话记录访问过哪些应用(apps 集合),"应用会话数"因此有了真实语义:访问过该应用的活跃会话数量。设备浏览器访问 /docs 一次,Manager 界面里 docs 的会话数就会 +1——不是模拟,是同一个真实服务的两份数据视图。

4.3 Manager JSON API:status 聚合

状态接口聚合全部真实运行时数据。内存来自 process.memoryUsage 与 v8.getHeapStatistics,事件循环延迟来自 perf_hooks.monitorEventLoopDelay——这些是 Node 进程的"JVM 等价物":

// GET /manager/api/status —— 聚合状态(真实运行时数据)
    if (req.method === 'GET' && (seg === 'status' || seg === 'status/')) {
      var mem = process.memoryUsage();
      var heap = v8.getHeapStatistics();
      var evMs = -1;
      if (evHistogram) {
        try { evMs = Math.round(evHistogram.mean / 1e6 * 100) / 100; } catch (e) { evMs = -1; }
      }
      return sendJson(res, 200, {
        ok: true,
        running: running,
        port: port,
        startTime: startedAt,
        uptimeMs: startedAt ? Date.now() - startedAt : 0,
        sessionId: req._tcSession.id,
        memory: {
          heapUsed: Math.round(mem.heapUsed / 1048576),
          heapTotal: Math.round(mem.heapTotal / 1048576),
          heapMax: Math.round(heap.heap_size_limit / 1048576),
          rss: Math.round(mem.rss / 1048576)
        },
        requests: reqTotal,
        errors: errTotal,
        trafficBytes: bytesIn + bytesOut,
        activeRequests: activeRequests,
        sessions: activeSessionCount(),
        eventLoopDelayMs: evMs,
        apps: apps.map(function(a) {
          return {
            path: a.path, name: a.name, state: a.state,
            sessions: appSessionCount(a.path),
            requests: a.requests, displayName: a.displayName
          };
        })
      });
    }

4.4 部署校验:全部在服务端

部署接口承担所有校验(渲染进程只做非空提示)。重名 Context Path 的处理与真实 Manager 的 redeploy 语义一致——先卸载旧应用再部署新的:

// POST /manager/api/apps —— 部署应用(服务端校验)
      if (seg === 'apps' || seg === 'apps/') {
        var ctx = String(b.contextPath || '').trim();
        var war = String(b.warPath || '').trim();
        var disp = String(b.displayName || '').trim();
        if (!ctx) return sendJson(res, 400, { ok: false, error: '请输入 Context Path' });
        if (ctx.charAt(0) !== '/') return sendJson(res, 400, { ok: false, error: 'Context Path 必须以 / 开头' });
        if (!war) return sendJson(res, 400, { ok: false, error: '请输入 WAR 文件或目录路径' });
        if (war.charAt(0) !== '/') return sendJson(res, 400, { ok: false, error: '路径必须以 / 开头(绝对路径)' });
        var exist = findApp(ctx);
        if (exist) {
          apps = apps.filter(function(a) { return a.path !== ctx; });
          addLog('WARN', 'Undeploying existing application [' + ctx + ']');
        }
        var name = ctx === '/' ? 'ROOT' : ctx.substring(1);
        var app = { path: ctx, name: name, state: 'running', requests: 0, displayName: disp || name, deployedAt: Date.now() };
        apps.push(app);
        addLog('INFO', 'Deploying web application [' + war + '] to context [' + ctx + ']');
        addLog('INFO', 'Deployment of web application [' + ctx + '] has finished');
        persistNow();
        return sendJson(res, 200, { ok: true, app: app });
      }

4.5 应用真实路由与 Tomcat 风格 404

这是"真实"二字的落点:部署的应用立即拥有真实 HTTP 路由。路由匹配支持精确匹配与前缀匹配(/myapp/anything 也属于 /myapp 应用),停止状态的应用返回 404 并说明原因:

function matchApp(rawPath) {
    var exact = findApp(rawPath);
    if (exact) return exact;
    // 前缀匹配:/docs/、/docs/a → /docs
    for (var i = 0; i < apps.length; i++) {
      var p = apps[i].path;
      if (p !== '/' && rawPath.indexOf(p + '/') === 0) return apps[i];
    }
    return null;
  }
// 已部署应用的真实路由
    var app = matchApp(rawPath);
    if (app) {
      if (app.state === 'running') {
        app.requests++;
        sess.apps.add(app.path);
        sendHtml(res, 200, appPageHtml(app),
          { 'Set-Cookie': 'JSESSIONID=' + sess.id + '; Path=/; SameSite=Lax' });
        finish(200);
      } else {
        sendHtml(res, 404, notFoundHtml(rawPath, '应用 ' + app.path + ' 当前处于停止状态'));
        finish(404);
      }
      return;
    }

    // 未匹配路由 → Tomcat 风格 404
    sendHtml(res, 404, notFoundHtml(rawPath));
    finish(404);

404 页面复刻真实 Tomcat 的经典样式(红色大标题 + Type Status Report + URI 行):

// 复刻真实 Tomcat 404 页面风格
function notFoundHtml(reqPath, extra) {
  return '<!DOCTYPE html><html><head><meta charset="UTF-8"><title>HTTP Status 404 – Not Found</title></head>' +
    '<body style="font-family:Tahoma,Arial,sans-serif;color:#222;max-width:640px;margin:48px auto;padding:0 16px;">' +
    '<h1 style="color:#c00;">HTTP Status 404 – Not Found</h1><hr style="border:none;border-top:1px solid #bbb;">' +
    '<p><b>Type</b> Status Report</p>' +
    '<p>The origin server did not find a current representation for the target resource or is not willing to disclose that one exists.</p>' +
    (extra ? '<p>' + esc(extra) + '</p>' : '') +
    '<p><b>URI</b> ' + esc(reqPath) + '</p>' +
    '</body></html>';
}

4.6 节流持久化

每个请求完成都会触发 persistSoon,但真正写盘由 1 秒节流定时器合并,避免高频轮询把磁盘打满;关键操作(部署/启停/清日志)则调用 persistNow 立即落盘:

// ========== 持久化(节流合并写盘) ==========
  function persistNow() {
    if (!dataFile) return;
    try {
      var dir = path.dirname(dataFile);
      if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
      fs.writeFileSync(dataFile, JSON.stringify({
        startedAt: startedAt,
        requests: reqTotal,
        errors: errTotal,
        bytesIn: bytesIn,
        bytesOut: bytesOut,
        apps: apps,
        logs: logs
      }, null, 2), 'utf-8');
    } catch (e) {
      console.warn('[Tomcat] 持久化失败:', e.message);
    }
  }
  function persistSoon() {
    if (persistTimer) return;
    persistTimer = setTimeout(function() {
      persistTimer = null;
      persistNow();
    }, 1000);
  }

五、渲染进程:真实 HTTP 客户端(renderer.js)

渲染进程删除了所有本地模拟逻辑,全部数据来自真实 HTTP 响应。

5.1 HTTP 客户端层

fetch 优先、XMLHttpRequest 兜底;apiUrl 自动附加会话 ID 的 URL 重写后缀:

function apiUrl(p) {
  var base = 'http://127.0.0.1:' + API_PORT + p;
  if (SESSION_ID) base += ';jsessionid=' + SESSION_ID;
  return base;
}

function httpJson(method, url, body) {
  return new Promise(function(resolve, reject) {
    if (typeof fetch === 'function') {
      var opts = { method: method, headers: { 'Content-Type': 'application/json' } };
      if (body !== undefined) opts.body = JSON.stringify(body);
      fetch(url, opts)
        .then(function(r) { return r.json(); })
        .then(function(data) { resolve({ status: 200, data: data }); })
        .catch(reject);
    } else {
      var xhr = new XMLHttpRequest();
      xhr.open(method, url);
      xhr.setRequestHeader('Content-Type', 'application/json');
      xhr.onload = function() {
        try { resolve({ status: xhr.status, data: JSON.parse(xhr.responseText) }); }
        catch (e) { reject(new Error('响应不是合法 JSON')); }
      };
      xhr.onerror = function() { reject(new Error('网络错误:无法连接服务器')); };
      xhr.send(body !== undefined ? JSON.stringify(body) : null);
    }
  });
}

function apiGet(p) { return httpJson('GET', apiUrl(p)); }
function apiPost(p, body) { return httpJson('POST', apiUrl(p), body || {}); }

5.2 状态轮询与离线检测

每 2 秒一次轮询本身就是真实流量:manager 应用的请求数会随 UI 打开时间自然增长,与真实 Tomcat Manager 的行为一致。服务器停止后,轮询真实地收到连接拒绝,UI 切换到离线展示:

// ============================================
// 状态轮询(每 2 秒一次真实 HTTP 请求,本身即真实流量)
// ============================================
function pollStatus() {
  return apiGet('/manager/api/status').then(function(r) {
    var s = r.data;
    if (s.sessionId) SESSION_ID = s.sessionId;
    var wasDown = !connected;
    connected = true;
    statusCache = s;
    if (wasDown) getElementById('btnServerToggle').textContent = '⏹ 停止服务器';
    getElementById('statusLeft').textContent =
      '已连接 127.0.0.1:' + s.port + ' · 会话 ' + s.sessionId.substring(0, 8) + '…';
    renderCurrentView();
  }).catch(function() {
    var wasUp = connected;
    connected = false;
    statusCache = null;
    if (wasUp) getElementById('btnServerToggle').textContent = '▶ 启动服务器';
    getElementById('statusLeft').textContent = '无法连接 127.0.0.1:' + API_PORT;
    renderCurrentView();
  });
}

5.3 应用操作与部署

所有操作按钮走事件委托,点击后发真实 POST,以服务端返回为准:

// 应用操作(真实 HTTP POST,服务端执行并校验)
function handleAppOp(op, path) {
  if (!connected) { showNotification('错误', '服务器未运行,请先启动服务器', 'error'); return; }
  apiPost('/manager/api/apps/' + op, { path: path }).then(function(r) {
    if (r.data.ok) {
      var labels = { start: '启动', stop: '停止', reload: '重载', undeploy: '卸载' };
      showNotification(labels[op] || op, '应用 ' + path + ' 已' + (labels[op] || op), 'success');
      return pollStatus();
    }
    showNotification('错误', r.data.error || '操作失败', 'error');
  }).catch(function() {
    showNotification('错误', '网络错误:无法连接服务器', 'error');
  });
}
// ============================================
// 部署应用视图(真实 HTTP POST,服务端校验并注册真实路由)
// ============================================
function handleDeploy() {
  if (!connected) { showNotification('错误', '服务器未运行,请先启动服务器', 'error'); return; }
  var ctx = getElementById('inputContextPath').value.trim();
  var war = getElementById('inputWarPath').value.trim();
  var disp = getElementById('inputDisplayName').value.trim();
  if (!ctx || !war) { showNotification('错误', '请填写 Context Path 与 WAR 路径', 'error'); return; }

  apiPost('/manager/api/apps', { contextPath: ctx, warPath: war, displayName: disp }).then(function(r) {
    if (r.data.ok) {
      showNotification('部署成功',
        '应用 ' + ctx + ' 已部署并启动,可访问 http://127.0.0.1:' + API_PORT + ctx, 'success');
      getElementById('inputContextPath').value = '';
      getElementById('inputWarPath').value = '';
      getElementById('inputDisplayName').value = '';
      getElementById('appSearch').value = '';
      return pollStatus().then(function() { switchView('apps'); });
    }
    showNotification('错误', r.data.error || '部署失败', 'error');
  }).catch(function() {
    showNotification('错误', '网络错误:无法连接服务器', 'error');
  });
}

部署成功的通知里直接给出可访问的 URL——用户可以立即切到设备浏览器验证真实路由。

5.4 服务器启停:双通道设计

// ============================================
// 服务器启停(HTTP 停止 / IPC 启动——与真实运维通道一致)
// ============================================
function handleServerToggle() {
  if (connected) {
    // 停止:真实关闭监听(服务器处理完本请求后 close)
    apiPost('/manager/api/server/stop').then(function() {
      connected = false;
      statusCache = null;
      getElementById('btnServerToggle').textContent = '▶ 启动服务器';
      showNotification('服务器', 'Tomcat 服务器已停止(监听已关闭)', 'success');
      renderCurrentView();
    }).catch(function() {
      showNotification('错误', '停止请求失败', 'error');
    });
  } else {
    // 启动:HTTP 通道已关闭,走本地 IPC 通道重新 listen
    ipcRenderer.invoke('data:serverStart').then(function(result) {
      if (result.success) {
        getElementById('btnServerToggle').textContent = '⏹ 停止服务器';
        showNotification('服务器', 'Tomcat 服务器已启动(端口 ' + result.port + ')', 'success');
        pollStatus();
      } else {
        showNotification('错误', '服务器启动失败', 'error');
      }
    });
  }
}

服务器端的 stop 处理有一个细节:先把 200 响应发给客户端,再用 setImmediate 在本轮事件循环结束后真正 close——保证客户端能收到响应,同时监听真实关闭:

// POST /manager/api/server/stop —— 响应送达后真实关闭监听
      if (seg === 'server/stop') {
        sendJson(res, 200, { ok: true, message: 'Server stopping' });
        addLog('INFO', 'Pausing ProtocolHandler ["http-nio-' + port + '"]');
        addLog('INFO', 'Stopping service [Catalina]');
        persistNow();
        setImmediate(function() { internalStop(); });
        return;
      }

六、鸿蒙平台特殊适配

6.1 页面结构

index.html 采用左侧导航 + 顶部工具栏 + 5 个视图容器的布局。核心结构如下(其余视图结构类似,完整代码见仓库):

<div class="app-container">
  <!-- 左侧导航栏 -->
  <nav class="sidebar">
    <div class="sidebar-header">
      <span class="logo">🐈</span>
      <span class="logo-text">Tomcat Manager</span>
    </div>
    <div class="server-badge" id="serverBadge">运行中</div>
    <ul class="nav-menu">
      <li class="nav-item active" data-view="status">
        <span class="nav-icon">📊</span>
        <span class="nav-label">服务器状态</span>
      </li>
      <!-- apps / deploy / info / logs 同结构 -->
    </ul>
    <div class="sidebar-footer">
      <button class="server-btn" id="btnServerToggle">⏹ 停止服务器</button>
    </div>
  </nav>

  <!-- 右侧主内容区:topbar + 5 个 view-container + statusbar -->
  <!-- 通知组件(替代 alert) -->
  <div id="tcNotification"></div>
</div>

页面设计要点:导航项通过 data-view 属性驱动视图切换;服务器状态视图的指标卡(当前并发/总请求数/错误数/流量/活动会话)全部由轮询数据填充;服务器信息视图展示真实运行时(Node 版本、Electron 版本、V8 版本、操作系统、PID、监听地址);底部状态栏实时显示连接状态与会话 ID 前缀。

6.2 "三防"稳定性策略

鸿蒙 Electron 适配层存在已知限制,本应用的标准防护配置:

防护目标措施代码位置
防原生弹窗崩溃自定义 div 通知替代 alert/confirm/promptrenderer.js showNotification
防 select 崩溃全程未使用原生 select 元素index.html
防 XComponent 崩溃frame: true + transparent: false + resizable: truemain.js BrowserWindow 配置
防 GPU 白屏app.disableHardwareAcceleration()main.js 启动时调用
防 API 不兼容未使用 setWindowOpenHandler / will-navigate全局

通知组件实现(替代 alert):

function showNotification(title, message, type) {
  var existing = getElementById('tcNotification');
  if (existing) existing.remove();
  var div = document.createElement('div');
  div.id = 'tcNotification';
  div.className = 'tc-notification' + (type === 'error' ? ' error' : type === 'success' ? ' success' : '');
  div.innerHTML = '<div class="notification-title">' + escapeHtml(title) + '</div>' +
    '<div class="notification-msg">' + escapeHtml(message) + '</div>';
  document.body.appendChild(div);
  setTimeout(function() { if (div.parentNode) div.remove(); }, 4000);
}

七、Catppuccin Mocha 深色主题

主题使用 Catppuccin Mocha 配色体系,共 18 个 CSS 变量:

/* Apache Tomcat Manager - Catppuccin Mocha 深色主题 */
:root {
  --base: #1e1e2e;
  --mantle: #181825;
  --crust: #11111b;
  --surface0: #313244;
  --surface1: #45475a;
  --surface2: #585b70;
  --text: #cdd6f4;
  --text-muted: #a6adc8;
  --overlay0: #6c7086;
  --blue: #89b4fa;
  --green: #a6e3a1;
  --red: #f38ba8;
  --yellow: #f9e2af;
  --mauve: #cba6f7;
  --peach: #fab387;
  --teal: #94e2d5;
  --border: #313244;
  --border-light: #45475a;
}

运行状态徽章使用半透明底色 + 高饱和前景色的组合,是全应用状态标识的统一样式范式:

.state-chip {
  display: inline-block;
  padding: 2px 10px;
  border-radius: 10px;
  font-size: 11px;
  font-weight: 600;
}
.state-chip.running { background: rgba(166, 227, 161, 0.12); color: var(--green); }
.state-chip.stopped { background: rgba(243, 139, 168, 0.12); color: var(--red); }

HTTP 监听器卡片的状态样式与状态芯片同范式,保证"运行中/已停止"在任何位置出现都有相同的视觉语言:

.listener-port { color: var(--blue); font-family: Consolas, monospace; }
.listener-state {
  font-size: 11px;
  font-weight: 600;
  padding: 2px 10px;
  border-radius: 10px;
}
.listener-state.running { background: rgba(166, 227, 161, 0.12); color: var(--green); }
.listener-state.stopped { background: rgba(243, 139, 168, 0.12); color: var(--red); }

八、构建与部署

8.1 同步到 web_engine 部署目录

# 复制全部应用文件(含 server.js)
$appDir = "web_engine\src\main\resources\resfile\resources\app"
Copy-Item "electron-apps\Tomcat\main.js" $appDir -Force
Copy-Item "electron-apps\Tomcat\server.js" $appDir -Force
Copy-Item "electron-apps\Tomcat\renderer.js" $appDir -Force
Copy-Item "electron-apps\Tomcat\index.html" $appDir -Force
Copy-Item "electron-apps\Tomcat\package.json" $appDir -Force
Copy-Item "electron-apps\Tomcat\styles\tomcat.css" "$appDir\styles\" -Force

注意:server.js 是新增文件,务必确认它进入了部署目录,否则主进程 require(‘./server’) 会直接失败。

8.2 构建与真机运行

  1. DevEco Studio 打开工程 → Build → Build Hap(s)/APP(s) → Build Hap(s)
  2. 连接鸿蒙 PC → Run → Run(需配置 signingConfigs)

8.3 真机功能验证

验证步骤操作预期结果
1打开应用,观察服务器状态视图徽章"运行中",指标卡为真实数值,状态栏显示已连接与端口号
2停留数秒总请求数自动增长(2 秒轮询即真实请求)
3设备浏览器访问 http://127.0.0.1:8080/manager/api/status返回真实状态 JSON
4Manager 界面部署 /demo 后,浏览器访问 http://127.0.0.1:8080/demo返回 200 欢迎页(真实路由)
5浏览器多次刷新 /demo,回 Manager 看应用行demo 的请求数同步增长
6停止服务器后浏览器刷新连接被拒绝;UI 点启动后恢复

其中第 4、5 步是整个"真实适配"的核心验证点:Manager 界面与设备浏览器看到的是同一个真实 HTTP 服务的两份数据视图。

九、常见问题与解决方案

Q1:渲染进程请求被跨域拦截

问题现象:渲染进程页面通过 file:// 协议加载,向 http://127.0.0.1:8080 发送请求时,浏览器控制台报错 No ‘Access-Control-Allow-Origin’ header is present,请求被 CORS 策略拦截。

根本原因:页面协议与请求目标不同源,跨域请求需要服务端显式返回 CORS 响应头。

错误做法:

// ❌ 直接写响应头,不带 CORS 字段
function sendJson(res, code, obj) {
  var body = Buffer.from(JSON.stringify(obj));
  res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(body);
}

解决方案:所有响应统一附加 CORS 头,并处理 OPTIONS 预检请求:

// ✅ 响应工具统一携带 CORS 头(server.js)
function corsHeaders() {
  return {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type'
  };
}
function sendJson(res, code, obj) {
  var body = Buffer.from(JSON.stringify(obj));
  var headers = corsHeaders();
  headers['Content-Type'] = 'application/json; charset=utf-8';
  res.writeHead(code, headers);
  res.end(body);
  bytesOut += body.length;
  return body.length;
}
// ✅ 主处理器响应 OPTIONS 预检(server.js handler 节选)
if (req.method === 'OPTIONS') {
  res.writeHead(204, corsHeaders());
  res.end();
  finish(204);
  return;
}

Q2:服务器停止后无法再启动

问题现象:通过 HTTP 接口停止服务器后(连接已被拒绝),再点击"启动服务器"按钮无效。

根本原因:停止服务器是真实 close——此时 HTTP 通道已经不存在,任何"通过 HTTP 启动服务器"的请求都无处可发。这就好比真实环境中服务器关机后,无法通过网络远程开机。

错误做法:

// ❌ 试图通过 HTTP 重新启动——监听已关闭,请求必然失败
apiPost('/manager/api/server/start').then(function(r) {
  // 永远走不到这里:ECONNREFUSED
});

解决方案:双通道设计。停止走 HTTP(真实关闭),启动走本地 IPC(主进程直接重新 listen):

// ✅ 启动走 IPC 通道,由主进程调用 tomcat.start 重新 listen(renderer.js handleServerToggle 节选)
ipcRenderer.invoke('data:serverStart').then(function(result) {
  if (result.success) {
    getElementById('btnServerToggle').textContent = '⏹ 停止服务器';
    showNotification('服务器', 'Tomcat 服务器已启动(端口 ' + result.port + ')', 'success');
    pollStatus();
  } else {
    showNotification('错误', '服务器启动失败', 'error');
  }
});
// ✅ 主进程 IPC:真实重新监听
ipcMain.handle('data:serverStart', () => {
  if (!tomcat) return { success: false };
  return new Promise(function(resolve) {
    tomcat.start(function(port) {
      resolve({ success: port > 0, port: port });
    });
  });
});

Q3:设备上 8080 端口被占用

问题现象:应用启动后 UI 一直显示"无法连接 127.0.0.1:8080",但设备浏览器访问 8080 却能打开一个陌生的 404 页面。

根本原因:8080 被设备上其他服务占用,监听失败抛出 EADDRINUSE;而 UI 把端口写死为 8080,与服务器实际监听的端口不一致。

错误做法:

// ❌ 渲染进程写死端口
var API_PORT = 8080;  // 服务器可能已经在 8081 上监听

// ❌ 监听失败直接崩溃,无重试
var server = http.createServer(handler);
server.listen(8080);  // EADDRINUSE → uncaught exception

解决方案:服务端端口候选自动重试(候选序列 8080、8081、8082、8888、18080)+ 渲染进程通过 IPC 获取实际端口:

// ✅ 服务端:EADDRINUSE 自动尝试下一个候选端口(server.js start 函数节选)
s.on('error', function(err) {
  if (err.code === 'EADDRINUSE') {
    s = null;
    attempt(i + 1);
  } else {
    console.error('[Tomcat] 服务器错误:', err.message);
  }
});
// ✅ 渲染进程:启动时先通过 IPC 查询实际端口(renderer.js 初始化节选)
ipcRenderer.invoke('data:getConfig').then(function(cfg) {
  if (cfg && cfg.port) API_PORT = cfg.port;
  getElementById('statusRight').textContent =
    'Apache Tomcat/10.1.19 | 127.0.0.1:' + API_PORT + ' | Manager on Node.js';
  return pollStatus();
});

Q4:页面加载时先闪一下"无法连接"

问题现象:应用刚打开的一瞬间,底部状态栏短暂显示"无法连接 127.0.0.1:8080",随后恢复正常。

根本原因:主进程的 listen 是异步回调,渲染进程的首轮轮询可能早于监听建立完成,连接失败属于正常时序。

解决方案:轮询本身具备自动恢复能力——wasDown/wasUp 状态跟踪保证连接恢复时 UI 自动切回在线展示,无需额外处理,但需要确保轮询定时器持续运行:

// ✅ 轮询的自动恢复逻辑(renderer.js pollStatus 节选,... 为省略的视图刷新)
function pollStatus() {
  return apiGet('/manager/api/status').then(function(r) {
    var s = r.data;
    if (s.sessionId) SESSION_ID = s.sessionId;
    var wasDown = !connected;
    connected = true;
    statusCache = s;
    if (wasDown) getElementById('btnServerToggle').textContent = '⏹ 停止服务器';
    ...
  }).catch(function() {
    var wasUp = connected;
    connected = false;
    ...
  });
}
// ✅ DOMContentLoaded 后启动固定轮询,单次失败不中断定时器(renderer.js 初始化节选)
ipcRenderer.invoke('data:getConfig').then(function(cfg) {
  if (cfg && cfg.port) API_PORT = cfg.port;
  getElementById('statusRight').textContent =
    'Apache Tomcat/10.1.19 | 127.0.0.1:' + API_PORT + ' | Manager on Node.js';
  return pollStatus();
}).then(function() {
  pollTimer = setInterval(pollStatus, 2000);
});

Q5:首次状态响应的流量统计为 0

问题现象:集成测试中对 status 接口发起第一次请求,响应里的 trafficBytes 为 0;第二次请求才能看到非零值。

根本原因:响应字节数是在响应发送完成后才累加的(sendJson 内 bytesOut += body.length),而首次响应携带的统计值在发送前生成——响应自身还来不及计入。这是监控系统的标准采样语义:任何时点读到的都是"上一刻"的累计值。

错误做法:

// ❌ 断言首次响应就有流量——时序上不可能
var s1 = await GET('/manager/api/status');
assert(s1.data.trafficBytes > 0);  // 失败:首响应未计入自身

解决方案:测试断言按采样语义修正;产品端无需改动:

// ✅ 第二次请求可观测到首次响应产生的真实字节数
var s1 = await GET('/manager/api/status');
var s1b = await GET('/manager/api/status');
assert(s1b.data.trafficBytes > 0);  // 含首次响应的 body 字节数

十、总结

本文完整记录了 Apache Tomcat Manager 控制台在鸿蒙 PC 平台的适配过程。核心技术要点总结如下:

技术点方案
服务端实现Node 内置 http 模块(零外部依赖)
端口策略8080 优先 + EADDRINUSE 自动候选重试
会话跟踪JSESSIONID 双通道(URL 重写 + Set-Cookie),30 分钟超时
部署语义服务端校验 + 重名替换 + 真实路由注册(精确 + 前缀匹配)
运行时统计process.memoryUsage + v8.getHeapStatistics + monitorEventLoopDelay
访问日志方法/路径/状态码/字节数/耗时,4xx 记 WARN、5xx 记 ERROR
启停通道HTTP 停止(真实 close)+ IPC 启动(真实重新 listen)
客户端fetch 优先 + XMLHttpRequest 兜底 + 2 秒轮询离线检测
持久化1 秒节流合并写盘 + 关键操作立即落盘
鸿蒙稳定性三防策略 + 自定义通知替代原生弹窗
主题配色Catppuccin Mocha(18 个 CSS 变量)

核心经验:Electron 壳方案的能力边界不限于"画界面"。主进程本身就是完整的 Node.js 运行时,当适配对象是网络服务类工具(Tomcat Manager、数据库客户端、API 调试器)时,与其在前端伪造数据,不如把服务端做真——一条真实的 TCP 监听、一套真实的请求/会话/统计语义,让界面、浏览器、自动化测试面对的都是同一个真实服务。这种"内嵌真实服务"模式让适配成果可以被独立验证(设备浏览器直接访问)、可以被自动化回归(Node 客户端 56 项断言全部通过),也让"模拟"与"真实"的边界清晰可审计。

整个适配过程遵循 Electron 壳方案标准化流程:在 electron-apps/Tomcat/ 开发目录中编写代码,同步到 web_engine/ 模块部署目录,最终由鸿蒙壳工程打包为 HAP 安装包。开发者专注于 Node HTTP 服务与 Web 技术栈,无需关心平台差异。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐