干货!NPM私服 + 自定义NFS

4,575 阅读8分钟

Cnpm,官方解释为Company npm。

由于团队需求,现在需要搭建一个npm私服,用来更方便地管理团队的组件库,并且更快速更稳定地提供服务,我踏上了搭建npm私服的道路。

Clone cnpmjs.org项目代码

git clone https://github.com/cnpm/cnpmjs.org.git

下载完代码后,咱们先来大概瞄一眼项目目录

+-- bin/                            ---一些命令脚本
|   --- nodejsctl                        ---npm start启动的脚本
|   --- ...    
+-- common/                         ---公共目录,存放日志配置、邮件配置等
+-- config/                         
|   --- index.js                    ---主要配置文件
+-- controllers/                                 
|   --- registry/                   ---7001端口的controller层
|   --- web/                        ---7002端口的controller层
|   --- sync_module_worker.js       ---sync的主进程文件        
|   --- ...                         
+-- docs/                                 
|   --- db.sql                      ---数据库建表sql
|   --- ...                         
+-- lib/           
+-- middleware/       
+-- models/                         ---数据库操作目录
+-- public/   
+-- routes/                                 
|   --- registry.js                 ---7001端口的路由文件
|   --- web.js                      ---7002端口的路由文件
|   --- ...                         
+-- servers/                                 
|   --- registry.js                 ---7001端口的服务器入口文件
|   --- web.js                      ---7002端口的服务器入口文件
|   --- ...                         
+-- services/
+-- sync/                                 
|   --- sync_all.js                 ---sync模式选择all时执行的文件
|   --- sync_exist.js               ---sync模式选择exist时执行的文件
|   --- ...    
+-- test/
+-- tools/
+-- view/
--- dispatch.js                     ---启动npm服务的主要文件,bin/nodejsctl中执行的就是这个文件
--- package.json   

我们可以发现,cnpm使用的是koa框架,结构是经典的route->controller->services->model

同步模块的具体流程是在controllers/sync_module_worker.js文件中的

1. 根据设置的sync模式,从上游源中下载模块到一个临时路径/root/.cnpmjs.org/downloads/xxxxx.tgz
2. 调用nfs.upload方法将临时路径存储的tgz上传到指定存储位置
3. 无论是否上传成功,都删除刚刚下载的临时文件

看到这里,喜欢思考的同学或许会说了,我到底应该怎么搭建自己的npm私服?你说了半天,我还是啥都不知道,比如吧:

  1. sync模式怎么选择呢?
  2. 上游源是什么呢?
  3. 临时路径为什么是root/.cnpmjs.org/downloads?我能随意修改吗?
  4. nfs.upload是什么呢,到底是将tgz上传到哪里呢?
  5. 需要数据库吗,数据库配置又在哪里呢????
  6. 7001端口是什么,7002端口又是什么??
  7. 搭建完成后,我应该怎么使用我的私服?

配置config/index.js文件~

