【鸿蒙PC开发】如何让electron拉起go服务并运行在鸿蒙PC上

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

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

摘要

ElectronEgg(ee-v5)的主进程可以用 cross 模块拉起 Go 后端子进程,这一步在桌面上开箱即用。搬到鸿蒙 PC 后多出两个问题:未签名的二进制会被内核 XPM 拦截,得先用 HNP(HarmonyOS Native Package)打包、让系统在安装时签名放行;鸿蒙sandbox的目录权限模型和桌面不一样,$HOME 这类常见环境变量在sandbox里不可写,靠它建目录的 Go 程序会直接启动失败。下面按打包签名 → 环境适配 → 构建注入 → 验证的顺序,记录这套链路踩过的坑和最终解法。

项目源码托管在 AtomGit PC 社区:ohos_electron-egg。

一、总体思路

在鸿蒙 PC 上让 Go 服务跑起来,需要打通三层:

层问题方案
二进制执行内核 XPM 拦截未签名二进制的 exec用 HNP 打包,安装时由系统签名放行
运行时环境sandbox目录权限与桌面系统不同,$HOME 不可写让 Go 程序的运行时目录检测优先读取可写的环境变量
构建与注入HAP 打包用的资源目录和主构建产物是两份拷贝每次改动后跑资源同步命令,确保新代码真正进了 HAP

二、二进制签名:HNP 打包

鸿蒙 PC 的内核 XPM 会拦截未签名二进制的 exec,resfile 目录下直接 spawn 出来的 Go 二进制会报 EACCES。解法是把交叉编译产物打成 HNP 包,安装时由系统释放到sandbox内并签名,运行路径固定为:

/data/app/<appName>.org/<appName>_<version>/bin/<appName>/<appName>

对应到本项目的构建命令:

npm run build-go-ohos      # cross-env GOOS=linux GOARCH=arm64 CGO_ENABLED=0 交叉编译
npm run build-hnp-ohos      # 打包成 .hnp,产物落在 ohos_hap/hnp/arm64-v8a/

module.json5 里声明这个 HNP 包,安装时系统才会去解包签名:

"hnpPackages": [
  {
    "package": "goapp.hnp",
    "type": "private"
  }
],

业务代码里,鸿蒙分支的可执行文件路径要指向系统释放后的签名路径,而不是 resfile 里的原始拷贝:

function getOhosGoAppCmd(): string {
  const home = process.env.HNP_PRIVATE_HOME || '/data/app';
  return path.join(home, 'goapp.org', 'goapp_1.0', 'bin', 'goapp', 'goapp');
}

const opt: CrossTargetConfig = {
  name: 'goapp',
  cmd: is.openharmony() ? getOhosGoAppCmd() : path.join(getExtraResourcesDir(), 'goapp'),
  directory: getExtraResourcesDir(),
  args: ['--port=7073'],
  appExit: false,
}

下图是 ee-core 的 cross 模块创建子进程的实现:接到 cross.run() 调用后,底层用 cross-spawn 把签名释放好的二进制 exec 起来。

在这里插入图片描述

到这里 cross.run() 已经能把二进制真正 exec 起来,EACCES 不再出现。但签名只是让进程能启动,能不能活下来是另一回事。

三、运行时环境:sandbox目录权限适配

3.1 现象:进程秒退,ps -ef 显示僵尸态

如果 Go 程序依赖用户主目录($HOME)建配置/数据目录,在鸿蒙sandbox下大概率会启动失败:

$ ps -ef | grep goapp
20020232  27178  26406  0 23:03:57 ?  00:00:00 [/data/app/goapp]

命令名带方括号,说明进程已经退出(defunct)。原因是鸿蒙 PC 应用sandbox里,从父进程继承的 $HOME(/storage/Users/currentUser)只读不可写,真正可写的只有 ee-core 的 getAppUserDataDir()(对应 /data/storage/el2/base/files)。Go 程序尝试在不可写目录下 mkdir 会直接失败退出。

3.2 排查工具:先找对日志文件,再打开 stdio

ee-core 有两套独立日志,业务代码的输出在 ee.*.log(应用层 logger),不在 ee-core.*.log(框架内部 logger):

hdc file recv /data/app/el2/100/base/<bundleName>/files/ee/logs/ee.$(date +%Y-%m-%d).1.log /tmp/ee_app.log
grep "\[go\]" /tmp/ee_app.log

cross.run() 默认的 stdio 配置不会转发子进程的 stdout/stderr,需要临时打开:

