Codex实战:APP远程升级服务搭建(一)NodeJS_Express

摘要

最近给 APP(QGC/地面站)做了一套远程升级服务,目标很明确:App 启动后请求服务器,服务器根据当前 versionCode 判断是否有新版本,如果有就返回 APK 下载地址、版本号和更新说明,App 端再弹窗下载并安装。

这篇先讲服务端主链路,也就是:

text 复制代码
App -> /fra/check/update -> update-config.json -> /updates/*.apk

本文偏实战,不讲大而全的发布平台,只把一个可落地、可部署、可维护的小型升级服务跑起来。

最终的web:

适用场景

  • QGC、地面站、Android 工具类 App 需要远程升级。
  • 暂时不想接第三方应用市场或企业分发平台。
  • 需要自己控制多应用、多渠道、强制更新、更新说明。
  • 服务端希望轻量,Node.js + Express 即可部署到 Ubuntu 云服务器。

本文效果

完成后可以得到:

  • 一个健康检查接口 /health
  • 一个兼容 App 的升级检查接口 /fra/check/update
  • 一个静态 APK 下载目录 /updates
  • 一个本地 JSON 版本库 update-config.json
  • 支持按应用 ID 和版本号筛选最新版本。

背景

地面站项目里,App 端升级逻辑通常已经有一部分:比如启动时请求接口、拿到 APK 地址、显示升级弹窗、下载完成后调 Android 安装界面。

真正容易卡住的是服务端:

  • App 请求地址必须稳定,不能写成本机 localhost
  • 返回 JSON 必须和客户端字段匹配。
  • 版本判断要用 versionCode,不能只看 versionName
  • APK 地址要返回公网可访问的完整 URL。
  • 后续要能通过后台维护多个 App 的版本。

所以我这里没有一上来搞复杂数据库,而是先用 update-config.json 做版本配置,Express 提供接口,APK 放到静态目录里。

系统框架图

#mermaid-svg-OecVkCRg5twZNTez{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OecVkCRg5twZNTez .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OecVkCRg5twZNTez .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OecVkCRg5twZNTez .error-icon{fill:#552222;}#mermaid-svg-OecVkCRg5twZNTez .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OecVkCRg5twZNTez .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OecVkCRg5twZNTez .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OecVkCRg5twZNTez .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OecVkCRg5twZNTez .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OecVkCRg5twZNTez .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OecVkCRg5twZNTez .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OecVkCRg5twZNTez .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OecVkCRg5twZNTez .marker.cross{stroke:#333333;}#mermaid-svg-OecVkCRg5twZNTez svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OecVkCRg5twZNTez p{margin:0;}#mermaid-svg-OecVkCRg5twZNTez .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-OecVkCRg5twZNTez .cluster-label text{fill:#333;}#mermaid-svg-OecVkCRg5twZNTez .cluster-label span{color:#333;}#mermaid-svg-OecVkCRg5twZNTez .cluster-label span p{background-color:transparent;}#mermaid-svg-OecVkCRg5twZNTez .label text,#mermaid-svg-OecVkCRg5twZNTez span{fill:#333;color:#333;}#mermaid-svg-OecVkCRg5twZNTez .node rect,#mermaid-svg-OecVkCRg5twZNTez .node circle,#mermaid-svg-OecVkCRg5twZNTez .node ellipse,#mermaid-svg-OecVkCRg5twZNTez .node polygon,#mermaid-svg-OecVkCRg5twZNTez .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OecVkCRg5twZNTez .rough-node .label text,#mermaid-svg-OecVkCRg5twZNTez .node .label text,#mermaid-svg-OecVkCRg5twZNTez .image-shape .label,#mermaid-svg-OecVkCRg5twZNTez .icon-shape .label{text-anchor:middle;}#mermaid-svg-OecVkCRg5twZNTez .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OecVkCRg5twZNTez .rough-node .label,#mermaid-svg-OecVkCRg5twZNTez .node .label,#mermaid-svg-OecVkCRg5twZNTez .image-shape .label,#mermaid-svg-OecVkCRg5twZNTez .icon-shape .label{text-align:center;}#mermaid-svg-OecVkCRg5twZNTez .node.clickable{cursor:pointer;}#mermaid-svg-OecVkCRg5twZNTez .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OecVkCRg5twZNTez .arrowheadPath{fill:#333333;}#mermaid-svg-OecVkCRg5twZNTez .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OecVkCRg5twZNTez .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OecVkCRg5twZNTez .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OecVkCRg5twZNTez .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OecVkCRg5twZNTez .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OecVkCRg5twZNTez .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OecVkCRg5twZNTez .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OecVkCRg5twZNTez .cluster text{fill:#333;}#mermaid-svg-OecVkCRg5twZNTez .cluster span{color:#333;}#mermaid-svg-OecVkCRg5twZNTez div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OecVkCRg5twZNTez .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OecVkCRg5twZNTez rect.text{fill:none;stroke-width:0;}#mermaid-svg-OecVkCRg5twZNTez .icon-shape,#mermaid-svg-OecVkCRg5twZNTez .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OecVkCRg5twZNTez .icon-shape p,#mermaid-svg-OecVkCRg5twZNTez .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OecVkCRg5twZNTez .icon-shape .label rect,#mermaid-svg-OecVkCRg5twZNTez .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OecVkCRg5twZNTez .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OecVkCRg5twZNTez .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OecVkCRg5twZNTez :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /fra/check/update
有新版本
无新版本
Android 地面站 App
Node.js Express 升级服务
读取 update-config.json
匹配 appId / versionCode
返回 code=2000 + APK 地址
返回 code=2201
App 下载 /updates/*.apk
Android 安装界面

目录结构

服务端目录大概是这样:

text 复制代码
server
├── server.js
├── package.json
├── update-config.json
├── public
│   ├── admin
│   └── updates
├── deploy
└── client-porting

其中本篇重点是:

  • server.js:升级接口和版本判断。
  • update-config.json:保存版本配置。
  • public/updates:保存 APK 文件。

package.json

依赖很少,核心就是 Express、cookie、multer:

json 复制代码
{
  "name": "gcs-update-server",
  "version": "1.0.0",
  "private": true,
  "type": "commonjs",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "cookie-parser": "^1.4.7",
    "express": "^4.19.2",
    "multer": "^2.1.1"
  },
  "engines": {
    "node": ">=18"
  }
}

安装依赖:

powershell 复制代码
npm install

启动:

powershell 复制代码
$env:ADMIN_PASSWORD="your-admin-password"
$env:PUBLIC_BASE_URL="http://203.0.113.10:8080"
npm start

这里的 203.0.113.10 是示例公网 IP,实际部署时换成自己的 ECS IP 或域名。

版本配置结构

update-config.json 是当前服务端的版本库。为了方便公开,这里做了脱敏示例:

json 复制代码
{
  "latest": {
    "id": "b369425a32ee",
    "appName": "GCS Demo",
    "appIds": ["com.example.gcs"],
    "flavors": ["release"],
    "versionCode": 10308,
    "versionName": "1.03.08",
    "fileName": "GCS-Demo-1.03.08.apk",
    "fileUrl": "/updates/GCS-Demo-1.03.08.apk",
    "sizeKB": 211849,
    "force": false,
    "description": "修复已知问题,优化升级流程",
    "descriptionEn": "",
    "enabled": true
  },
  "versions": [
    {
      "id": "b369425a32ee",
      "appName": "GCS Demo",
      "appIds": ["com.example.gcs"],
      "flavors": ["release"],
      "versionCode": 10308,
      "versionName": "1.03.08",
      "fileName": "GCS-Demo-1.03.08.apk",
      "fileUrl": "/updates/GCS-Demo-1.03.08.apk",
      "sizeKB": 211849,
      "force": false,
      "description": "修复已知问题,优化升级流程",
      "descriptionEn": "",
      "enabled": true
    }
  ]
}

几个字段比较关键:

  • appIds:客户端传上来的应用 ID,必须能匹配。
  • versionCode:升级判断真正使用的数字版本号。
  • fileUrl:APK 相对地址,服务端会转换成完整公网地址。
  • enabled:是否参与升级判断。
  • force:是否强制升级,客户端可以据此控制弹窗按钮。

核心实现:选择最新版本

服务端不是简单返回 latest,而是从版本列表里筛选启用的版本,并选择 versionCode 最大的版本。

js 复制代码
function selectLatest(versions) {
  const enabledVersions = versions.filter((version) => version.enabled !== false);
  if (enabledVersions.length === 0) {
    return null;
  }

  return enabledVersions.reduce((best, version) => {
    if (!best || Number(version.versionCode) > Number(best.versionCode)) {
      return version;
    }
    return best;
  }, null);
}

function selectLatestForRequest(versions, form) {
  return selectLatest(versions.filter((version) => (
    version.enabled !== false
    && isAllowed(form.appIds, version.appIds)
  )));
}

function isAllowed(value, allowedValues) {
  return !Array.isArray(allowedValues)
    || allowedValues.length === 0
    || allowedValues.includes(value);
}

这里的判断比较朴素,但实际够用:

  • 后台可以保留历史版本。
  • 只有 enabled !== false 的版本参与升级。
  • 先按 appIds 匹配应用,再从匹配结果里选最大 versionCode

核心实现:拼接公网 APK 地址

客户端拿到的 fileUrl 必须能直接访问。部署在 Nginx 或反向代理后,不能简单写死 localhost

js 复制代码
function absoluteUrl(req, maybeRelativeUrl) {
  if (/^https?:\/\//i.test(maybeRelativeUrl)) {
    return maybeRelativeUrl;
  }

  const relativePath = maybeRelativeUrl.startsWith("/")
    ? maybeRelativeUrl
    : `/${maybeRelativeUrl}`;

  if (publicBaseUrl) {
    return `${publicBaseUrl}${relativePath}`;
  }

  const proto = req.headers["x-forwarded-proto"] || req.protocol || "http";
  const host = req.headers["x-forwarded-host"] || req.headers.host;
  return `${proto}://${host}${relativePath}`;
}

我更建议线上配置 PUBLIC_BASE_URL,比如:

bash 复制代码
PUBLIC_BASE_URL=http://203.0.113.10:8080

这样无论请求从哪里进来,返回给 App 的 APK 地址都是稳定的公网地址。

核心实现:升级检查接口

App 端请求:

text 复制代码
POST /fra/check/update
Content-Type: application/x-www-form-urlencoded

&app_ids=com.example.gcs&app_code=10307&app_flavor=release

服务端接口:

js 复制代码
app.get("/health", (req, res) => {
  res.json({ ok: true });
});

app.post(["/fra/check/update", "/fra-bate/check/update"], (req, res) => {
  const form = parseForm(req);
  const config = readConfig();
  const latest = selectLatestForRequest(config.versions, form);

  if (!latest) {
    res.json({ code: 2201, msg: "no update for this app" });
    return;
  }

  if (form.appCode >= Number(latest.versionCode)) {
    res.json({ code: 2201, msg: "already latest" });
    return;
  }

  const apkPath = path.join(updatesDir, latest.fileName);
  let sizeKB = Number(latest.sizeKB || 0);
  if (fs.existsSync(apkPath)) {
    sizeKB = Math.max(1, Math.ceil(fs.statSync(apkPath).size / 1024));
  }

  res.json({
    code: 2000,
    msg: "new version available",
    data: {
      versionCode: Number(latest.versionCode),
      versionName: latest.versionName,
      fileName: latest.fileName,
      fileUrl: absoluteUrl(req, latest.fileUrl),
      sizeKB,
      force: Boolean(latest.force),
      description: latest.description || "",
      descriptionEn: latest.descriptionEn || ""
    }
  });
});

注意这里兼容了两个路径:

text 复制代码
/fra/check/update
/fra-bate/check/update

这是为了兼容历史客户端,实际新项目只保留一个也可以。

升级请求时序图

public/updates update-config.json Express Server Android App public/updates update-config.json Express Server Android App #mermaid-svg-a9BlfpSmw0EYO1WL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-a9BlfpSmw0EYO1WL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-a9BlfpSmw0EYO1WL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-a9BlfpSmw0EYO1WL .error-icon{fill:#552222;}#mermaid-svg-a9BlfpSmw0EYO1WL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-a9BlfpSmw0EYO1WL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-a9BlfpSmw0EYO1WL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-a9BlfpSmw0EYO1WL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-a9BlfpSmw0EYO1WL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-a9BlfpSmw0EYO1WL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-a9BlfpSmw0EYO1WL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-a9BlfpSmw0EYO1WL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-a9BlfpSmw0EYO1WL .marker.cross{stroke:#333333;}#mermaid-svg-a9BlfpSmw0EYO1WL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-a9BlfpSmw0EYO1WL p{margin:0;}#mermaid-svg-a9BlfpSmw0EYO1WL .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-a9BlfpSmw0EYO1WL text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-a9BlfpSmw0EYO1WL .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-a9BlfpSmw0EYO1WL .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-a9BlfpSmw0EYO1WL .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-a9BlfpSmw0EYO1WL .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-a9BlfpSmw0EYO1WL #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-a9BlfpSmw0EYO1WL .sequenceNumber{fill:white;}#mermaid-svg-a9BlfpSmw0EYO1WL #sequencenumber{fill:#333;}#mermaid-svg-a9BlfpSmw0EYO1WL #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-a9BlfpSmw0EYO1WL .messageText{fill:#333;stroke:none;}#mermaid-svg-a9BlfpSmw0EYO1WL .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-a9BlfpSmw0EYO1WL .labelText,#mermaid-svg-a9BlfpSmw0EYO1WL .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-a9BlfpSmw0EYO1WL .loopText,#mermaid-svg-a9BlfpSmw0EYO1WL .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-a9BlfpSmw0EYO1WL .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-a9BlfpSmw0EYO1WL .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-a9BlfpSmw0EYO1WL .noteText,#mermaid-svg-a9BlfpSmw0EYO1WL .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-a9BlfpSmw0EYO1WL .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-a9BlfpSmw0EYO1WL .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-a9BlfpSmw0EYO1WL .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-a9BlfpSmw0EYO1WL .actorPopupMenu{position:absolute;}#mermaid-svg-a9BlfpSmw0EYO1WL .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-a9BlfpSmw0EYO1WL .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-a9BlfpSmw0EYO1WL .actor-man circle,#mermaid-svg-a9BlfpSmw0EYO1WL line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-a9BlfpSmw0EYO1WL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt有新版本已是最新 POST /fra/check/updatereadConfig()versions\[\]appId 匹配 + versionCode 比较code=2000, fileUrl, versionNameGET /updates/GCS-Demo.apkAPK 文件code=2201

本地验证

启动服务:

powershell 复制代码
cd D:\your_workspace\server
$env:ADMIN_PASSWORD="your-admin-password"
$env:PUBLIC_BASE_URL="http://localhost:8080"
npm start

健康检查:

powershell 复制代码
Invoke-RestMethod http://localhost:8080/health

期望结果:

json 复制代码
{
  "ok": true
}

升级接口验证:

powershell 复制代码
Invoke-RestMethod -Method Post `
  -Uri http://localhost:8080/fra/check/update `
  -ContentType 'application/x-www-form-urlencoded' `
  -Body '&app_ids=com.example.gcs&app_code=10307&app_flavor=release'

如果服务器上配置的版本号是 10308,客户端传 10307,正常会返回:

json 复制代码
{
  "code": 2000,
  "msg": "new version available",
  "data": {
    "versionCode": 10308,
    "versionName": "1.03.08",
    "fileName": "GCS-Demo-1.03.08.apk",
    "fileUrl": "http://localhost:8080/updates/GCS-Demo-1.03.08.apk",
    "sizeKB": 211849,
    "force": false,
    "description": "修复已知问题,优化升级流程",
    "descriptionEn": ""
  }
}

如果客户端传的 app_code 已经大于等于服务端版本,则返回:

json 复制代码
{
  "code": 2201,
  "msg": "already latest"
}

常见问题

1. 为什么不用 versionName 判断?

versionName 是展示给用户看的字符串,比如 1.03.08。字符串比较容易出问题,比如 1.10.01.9.9

真正用于升级判断的应该是 Android 的 versionCode,它是整数,只要新包比旧包大即可。

2. 为什么 App 下载地址不能返回 localhost?

因为 localhost 对手机来说是手机自己,不是你的电脑或服务器。

本地调试可以让手机访问电脑局域网 IP,正式发布必须使用公网 IP 或域名。

3. 为什么返回 code=2201?

常见原因:

  • 客户端 app_ids 和服务端 appIds 不一致。
  • 客户端 app_code 已经是最新或更高。
  • 服务端版本 enabled=false
  • APK 版本配置没有写入 update-config.json
  • 请求地址不对,比如少了 /fra/ 前缀。

小结

这一篇先把远程升级服务的主链路跑通了。核心其实就三件事:

  1. App 把应用 ID 和当前 versionCode 发给服务端。
  2. 服务端从版本列表里找到匹配应用的最高可用版本。
  3. 如果服务器版本更高,就返回 code=2000 和 APK 公网下载地址。

下一篇继续写部署,把这个 Node 服务放到阿里云 ECS 的 Ubuntu 上,用 systemd 托管,并处理安全组、Nginx 转发这些线上必踩点。

相关推荐
之歆1 天前
Day11_Express 深入解析:从中间件到项目实战
中间件·express
之歆1 天前
Day10_Node.js 与 Express 开发实战指南:从零到一构建专业级 Web 服务
前端·node.js·express
Aolith4 天前
Express + TypeScript 下写 JWT 中间件,我踩了三个坑
typescript·node.js·express
winfredzhang5 天前
用 Node.js + SQLite + 原生前端写一个本地情绪急救 Web App:情绪降落伞 Mood Parachute
前端·sqlite·node.js·express·情绪管理
海兰8 天前
【实用程序】 极简OA系统-详细设计及源码(基于Node.js + Express + SQLite + 原生前端)
sqlite·node.js·express
云水一下9 天前
掌握 Express 框架:从零到 MVC 博客系统
node.js·express
qq_25183645712 天前
基于nodejs express +vue 天天商城系统设计与实现 (源码 文档)
前端·vue.js·express
大家的林语冰13 天前
Express 团队官宣:全新网站正式上线,Logo 重做,支持两个主版本文档无缝切换!
javascript·node.js·express
GISHUB14 天前
Express + TypeScript + ESM 后端服务搭建教程
javascript·typescript·express