var config = {
  version: version,
  dataDir: dataDir,

  /**
   * Cluster mode
   */
  enableCluster: true,
  numCPUs: os.cpus().length,

  /*
   * server configure
   */

  registryPort: 7001,
  webPort: 7002,
  bindingHost: '0.0.0.0', // only binding on 127.0.0.1 for local access

  // debug mode
  // if in debug mode, some middleware like limit wont load
  // logger module will print to stdout
  debug: process.env.NODE_ENV === 'development',
  
  // page mode, enable on development env
  pagemock: process.env.NODE_ENV === 'development',
  
  // session secret
  sessionSecret: 'cnpmjs.org test session secret',
  
  // max request json body size
  jsonLimit: '10mb',
  
  // log dir name
  logdir: path.join(dataDir, 'logs'),
  
  // update file template dir
  uploadDir: path.join(dataDir, 'downloads'),
  
  // web page viewCache
  viewCache: false,

  // config for koa-limit middleware
  // for limit download rates
  limit: {
    enable: false,
    token: 'koa-limit:download',
    limit: 1000,
    interval: 1000 * 60 * 60 * 24,
    whiteList: [],
    blackList: [],
    message: 'request frequency limited, any question, please contact fengmk2@gmail.com',
  },

  enableCompress: true, // enable gzip response or not

  // default system admins
  admins: {
    // name: email
    sunxiuguo: 'sunxiuguo@my.com',
  },

  // email notification for errors
  // check https://github.com/andris9/Nodemailer for more informations
  mail: {
    enable: false,
    appname: 'cnpmjs.org',
    from: 'cnpmjs.org mail sender <adderss@gmail.com>',
    service: 'gmail',
    auth: {
      user: 'address@gmail.com',
      pass: 'your password'
    }
  },

  logoURL: 'https://os.alipayobjects.com/rmsportal/oygxuIUkkrRccUz.jpg', // cnpm logo image url
  adBanner: '',
  customReadmeFile: '', // you can use your custom readme file instead the cnpm one
  customFooter: '', // you can add copyright and site total script html here
  npmClientName: 'cnpm', // use `${name} install package`
  packagePageContributorSearch: true, // package page contributor link to search, default is true

  // max handle number of package.json `dependencies` property
  maxDependencies: 200,
  
  // backup filepath prefix
  backupFilePrefix: '/cnpm/backup/',

  /**
   * database config
   */

  database: {
    db: '******',  // 库名
    username: '*********', // 数据库用户名
    password: '************', // 数据库密码

    // the sql dialect of the database
    // - currently supported: 'mysql', 'sqlite', 'postgres', 'mariadb'
    dialect: 'mysql',

    // the Docker container network hostname defined at docker-compose.yml
    host: '**************',  // 数据库域名

    // custom port; default: 3306
    port: 3318,  // 数据库端口号

    // use pooling in order to reduce db connection overload and to increase speed
    // currently only for mysql and postgresql (since v1.5.0)
    pool: {
      maxConnections: 10,
      minConnections: 0,
      maxIdleTime: 30000
    },

    dialectOptions: {
      // if your server run on full cpu load, please set trace to false
      trace: true,
    },

    // the storage engine for 'sqlite'
    // default store into ~/.cnpmjs.org/data.sqlite
    storage: path.join(dataDir, 'data.sqlite'),

    logging: !!process.env.SQL_DEBUG,
  },

  // package tarball store in local filesystem by default
  nfs: aws.create({
    accessKeyId: '*************',  // s3 accessKeyId
    secretAccessKey: '****************', // s3 secretAccessKey
    // change to your endpoint
    endpoint: '*****************', // https://docs.aws.amazon.com/zh_cn/general/latest/gr/rande.html
    bucket: 'npm-online', // s3 bucket名称
    signatureVersion: 'v4', // s3 api版本
    mode: 'private', // public: 通过url下载tar包; private: 通过key下载tar包
  }),
  
  // if set true, will 302 redirect to `nfs.url(dist.key)`
  downloadRedirectToNFS: false,

  // registry url name
  registryHost: 'registry.npm.my.com',

  /**
   * registry mode config
   */

  // enable private mode or not
  // private mode: only admins can publish, other users just can sync package from source npm
  // public mode: all users can publish
  enablePrivate: false,

  // registry scopes, if dont set, means do not support scopes
  scopes: [ '@cnpm', '@sunxiuguo', '@companyName' ],

  // some registry already have some private packages in global scope
  // but we want to treat them as scoped private packages,
  // so you can use this white list.
  privatePackages: [],

  /**
   * sync configs
   */

  // the official npm registry
  // cnpm wont directly sync from this one
  // but sometimes will request it for some package infomations
  // please dont change it if not necessary
  officialNpmRegistry: 'https://registry.npmjs.com',
  officialNpmReplicate: 'https://replicate.npmjs.com',

  // sync source, upstream registry
  // If you want to directly sync from official npm registry
  // please drop them an email first
  sourceNpmRegistry: 'https://registry.npm.taobao.org',
  sourceNpmWeb: 'https://npm.taobao.org',

  // upstream registry is base on cnpm/cnpmjs.org or not
  // if your upstream is official npm registry, please turn it off
  sourceNpmRegistryIsCNpm: true,

  // if install return 404, try to sync from source registry
  syncByInstall: true,

  // sync mode select
  // none: do not sync any module, proxy all public modules from sourceNpmRegistry
  // exist: only sync exist modules
  // all: sync all modules
  syncModel: 'exist', // 'none', 'all', 'exist'

  syncConcurrency: 1,
  // sync interval, default is 10 minutes
  syncInterval: '10m',

  // sync polular modules, default to false
  // because cnpm can not auto sync tag change for now
  // so we want to sync popular modules to ensure their tags
  syncPopular: false,
  syncPopularInterval: '1h',
  // top 100
  topPopular: 100,

  // sync devDependencies or not, default is false
  syncDevDependencies: false,
  // try to remove all deleted versions from original registry
  syncDeletedVersions: true,

  // changes streaming sync
  syncChangesStream: false,
  handleSyncRegistry: 'http://127.0.0.1:7001',

  // default badge subject
  badgeSubject: 'cnpm',
  // defautl use https://badgen.net/
  badgeService: {
    url: function(subject, status, options) {
      options = options || {};
      let url = `https://badgen.net/badge/${utility.encodeURIComponent(subject)}/${utility.encodeURIComponent(status)}`;
      if (options.color) {
        url += `/${utility.encodeURIComponent(options.color)}`;
      }
      if (options.icon) {
        url += `?icon=${utility.encodeURIComponent(options.icon)}`;
      }
      return url;
    },
  },

  packagephobiaURL: 'https://packagephobia.now.sh',
  packagephobiaSupportPrivatePackage: false,

  // custom user service, @see https://github.com/cnpm/cnpmjs.org/wiki/Use-Your-Own-User-Authorization
  // when you not intend to ingegrate with your company  user system, then use null, it would
  // use the default cnpm user system
  userService: null,

  // always-auth https://docs.npmjs.com/misc/config#always-auth
  // Force npm to always require authentication when accessing the registry, even for GET requests.
  alwaysAuth: false,

  // if you are behind firewall, need to request through http proxy, please set this
  // e.g.: `httpProxy: 'http://proxy.mycompany.com:8080'`
  // httpProxy: 'http://gfw.guazi-corp.com',
  httpProxy: null,


  // snyk.io root url
  snykUrl: 'https://snyk.io',

  // https://github.com/cnpm/cnpmjs.org/issues/1149
  // if enable this option, must create module_abbreviated and package_readme table in database
  enableAbbreviatedMetadata: true,

  // global hook function: function* (envelope) {}
  // envelope format please see https://github.com/npm/registry/blob/master/docs/hooks/hooks-payload.md#payload
  globalHook: null,

  opensearch: {
    host: '',
  },
};