const opt: CrossTargetConfig = {
  // ...
  stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
}
const entity = await cross.run(serviceName, opt);
const child = (entity as any).child;
child?.stdout?.on('data', (d: Buffer) => logger.info('[go][stdout]', d.toString()));
child?.stderr?.on('data', (d: Buffer) => logger.info('[go][stderr]', d.toString()));
child?.on('exit', (code: number, signal: string) => logger.info('[go][exit]', code, signal));
child?.on('error', (err: Error) => logger.info('[go][error]', err.message));

打开之后,能在 ee.*.log 里看到 Go 程序真实的报错,例如:

[go][stdout] Error: create user home conf folder [/storage/Users/currentUser/.config/ee] failed:
mkdir /storage/Users/currentUser/.config: operation not permitted

排查完这几行监听代码建议注释而非删除,方便下次同类问题复用。

3.3 方案:把可写目录喂给 Go 程序,但要注意两个转发陷阱

思路很直接——把子进程的 HOME 环境变量指向应用可写的数据目录。但这里有两个容易踩的坑:

陷阱一:CrossTargetConfig.env 可能不会被框架转发。 需要确认所使用的 ee-core 版本,其 cross-spawn 调用是否真的读取了 env 配置字段——如果没有,写在 opt.env 里的值会被静默忽略,子进程实际继承的是 Node 主进程自己的 process.env。稳妥的做法是直接修改 process.env 后再调用 cross.run():

if (is.openharmony()) {
  process.env.HOME = getAppUserDataDir();
}
const entity = await cross.run(serviceName, opt);

下图是本工程业务代码里 cross.run() 拉起 goapp 的实际调用现场:

在这里插入图片描述

陷阱二:目标语言的运行时可能绕过环境变量检查。 以 Go 为例,标准的"取用户主目录"实现通常先调用 user.Current()(系统级用户信息查询),只有它报错时才会去读 HOME 环境变量兜底:

func GetUserHomeDir() (string, error) {
	user, err := user.Current()
	if nil == err {
		return user.HomeDir, nil   // sandbox下这里可能成功,但返回值不可写
	}
	return homeUnix()               // 只有上面报错才会走到这里检查 HOME
}

鸿蒙sandbox下 user.Current() 往往是成功的,只是返回的目录不可写。也就是说,HOME 环境变量无论怎么设,都不会被这段逻辑采纳。判断方法很简单:设了 HOME 之后报错依然一字不差,基本就是这层原因。

如果目标程序是自己维护的框架源码,修复方式是把环境变量检查提到系统调用之前:

func GetUserHomeDir() (string, error) {
	if !IsWindows() {
		if home := os.Getenv("HOME"); home != "" {
			return home, nil
		}
	}
	user, err := user.Current()
	if nil == err {
		return user.HomeDir, nil
	}
	// ... 其余兜底逻辑不变
}

3.4 改第三方依赖源码的安全流程:本地 replace 先联调

如果需要改的是一个独立仓库的第三方 Go 依赖,不要直接改版本号指向的缓存副本。用 Go modules 的 replace 指令把依赖临时指向本地开发checkout,改完本地源码直接编译验证:

# go.mod
replace github.com/<org>/<pkg> => /path/to/local/checkout
go build -o /tmp/test_bin .          # 本地编译验证
npm run build-go-ohos                # 交叉编译 OHOS 版本
npm run build-hnp-ohos                # 重新打 HNP 包

验证通过后,在依赖仓库里提交、打 tag、推送,再把 go.mod 的 replace 去掉、版本号改成新 tag:

# go.mod 里删掉 replace 行,版本号改成新版本
GOPROXY=https://goproxy.cn,direct go mod tidy   # 如果连不上境外代理,单次命令临时切镜像

这套"本地 replace 联调 → 发布真实版本 → 切回版本号依赖"的流程,不会在验证阶段污染 go.sum,也不会让其他协作者的构建依赖一个只存在于本机的路径。

四、构建与注入:确保改动真正进了 HAP

4.1 陷阱:两份 main.js

npm run build-electron 只会更新仓库根目录下的 public/electron/main.js,但 hvigor 打包 HAP 时用的是另一份拷贝:

ohos_hap/web_engine/src/main/resources/resfile/resources/app/public/electron/main.js

只有执行资源同步命令后,这份拷贝才会更新:

npm run build-electron
ee-bin ohos --cmds=test      # 或 --cmds=resources,把 public/ 同步进 resfile

漏掉这一步,表现和"修复没生效"完全一样——报错信息一字不差,但原因是根本没测到新代码。改完业务代码后,先确认两处 main.js 内容或修改时间一致,再重新打包安装。

4.2 按需拆分构建命令

如果项目同时维护含 Go 后端和不含 Go 后端的构建路径,建议把资源同步命令拆开,避免每次都强制触发一次 Go 交叉编译:

