前言
新版 cnpmcore ( npmmirror.com
同款 )出世已久,但部署却十分麻烦,如果是小范围场景使用,建议直接使用 verdaccio
,原因是: docker 一键启动,自带界面可登录、注册、管理包,十分方便,只需将内部包都发布至一个 @scope
下然后对内部包做分流即可:
# .npmrc
@scope:registry=http://registry.domain.com/
registry=https://registry.npmmirror.com/
这样可以最大限度节省资源,不会造成多人同时下载大量依赖非常慢的问题,是最推荐的解决方案。
如果使用 cnpmcore
,就要至少面临以下问题:
-
mysql
-
redis
-
s3 或 oss 存储
-
默认没有 web 管理界面
-
需要 oss 统一鉴权登录
光基础依赖就比较多,且部署过程较繁琐,但好处是能支持较大规模的请求,高扩展性,能支持多大的体量取决于你有多少服务器资源,适合大场景使用。
如果就是有资源任性且愿折腾也可以无脑使用 cnpmcore
,下面开始部署教程!
正文
准备数据存储
redis
准备一个 redis ,或者是集群的 redis 基建。
mysql
准备一个 mysql 或者 mariadb ,或者是基建数据库集群。
把 cnpmcore
仓库 clone 下来,主要用到根目录的以下文件来初始化数据库的表:
- sql
- xxxx.sql
- ...
- prepare-database.sh
prepare-database.sh
会排着运行 sql/*.sql
里面的数据库 migration 性质的脚本来初始化表,修改一下 prepare-database.sh
里面开头的数据库配置为你的配置:
# `prepare-database.sh`
# read variables from environment
db_host=127.0.0.1
db_port=3306
db_username=root
db_password=""
# ↓ 这个不要改,生产和本地就在 cnpmcore 库里没问题,如果是本地还有测试环境可以改成其他的名字
db_name=cnpmcore
之后运行 prepare-database.sh
建表即可,如果在 docker 中运行 mysql ,把 sql/*
和 prepare-database.sh
用 docker cp
拷贝进容器运行就可以。
如果你在基建平台上,可以参照 prepare-database.sh
的脚本,手工排着运行 sql/*.sql
下面的 mysql 脚本来建表。
注意使用 mariadb 的时候,命令是 mariadb
而不是 mysql
:
# mariadb
mariadb -u root -e "sql command"
# mysql
mysql -u root -e "sql command"
docker (or 本地调试)
如果在本地调试,或者打算用 docker 准备 reids
和 mysql
,直接使用 cnpmcore
仓库根目录的 docker-compose.yml
即可。
注意:
-
权限问题:在容器里默认就有
MYSQL_USER
这个环境变量,是你在docker-compose.yml
里指定的,假如值是user
,这个用户可能没权限建表,需要先赋权,能用root
用户还是用root
操作吧。 -
命令问题:
cnpmcore
仓库里的docker-compose.yml
默认用的 mariadb ,所以进到容器里之后,数据库操作命令是mariadb -u root -e "..."
,不是mysql ...
。
准备 cnpmcore 代码
这一环是最细致繁琐的,但本文会尽可能描述详细。
准备模板代码
cnpmcore
没给我们准备现成的 example ,需要我们一点点复制粘贴,先 clone 一份 eggjs/examples 仓库,把里面的 hello-tegg 文件夹单独拷贝出来作为我们的起始模板项目,并使用 npm >= 9
版本安装依赖:
npm i
注:虽然 pnpm
现在是公认的现代包管理器,但 cnpmcore 用到了幽灵和隐形依赖,为了不出问题还是用 npm 才安(bu)全(an quan)。
新增 cnpmcore 基础代码
根据这个 commit :
一丝不漏的,每个文件细心的 复制、更改 到我们准备的模板项目里。
之后 npm 重装依赖:
npm i
之后新增一个 app/infra/SearchAdapter.ts
文件:
// app/infra/SearchAdapter.ts
import { AccessLevel, SingletonProto } from '@eggjs/tegg'
// import { ESSearchAdapter } from 'cnpmcore/infra/SearchAdapter'
/**
* Use elasticsearch to search the huge npm packages.
*/
@SingletonProto({
accessLevel: AccessLevel.PUBLIC,
name: 'searchAdapter',
})
export class MySearchAdapter {}
如注释所述,这是用 ES 来加快搜索的,但这个功能比较新,所以不在上面的 commit 里,需要我们手动添加这个文件,否则项目启动不了。
之所以这里是空的 class ,是因为这个功能我们用不到(可以配置 cnpmcore 是否启用),如果你需要用到 ES ,则参考官方实现 cnpmcore/infra/SearchAdapter
复制修改即可。
修改 cnpmcore 配置
cnpmcore 的配置在 config/config.default.ts
文件里,我们关注以下几项。
数据库配置
// config/config.default.ts
// ...
config.orm = {
client: 'mysql',
database: process.env.MYSQL_DATABASE || 'cnpmcore',
host: process.env.MYSQL_HOST || '127.0.0.1',
port: process.env.MYSQL_PORT || 3306,
user: process.env.MYSQL_USER || 'root',
password: process.env.MYSQL_PASSWORD,
charset: 'utf8mb4',
};
// ...
↑ 这是 mysql 的配置,如果有多个数据库,参考 @eggjs/tegg-orm-plugin 插件源码配置 datasources
即可。
// config/config.default.ts
// ...
config.redis = {
client: {
port: 6379,
host: '127.0.0.1',
password: '',
db: 0,
},
};
// ...
↑ 这是 redis 的配置,如果是集群多片,参考 egg-redis 的文档配置 cluster 即可。
nfs 存储配置
// config/config.default.ts
// ...
config.nfs = {
client: null,
dir: join(config.dataDir, 'nfs'),
};
// ...
↑ 这是 nfs 也就是 npm 包存储的配置,常见的存储方式比如 oss
或者 s3
,更推荐用 s3 ,参考 官方配置示例 配置 s3 的 client 即可:
const S3Client = require('s3-cnpmcore');
config.nfs.client = new S3Client({
// ...
});
如果在本地调试,则不需要配置 client
,使用 null
就可以,默认会使用 fs-cnpm
这个包,在本地以文件形式进行存储。
在生产运行时,client
必须配置一种存储策略,否则无法启动(也可以配置 fs-cnpm
从而在生产中使用 fs 存储,但 fs 形式效率很差,使用 cnpmcore 大规模使用的意义也就不存在了,代码参考 FSClient
)。
cnpmcore 核心配置
所有默认配置项见:cnpmcpre/config/config.default.ts 。
由于官方没有任何文档,所以配置项的作用我们要自己理解。除了可以通过配置项的名字来理解意思,还可以通过之前我们模板项目里复制的代码里的注释来进一步认识这些配置项,但可能仍有选项不知道是什么意思,这种情况大概率你用不到这个选项,如果想知道什么意思,请自行阅读 cnpmcore 源码。
下面提几个比较重要的:
// config/config.default.ts
// 配置用到的枚举来源
import {
SyncMode,
SyncDeleteMode,
ChangesStreamMode,
} from 'cnpmcore/common/constants'
// ...
// 改成你的 npm 名字,比如 xnpm
name: 'mynpm',
// npm 上游你可以用 npmmirror ,这样不需要特殊的网络环境,但代价是 npmmirror 更新也是滞后的,有时候还会挂,但够用了
// 如果用官方源 `https://registry.npmjs.com` ,需把 `sourceRegistryIsCNpm` 设置为 `false` ,同时准备好良好的网络环境
sourceRegistry: 'https://registry.npmmirror.com',
sourceRegistryIsCNpm: true,
// 同步上游的策略,建议设置为 none ,只存储私有包,其他公有包一概 redirect 到上游去,减少资源占用
syncMode: SyncMode.none,
// 公司里应该限制所有的私有包都发到一个或几个独立的 @scope 下面,这样方便管理和统计,也方便配置 .npmrc 分流,减少资源使用
allowScopes: ['@company'],
allowPublishNonScopePackage: false
// 管理员,管理员可以无视 allowScopes 发布限制之外的包,建议不要配置 admins ,除非你要 unpublish 什么东西
admins: {}
// ...
修改完以上配置,cnpmcore 就基本达到可用状态了。
本地调试 cnpmcore
-
确保数据库:上文提到本地调试时,可以用官方仓库的
docker-compose.yml
准备数据库环境,当然建表要手动操作。 -
确保配置项:
config/config.default.ts
是任何环境都使用的默认配置,你也可以创建config.local.ts
/config.prod.ts
来区分不同环境的配置便于调试,详见 eggjs > config 。
确保好配置与数据库都满足本地调试之后,就可以使用 npm run dev
启动调试了:
npm run dev
若无法正常启动,自行根据报错排查即可。
若项目正常启动,则参考 cnpmcore > 功能验证 进行登录测试:
# 登录我们的 npm ,会弹出登录连接,点击后提示认证成功
npm login --registry=http://127.0.0.1:7001
# 认证成功后,查看当前用户
npm whoami --registry=http://127.0.0.1:7001
此时可以随便新建一个包,测试下发包是否正常:
npm publish --registry http://127.0.0.1:7001
注:
-
下载该包时通过
.npmrc
配置@scope
分流走我们的 npm 即可(参考本文前言
中的配置示例)。 -
本地调试时,发布的包会存储在项目根目录的
.cnpmcore/*
下面以文件形式存在。 -
用户数据存放在数据库
users
表中,本地自己的 npm token 存放于~/.npmrc
中,所有 Token 存放在数据库的tokens
表中,默认 Token 无过期时间,通过调用相关 service 方法或修改数据库达成删除 token 、创建有过期时间的 token 等,请自行学习 cnpmcore 源码。
准备 SSO 鉴权
可参考 《 企业级 npm 私有仓库部署方案 》 的鉴权流程:
// app/infra/AuthAdapter.ts
// ...
// 🟢 ↓ 重点关注
async getAuthUrl(ctx: EggContext): Promise<AuthUrlResult> {
const sessionId = randomUUID();
await this.redis.setex(sessionId, ONE_DAY, '');
const registry = ctx.app.config.cnpmcore.registry;
const redirectUrl = `${registry}/login/issue/token/${sessionId}`;
return {
loginUrl: redirectUrl,
doneUrl: `${registry}/-/v1/login/done/session/${sessionId}`,
};
}
// 🟡 ↓ 一般用不到,最后再讨论,不关心这个方法也可以
async ensureCurrentUser(): Promise<userResult | null> {
if (this.user) {
return this.user;
}
return null;
}
SSO 认证流程:
-
用户侧:当用户执行
npm login --registry http://my-custaom-npm-registry.com
时,npm 客户端即会请求并最终调用getAuthUrl
,将loginUrl
打印在终端里让用户点击,并在后台一直轮询doneUrl
是否已经完成登录。 -
服务侧:当用户通过终端访问
loginUrl
时,通过某种私有鉴权方式得知用户有权后,创建 Token 并通过doneUrl
下发给用户(因为 npm 客户端一直会轮询doneUrl
得知 Token 是否下发)。
我们需要自行实现 /login/issue/token/:sessionId
接口,在逻辑内检验用户是否有公司权,若有权,则下发 Token :
// app/controller/token.ts
// 以下代码片段来自于:https://blog.csdn.net/qiwoo_weekly/article/details/135376120
// 以下代码原型为 `/-/v1/login/sso/:sessionId` 接口代码的 fork 后修改
// ...
@HTTPMethod({
method: HTTPMethodEnum.GET,
path: '/login/issue/token/:sessionId',
})
async cliLogin(@Context() ctx: EggContext, @HTTPParam() sessionId: string) {
if (!sessionId) {
return { success: false, data: { message: 'need sessionId' } };
}
// 验证 sessionId 是否有效
const sessionData = await this.cacheAdapter.get(sessionId);
if (sessionData !== '') {
return { success: false, data: { message: 'invalid sessionId' } };
}
// 🟢 通过自己实现的 loginService 获取用户信息
const user = await this.loginService.getUser(ctx);
if (!user?.name || !user?.email) {
return { success: false, data: { message: 'invalid user info' } };
}
// 通过 cnpmcore UserService 保存 token
const { token } = await this.userService.ensureTokenByUser({ name: user.name, email: user.email, ip: ctx.ip });
// ↓ 这里保存的 Token 会在 `doneUrl` 的处理逻辑里被下发给用户
await this.cacheAdapter.set(sessionId, token!.token!);
// return { success: true, data: { message: 'login success' } };
// 跳转到登录成功页
ctx.redirect(`${ctx.origin}/-/v1/login/request/success`);
return;
}
只需实现以上两部分代码即可完成 SSO 的对接,在逻辑背后,redis 作为 sessionId 的临时存储中介,最终生成的 user 与 token 会存入 mysql 数据库的 users
与 tokens
表中。若你对流程仍一知半解,请阅读下文中下发逻辑背后的源代码。
考虑到用户当下的权限标识(如 cookie
)可能存在失效的可能,可以将 loginUrl
更改为 ${ssoLoginUrl}?redirect=${redirectUrl}
,其中 ssoLoginUrl
为 SSO 门户登录页面,若 SSO 门户已确保用户当下确实拥有有效的权限,则重定向到我们最终的下发 Token 接口。
编写过程中,除了需要具备类 Nestjs 的 IOC 认识外,以下内容供参考使用,便于加深整个流程的理解:
-
doneUrl
与 token 下发背后的逻辑:WebauthController
扩展内容:ensureCurrentUser
此 API 仅在内部极少数地方验证用户身份使用,具体位置可搜索 cnpmcore 源码。
只拷贝如上代码,不进一步实现细节也可以,不会影响 npm 主功能,如需具体实现,可通过 ctx
来获取鉴权信息,得知用户身份。
如需通过 NPM Auth Token 验证用户身份,可 fork 一份 cnpmcore/app/port/UserRoleManager.ts
进行鉴权,具体请阅读 cnpmcore 源码。
准备生产构建
在生产时,我们要运行 .js
而不是 .ts
,故需要编译为 .js
再运送上生产,创建一个 tsconfig.prod.ts
用于生产编译:
// tsconfig.prod.ts
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true
},
"exclude": ["node_modules"],
"include": [
"app/**/*.ts",
"app/**/*.json",
"config/**/*.ts",
"typings/**/*.ts",
]
}
// package.json
// 新增以下脚本用于打包
"clean": "rm -rf dist",
"build:copy": "cp package.json dist/package.json && cp tsconfig.json dist/tsconfig.json && cp .npmrc dist/.npmrc && cp config/module.json dist/config/module.json",
"build": "npm run clean && tsc -p ./tsconfig.prod.json && npm run build:copy"
注意 build:copy
命令中 copy 了项目需要的一些文件,包括 package.json
/ .npmrc
/ tsconfig.json
/ config/module.json
,如果你不需要 .npmrc
就不 copy ,如果你还有其他用到的 .json
文件就自己一并 copy 进去,或者用其他支持 glob 批量的拷贝工具库。
生产运行
把上一步构建的 dist
文件夹发到生产上去(或者容器里)之后,安装依赖,启动项目:
npm i
npm run start
// package.json
// 选项参考 https://github.com/eggjs/egg-scripts
// 如果运行在非 docker 的后台,需要 --daemon 参数
"start": "egg-scripts start"
扩大规模
扩大规模可以从各种方式入手,如大规模部署分流,开启 ES (参考 elasticsearch-setup ),增加 web 界面(npmmirror 的界面仓库在 cnpmweb ),扩展逻辑等。
当你需要深入 cnpmcore 或使用更多功能时,应该阅读 cnpmcore 源码,因为 cnpmcore 没有文档,一切都需要阅读源码寻找答案。
总结
当你需要进一步加深了解,或更深入的使用 cnpmcore 的功能时,需要多阅读 cnpmcore 源码,如 config/config.default.ts
、 UserService
等。
因为 cnpmcore 没有文档,需要你自己动手学习一切。
如果你搞不明白 cnpmcore 或者认为部署非常复杂,那应该使用真正便捷的 verdaccio ,因为你的需求规模大概率不会很大。