是不是成功打开文件了~恭喜你!你成功的迈出了第二步!
什么?你问第一步是什么?第一步是clone代码啊

配置文件解惑

  • sync模式是什么?怎么选择?

    syncModel属性控制sync模式,分为none,exist,all三种情况。

    • none:永不同步,只管理私有用户上传的包,其它源包会直接从源站获取;
    • exist:定时同步已经存在于数据库的包;
    • all:定时同步所有源站的包;
  • 上游源是什么?怎么设置?

    上游源就是你同步包的地址,比如你的上游源是淘宝源,那么你的npm私服就会从淘宝源进行包的同步。

    • sourceNpmRegistry属性控制上游源地址的设置,默认为registry.npm.taobao.org
    • sourceNpmRegistryIsCNpm属性表示上游源是否是cnpm,如果你的上游源是淘宝,此属性设置为true;如果你的上游源为官方Npm源,那么此属性设置为false
    • syncByInstall属性为true时,表示如果从你的私服源install时找不到包,那么程序会自动从上游源进行同步。
  • 临时路径在哪?我能随意修改吗?

    uploadDir属性设置同步的模块存放的临时路径,默认为path.join(dataDir, 'downloads'),即root/.cnpmjs.org/downloads

  • nfs.upload是什么?要将tgz上传到哪里?

    nfs属性控制包存储,包括上传,下载等等。nfs的意思是network file system

    • nfs默认使用的是fs-cnpm这个插件,可以看到里面定义了好多方法,比如upload,download,remove等等;
    • 可以看到配置文件中nfs传入了一个dir属性,默认为path.join(dataDir, 'nfs'),也就是root/.cnpmjs.org/nfs,即同步的包文件默认存放在这个目录下;
    • 当然这只是一种文件存储方案,我们现在是接入的amazon s3的对象存储系统来存储包,这个后面会详细说一下。
  • 需要数据库吗?数据库配置在哪里?
    • npm是需要数据库的,docs/db.sql就是建表sql,数据库存储的信息主要是包信息,用户信息,包和用户的关联信息,也会存储npm服务器各种包的下载信息等。
    • 当从上游成功同步了一个包到npm服务器时,数据库中就会记录下这个包的相关信息,包文件则会存储在nfs中。
    • database属性就是设置数据库信息的,包括库名,用户名,密码,端口号,数据库地址等等。
    • 只要把建表sql导入数据库中,创建好所有的表即可
  • 7001和7002端口分别是什么服务?

    registryPort属性默认为7001,webPort属性默认为7002.
    registry服务主要是用来提供给用户源相关操作,比如设置npm源 web服务主要是提供给用户的一个图形化管理界面,比如在界面上查询某个模块

  • 除了上面这些,还需要什么配置?
    • bindingHost: 设置为0.0.0.0,开放给外部使用
    • admins: 可以添加几个管理员用户 name: email的格式
    • registryHost:设置为npm服务器7001端口的域名,比如我搭建npm的服务器7001端口的域名为 registry.npm.my.com,就设置为这个域名
    • scopes:可以添加几个前缀,以后发布包的时候带有这些前缀的,就代表是私有包