"build-go-hnp-ohos": "npm run build-go-ohos && npm run build-hnp-ohos",
"ohos": "ee-bin ohos --cmds=resources",
"ohos-go": "npm run build-go-hnp-ohos && npm run ohos",
"ohos-test": "npm run build-electron && ee-bin ohos --cmds=test",
"ohos-test-go": "npm run build-electron && npm run build-go-hnp-ohos && ee-bin ohos --cmds=test"

不含 Go 后端、或 Go 侧没有改动时用 ohos / ohos-test;Go 侧有改动时用带 -go 后缀的命令。

4.3 打包与安装

export DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk
export JAVA_HOME=/Applications/DevEco-Studio.app/Contents/jbr/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
node /Applications/DevEco-Studio.app/Contents/tools/hvigor/hvigor/bin/hvigor.js \
  --mode module -p product=default -p buildMode=debug -p module=electron@default \
  assembleHap --no-daemon

hdc install ohos_hap/electron/build/default/outputs/default/electron-default-signed.hap

排障阶段优先用 buildMode=debug,跳过 ArkTS 混淆,减少额外故障面。

五、验证

安装完成后,启动应用并检查进程与端口:

hdc shell "aa start -a EntryAbility -b <bundleName>"
hdc shell "ps -ef" | grep goapp

正常存活的子进程会显示完整命令行,而不是方括号包裹的僵尸态:

20020232  43456  43037  0 23:29:07 ?  00:00:00 goapp --port=7073

转发端口后用 curl 验证 HTTP 服务确实可用:

hdc fport tcp:17073 tcp:7073
curl http://127.0.0.1:17073/api/hello
{"code":0,"msg":"","data":"hello electron-egg"}

真机上的实际验证效果:

在这里插入图片描述

日志里也能看到 HTTP 服务正常加载、后台任务正常触发:

[go][stderr] INFO  ehttp/http.go:94  [ee-go] http server http://127.0.0.1:7073, pid:43456
[go][stderr] INFO  job/index.go:36   [task] hello

六、迁移能力分档:T0 / T1 / T2

Go 子进程这条链路能不能算「跑通」,同样分三档看。这三档正好对应前文三层:签名管「能不能起」、可写目录管「起了会不会活」、资源同步管「改的东西进没进包」。

阶段目标验收内容
T0:可启动签名放行的二进制能被 execHNP 包随 HAP 安装并释放到sandbox;cross.run() 不再报 EACCES;goapp 进程能被拉起
T1:核心业务可用子进程常驻且服务可用ps -ef 显示完整命令行而非方括号僵尸态;HTTP 端口监听正常,curl 有返回;主进程可采集子进程 stdout / stderr 与退出码
T2:可发布覆盖实际运行的边界情况设备重启后自启、子进程异常退出后的重启策略、多实例下的端口冲突、release 包与 debug 包行为一致、目标设备回归

T0 与 T1 之间的落差最小、也最容易迷惑人:进程能起来不代表能活下来,而两者的表现都是「应用里没反应」。这也是为什么 3.2 节要先打开 stdio 把子进程的真实报错捞出来——没有日志,这两档根本分不开。

七、常见问题清单

同一批坑换个说法再列一遍没意义,这里只做成速查索引,具体做法看对应小节:

现象先看哪里
ee-core.*.log 里翻不到任何异常业务子进程的日志在 ee.*.log,不在框架日志里(3.2)
子进程的报错一个字都看不到cross.run() 默认不转发 stdio,临时开 pipe 并挂监听(3.2)
opt.env 配了却没生效直接改 process.env 再调用 cross.run()(3.3)
改了 HOME 还是报同一个错目标运行时可能先走系统级用户查询,要改依赖源码的检查顺序(3.3、3.4)
代码改了,装到设备上问题依旧两份 main.js,先确认资源同步跑过(4.1)
go mod tidy 连不上 proxy.golang.org单次命令临时加 GOPROXY=https://goproxy.cn,direct(3.4)

八、总结

三层缺一层都跑不起来:HNP 签名解决"能不能起",可写目录解决"起了会不会活",资源同步解决"改的东西到底进没进包"。麻烦的是这三者的故障现象高度相似,都是起不来或者起来就死,所以按这个顺序逐层确认,比盯着报错文案猜要快得多。

环境变量那一层最绕。env 字段可能压根没被框架转发,目标运行时又可能根本不读这个变量——两头任何一头没打通,现象一模一样。要改第三方依赖的源码时,先 replace 到本地联调,验证通过再发版本,别让 go.sum 和本机的临时路径扯上关系。

最后,HAP 资源同步是整条链路里最容易漏的一步。改完代码顺手确认一下两份产物是否一致,能省掉大量"改了但没生效"的排查时间。

参考与延伸

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