NFS个性化配置

因为我们的npm私服是放在docker里,包文件不可能使用fs-cnpm存储在docker里,所以我们接入了amazon s3的对象存储服务。
官方提供了接入npm的协议NFS-Guide

Can download the uploaded file through http request. like qn-cnpm.
uploadBuffer: use options.key to customize the filename, then callback {url: 'http://test.com/xxx.tgz'}.
url: accept a key and respond download url.
remove: remove file by key

Can not download by http request. like sfs-client or oss-cnpm.
uploadBuffer: upload the file, and must callback {key: 'xxx'}, so cnpmjs.org can record the key, and use this key to download or remove.
download: need provide download api to download the file by key.
createDownloadStream: streaming download file by key
remove: remove file by key

如果存储系统支持通过http请求下载包文件,就提供uploadBuffer,url,remove方法 如果存储系统不支持通过http请求下载包文件,就需要提供uploadBuffer,download,createDownloadStream,remove方法。
并且所有方法都需要是async的,或者是generatord的。

NFS接入S3对象存储

因为我们使用的bucket,首先要提供一个create的方法来实例化一个s3对象。

  • create
exports.create = function (options) {
    return new AwsWrapper(options);
};

function AwsWrapper(options) {
    this.client = new S3(options);
    this.mode = options.mode;
    this.bucket = options.bucket;
    var params = {
        Bucket: options.bucket,
        CreateBucketConfiguration: {
            LocationConstraint: ":npm"//桶所在服务区
        }
    };
    this.client.createBucket(params, function (err, data) {
        if (err) {
            // an error occurred
            logger.syncInfo(err);
        } else {
            // successful response
            console.log(data.Location);
        }
    });
}

然后按照协议提供对应的方法

  • uploadBuffer

    调用路径在controllers/registry/package/save.js,当publish包时会进入这个方法,入参为fileBuffer和options;
    这个方法很简单,只需调用对应存储系统提供的api,把buffer上传即可。

    const key = trimKey(options.key);
      logger.syncInfo(`enter aws->uploadBuffer key=${key}`);
      let result = {
          key,
      };
    
      let uploadParams = {
          Bucket: this.bucket,
          Key: key,
          Body: fileBuffer
      };
    
      this.client.upload (uploadParams, function (err, data) {
          if (err) {
              logger.syncInfo(err);
          }
      });
    
  • upload

    调用路径在controllers/sync_module_worker.js,当从上游同步包的时候会进入这个方法,入参为filePath和options
    upload和uploadBuffer不同的是,upload是读取传入的filePath的文件作为body上传,uploadBuffer是直接把传入的buffer对象作为body上传。

const key = trimKey(options.key);
    logger.syncInfo(`进入aws->upload key=${key} filePath=${filePath}`);
    let result = {
        key,
    };
    let fileStream = fs.createReadStream(filePath);
    fileStream.on('error', function(err) {
        logger.syncInfo(err);
    });

    let uploadParams = {
        Bucket: this.bucket,
        Key: key,
        Body: fileStream
    };

    await this.client.upload (uploadParams, function (err, data) {
        if (err) {
            logger.syncInfo(err);
        }
    });
    return result;
  • url

    调用路径在controllers/registry/package/download.js,当下载包的时候会进入这个方法,入参为key和options,用于获取包的存放的url地址

const params = { Bucket: this.bucket, Key: trimKey(key) };
    logger.syncInfo(`进入aws->url key=${key} trimKey=${trimKey(key)}`);
    return this.client.getSignedUrl('getObject', params);
  • remove

    调用路径在controllers/registry/package/remove.js controllers/registry/package/remove_version.js 和 controllers/sync_module_worker.js,当删除包或者删除版本的时候会进入这个方法,入参为key和options

const params = { Bucket: this.bucket, Key: trimKey(key) };
    logger.syncInfo(`进入aws->remove key=${key} trimKey=${trimKey(key)}`);
    await this.client.deleteObject(params);
  • createDownloadStream

    调用路径在controllers/utils.js,当下载包的时候会进入这个方法,入参为key和options,把可读流作为用户下载请求的response的body
    utils.js中是唯一调用download和createDownloadStream的地方,然而我们仔细看源码,可以发现如果定义了createDownloadStream方法,就会直接返回createDownloadStream的结果,而不会继续进行下面的download操作。
    也就是说,我们只需要定义createDownloadStream方法即可

const params = { Bucket: this.bucket, Key: trimKey(key) };
    logger.syncInfo(`进入aws->createDownloadStream key=${key} trimKey=${trimKey(key)}`);
    return this.client.getObject(params).createReadStream();

编写NPM测试脚本

设置npm源为刚搭建的私有源
npm config set registry http://registry.npm.my.com 

查看当前的registry地址
npm get registry

清理npm缓存
npm cache clean --force 

随便选一个项目 删除node_modules包
rm -rf node_modules

安装
npm install

手动同步一个包,比如react(可以在web界面上的/sync/路径下输入包名进行同步)
npm sync react

只是手动安装一个项目的依赖包可能无法说明什么,我们来写一个简单的自动测试脚本

require('shelljs/global')

const logger = require('./log').logger;
const fs = require('fs');
const MODULE_DIR = '/node_modules';
const PARENT_PATH = '/Users/sunxiuguo/project/';
const projectName = [
    'test1',
    'test2',
    'test3',
    'test4',
]
const absolutePath = projectName.map(item => {
    return {
        modulesPath: PARENT_PATH + item + MODULE_DIR,
        parentPath: PARENT_PATH + item,
    }
});

const startTime = new Date('2018/11/06 21:00:000').getTime();
const endTime = new Date('2018/11/08 10:00:000').getTime();

/**
 * 读取路径
 * @param path
 */
function getStat(path){
    if (exec(`cd ${path}`).code == 0) {
        return true;
    }
    return false;
}

async function npmCachecleanAndInstall(projectPath) {
    cd(projectPath);
    logger.info(`cd ${projectPath}`);
    exec('pwd');
    await execAndLogAsync(`npm cache clean --force`);
    await execAndLogAsync(`npm install --registry=http://registry.npm.my.com`)
}

async function execAndLogAsync(command) {
    logger.info(command);
    let result = await exec(command);
    if (result.stderr) {
        logger.error(result.stderr);
    }
}

async function install(path) {
    logger.info(`install: path = ${JSON.stringify(path)}`);
    let isExists = getStat(path.modulesPath);
    if (!isExists) {
        // 如果不存在 npm install
        logger.info(`install: 不存在${path.modulesPath}目录,开始npm install`)
        await npmCachecleanAndInstall(path.parentPath);
    } else {
        // 如果存在,删除 && npm install
        logger.info(`install: 存在${path.modulesPath}目录,开始删除`)
        await execAndLogAsync(`rm -rf ${path.modulesPath}`);
        logger.info(`install: 删除${path.modulesPath}成功,开始npm install`)
        await npmCachecleanAndInstall(path.parentPath);
    }
}

logger.info('beginning!')

if (new Date().getTime() < startTime) {
    logger.info(`未到开始时间, 开始时间为2018/11/06 22:00:000`)
    exit(1);
}

for(let path of absolutePath) {
    (async function(){
        while (new Date().getTime() < endTime) {
            await install(path);
        }
    })()
}

来发布一个包吧!

首先添加一个用户,添加后会默认以这个用户登录
npm adduser
username:sunxiuguo
password:sunxiuguo
email:sunxiuguo1@qq.com

进入要发布的目录
npm publish

查看刚才发布的包信息(也可以在web界面上查询)
npm view moduleName

这时如果其他小伙伴也要发布这个包,就会报错了,因为其他小伙伴不是这个包的maintainer
咱们来查看一下这个包的owner都有谁
npm owner ls moduleName

然后添加wangwang为这个包的owner
npm owner add wangwang moduleName

什么?!!又报错了?!
不要慌,那是因为根本没有wangwang这个用户,需要执行npm adduser添加一下
npm adduser
username:wangwang
password:1231131313
email:wangwang@guazi.com

再次添加owner
npm owner add wangwang moduleName

成功了!从此wangwang也可以发布这个包了

以后如果想登录,直接Login即可
npm login
username:wangwang
password:1231131313
email:wangwang@guazi.com

如果想撤销发布一个包怎么办?

强调一下,撤销发布包是很危险的一件事情,如果有其他同学用了你的包,然后你心血澎湃地把这个包撤销了??其他同学肯定一脸问号
npm unpublish moduleName
  1. 根据规范,只有在发包的24小时内才允许撤销发布的包( unpublish is only allowed with versions published in the last 24 hours)
  2. 即使你撤销了发布的包,发包的时候也不能再和被撤销的包的名称和版本重复了(即不能名称相同,版本相同,因为这两者构成的唯一标识已经被“占用”了)
如果你不再维护你发布的moduleA了,可以使用下面这个命令
这个命令并不会撤销已发布的包,只是会在其他人用的你的包时收到警告
npm deprecate moduleA

NPM包的版本应该怎么维护?

版本格式:主版号.次版号.修订号,版号递增规则如下:
主版号:当你做了不相容的API 修改,
次版号:当你做了向下相容的功能性新增,
修订号:当你做了向下相容的问题修正,比如修复了一个bug。

改变当前package的版本号,update_type为patch, minor, or major其中之一,分别表示修订号,次版号,主版号
npm version <update_type>

比如当前版本号为0.1.0
npm version patch
0.1.1
npm version minor
0.2.0
npm version major
1.0.0

我都踩过哪些坑?

  • docker中npm install -g报错
Error: could not get uid/gid
[ 'nobody', 0 ]
    at /usr/lib/node_modules/npm/node_modules/uid-number/uid-number.js:37:16
    at ChildProcess.exithandler (child_process.js:205:5)
    at emitTwo (events.js:106:13)
    at ChildProcess.emit (events.js:191:7)
    at maybeClose (internal/child_process.js:891:16)
    at Socket.<anonymous> (internal/child_process.js:342:11)
    at emitOne (events.js:96:13)
    at Socket.emit (events.js:188:7)
    at Pipe._handle.close [as _onclose] (net.js:497:12)
    
    在全局安装前执行下面这条命令即可
    npm config set unsafe-perm true

  • nodejs.ErrorException: Error: stream.push() after EOF
清一下缓存
npm cache clean --force
  • publish成功了,但是install失败,报错shanumNotMatch,unexpected end of file
这个问题我自己的情况是,在controllers/utils.js里,调用nfs.download方法,writeStream还没有写完,就开始了readStream并且清理了临时路径,导致文件被截断了,所以一定要注意异步的问题,并且调试的时候尽量写好try catch和日志,方便以后定位问题。  
当然也可以直接定义一个createDownloadStream方法,直接返回可读流给body。

期间还踩过好多好多坑,遗憾的是忘记记录下来了....

以上是在下关于npm私服搭建的一点拙见,如有不足,望诸位客官多多指正。