Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Yarn duplicate及解决方案 #10

Open
worldzhao opened this issue May 11, 2021 · 0 comments
Open

Yarn duplicate及解决方案 #10

worldzhao opened this issue May 11, 2021 · 0 comments

Comments

@worldzhao
Copy link
Owner

worldzhao commented May 11, 2021

什么是 Yarn duplicate

应用级 Monorepo 优化方案 中有提到过 Yarn duplicate。

使用 yarn 作为包管理器的同学可能会发现:app 在构建时会重复打包某个 package 的不同版本,即使该 package 的这些版本是可以兼容的。

举个 🌰,假设存在以下依赖关系:

monorepo-4

当 (p)npm 安装到相同模块时,判断已安装的模块版本是否符合新模块的版本范围,如果符合则跳过,不符合则在当前模块的 node_modules 下安装该模块。即 lib-a 会复用 app 依赖的 [email protected]

然而,使用 Yarn v1 作为包管理器,lib-a 会单独安装一份 [email protected]

🤔 思考一下,如果 app 项目依赖的是 lib-b@^1.1.0,这样是不是就没有问题了?

yarn-duplicate

app 安装 lib-b@^1.1.0 时,lib-b 的最新版本是 1.1.0,则 [email protected] 会在 yarn.lock 中被锁定。

若过了一段时间安装 lib-a,此时 lib-b 的最新版本已经是 1.2.0,那么依旧会出现 Yarn duplicate,所以这个问题还是比较普遍的。

虽然将公司的 Monorepo 项目迁移至了 Rush 以及 pnpm,很多项目依旧还是使用的 Yarn 作为底层包管理工具,并且没有迁移计划。

对于此类项目,我们可以使用 yarn-deduplicate 这个命令行工具修改 yarn.lock 来进行 deduplicate。

yarn-deduplicate — The Hero We Need

基本使用

按照默认策略直接修改 yarn.lock

npx yarn-deduplicate yarn.lock

处理策略

--strategy <strategy>

highest 策略

默认策略,会尽量使用已安装的最大版本。

例一,存在以下 yarn.lock:

library@^1.0.0:
  version "1.0.0"

library@^1.1.0:
  version "1.1.0"

library@^1.0.0:
  version "1.3.0"

修改后结果如下:

library@^1.0.0, library@^1.1.0:
  version "1.3.0"

library@^1.0.0, library@^1.1.0 会被锁定在 1.3.0(当前安装的最大版本)。

例二:

将 library@^1.1.0 改为 [email protected]

library@^1.0.0:
  version "1.0.0"

[email protected]:
  version "1.1.0"

library@^1.0.0:
  version "1.3.0"

修改后结果如下:

[email protected]:
  version "1.1.0"

library@^1.0.0:
  version "1.3.0"

[email protected] 不变,library@^1.0.0 统一至当前安装最大版本 1.3.0。

fewer 策略

会尽量使用最少数量的 package,注意是最少数量,不是最低版本,在安装数量一致的情况下,使用最高版本

例一:

library@^1.0.0:
  version "1.0.0"

library@^1.1.0:
  version "1.1.0"

library@^1.0.0:
  version "1.3.0"

修改后结果如下:

library@^1.0.0, library@^1.1.0:
  version "1.3.0"

注意:与 highest策略没有区别

例二:

将 library@^1.1.0 改为 [email protected]

library@^1.0.0:
  version "1.0.0"

[email protected]:
  version "1.1.0"

library@^1.0.0:
  version "1.3.0"

修改后结果如下:

library@^1.0.0, library@^1.1.0:
  version "1.1.0"

可以发现使用 1.1.0 版本才可以使得安装版本最少。

渐进式更改

一把梭很快,但可能带来风险,所以需要支持渐进式的进行改造。

--packages <package1> <package2> <packageN>

指定特定 Package

--scopes <scope1> <scope2> <scopeN>

指定某个 scope 下的 Package

诊断信息

--list

仅输出诊断信息

yarn-deduplicate 原理解析

基本流程

通过查看 yarn-deduplicate 的 package.json,可以发现该包依赖了以下 package:

  • commander 完整的 node.js 命令行解决方案;
  • @yarnpkg/lockfile 解析或写入 yarn.lock 文件;
  • semver The semantic versioner for npm,可以用来判断安装版本是否满足 package.json 要求版本。

源码中主要有两个文件:

  1. cli.js,命令行相关能力。解析参数并根据参数执行 index.js 中的方法。
  2. index.js。主要逻辑代码。

yarn-duplicate-1

可以发现关键点在 getDuplicatedPackages

Get Duplicated Packages

首先,明确 getDuplicatedPackages 的实现思路。

假设存在以下 yarn.lock,目标是找出 lodash@^4.17.15bestVersion

lodash@^4.17.15:
  version "4.17.21"

[email protected]:
  version "4.17.16"
  1. 通过 yarn.lock 分析出 lodash@^4.17.15requestedVersion^4.17.15installedVersion4.17.21
  2. 获取满足requestedVersion(^4.17.15) 的所有 installedVersion,即 4.17.214.17.16
  3. installedVersion 中挑选出满足当前策略的 bestVersion(若当前策略为 fewer ,那么 lodash@^4.17.15bestVersion4.17.16,否则为 4.17.21)。

👆🏻 这个过程很重要,是后续代码的指导原则

类型定义

const getDuplicatedPackages = (
  json: YarnLock,
  options: Options
): DuplicatedPackages => {
  // todo
};

// 解析 yarn.lock 获取到的 object
interface YarnLock {
  [key: string]: YarnLockVal;
}

interface YarnLockVal {
  version: string; // installedVersion
  resolved: string;
  integrity: string;
  dependencies: {
    [key: string]: string;
  };
}

// 类似于这种结构
const yarnLockInstanceExample = {
  // ...
  "lodash@^4.17.15": {
    version: "4.17.21",
    resolved:
      "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c",
    integrity:
      "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
    dependencies: {
      "fake-lib-x": "^1.0.0", // lodash 实际上没有 dependencies
    },
  },
  // ...
};

// 由命令行参数解析而来
interface Options {
  includeScopes: string[]; // 指定 scope 下的 packages 默认为 []
  includePackages: string[]; // 指定要处理的 packages 默认为 []
  excludePackages: string[]; // 指定不处理的 packages 默认为 []
  useMostCommon: boolean; // 策略为 fewer 时 该值为 true
  includePrerelease: boolean; // 是否考虑 prerelease 版本的 package 默认为 false
}

type DuplicatedPackages = PackageInstance[];

interface PackageInstance {
  name: string; // package name 如 lodash
  bestVersion: string; // 在当前策略下的最佳版本
  requestedVersion: string; // 要求的版本 ^15.6.2
  installedVersion: string; // 已安装的版本 15.7.2
}

最终目标是获取 PackageInstance

获取 yarn.lock 数据

const fs = require("fs");
const lockfile = require("@yarnpkg/lockfile");

const parseYarnLock = (file) => lockfile.parse(file).object;

// file 字段通过 commander 从命令行参数获取
const yarnLock = fs.readFileSync(file, "utf8");
const json = parseYarnLock(yarnLock);

yarn.lock 对象结构美化

我们需要根据指定范围的参数 Options 过滤掉一些 package。

同时 yarn.lock 对象中的 key 都是 lodash@^4.17.15 的形式,这种键名形式不便于查找数据。

可以统一以 lodashkeyvalue 为一个数组,数组项为不同的版本信息,方便后续处理,最终我们需要将 yarn.lock 对象转为下面 ExtractedPackages 的结构。

interface ExtractedPackages {
  [key: string]: ExtractedPackage[];
}

interface ExtractedPackage {
    pkg: YarnLockVal;
    name: string;
    requestedVersion: string;
    installedVersion: string;
    satisfiedBy: Set<string>;
}

satisfiedBy 就是用于存储满足此 package requestedVersion 的所有 installedVersion,默认值为 new Set() ,待后续补充。

从该 set 中取出满足策略的 installedVersion ,即为 bestVersion

具体实现如下:

const extractPackages = (
  json,
  includeScopes = [],
  includePackages = [],
  excludePackages = []
) => {
  const packages = {};
  // 匹配 yarn.lock object key 的正则
  const re = /^(.*)@([^@]*?)$/;

  Object.keys(json).forEach((name) => {
    const pkg = json[name];
    const match = name.match(re);

    let packageName, requestedVersion;
    if (match) {
      [, packageName, requestedVersion] = match;
    } else {
      // 如果没有匹配数据,说明没有指定具体版本号,则为 * (https://docs.npmjs.com/files/package.json#dependencies)
      packageName = name;
      requestedVersion = "*";
    }

    // 根据指定范围的参数过滤掉一些 package

    // 如果指定了 scopes 数组, 只处理相关 scopes 下的 packages
    if (
      includeScopes.length > 0 &&
      !includeScopes.find((scope) => packageName.startsWith(`${scope}/`))
    ) {
      return;
    }

    // 如果指定了 packages, 只处理相关 packages
    if (includePackages.length > 0 && !includePackages.includes(packageName))
      return;

    if (excludePackages.length > 0 && excludePackages.includes(packageName))
      return;

    packages[packageName] = packages[packageName] || [];
    packages[packageName].push({
      pkg,
      name: packageName,
      requestedVersion,
      installedVersion: pkg.version,
      satisfiedBy: new Set(),
    });
  });
  return packages;
};

在完成 packages 的抽离后,我们就有了同一个 package 的不同版本信息。

{
    // ...
    "lodash": [
        {
            "pkg": YarnLockVal,
            "name": "lodash",
            "requestedVersion": "^4.17.15",
            "installedVersion": "4.17.21",
            "satisfiedBy": new Set()
        },
        {
            "pkg": YarnLockVal,
            "name": "lodash",
            "requestedVersion": "4.17.16",
            "installedVersion": "4.17.16",
            "satisfiedBy": new Set()
        }
    ]
}

我们需要补充其中每一个数组项的 satisfiedBy 字段,并且通过其计算出满足当前 requestedVersionbestVersion,这个过程称之为 computePackageInstances

Compute Package Instances

相关类型定义如下:

const computePackageInstances = (
  packages: ExtractedPackages,
  name: string,
  useMostCommon: boolean,
  includePrerelease = false
): PackageInstance[] => {
  // todo
};

interface PackageInstance {
  name: string; // package name 如 lodash
  bestVersion: string; // 在当前策略下的最佳版本
  requestedVersion: string; // 要求的版本 ^15.6.2
  installedVersion: string; // 已安装的版本 15.7.2
}

实现 computePackageInstances 可以分为三个步骤:

  1. 获取当前 package 的全部 installedVersion
  2. 补充 satisfiedBy 字段;
  3. 通过 satisfiedBy 计算出 bestVersion

获取全部 installedVersion

/**
 * versions 记录当前 package 所有 installedVersion 的数据
 * satisfies 字段用于存储当前 installedVersion 满足的 requestedVersion
 * 初始值为 new Set()
 * 通过该字段的 size 可以分析出满足 requestedVersion 数量最多的 installedVersion
 * 用于 fewer 策略
 */
interface Versions {
  [key: string]: { pkg: YarnLockVal; satisfies: Set<string> };
}

// 当前 package name 对应的依赖信息
const packageInstances = packages[name];

const versions = packageInstances.reduce((versions, packageInstance) => {
  if (packageInstance.installedVersion in versions) return versions;
  versions[packageInstance.installedVersion] = {
    pkg: packageInstance.pkg,
    satisfies: new Set(),
  };
  return versions;
}, {} as Versions);

具体 versionsatisfies 字段用于存储当前 installedVersion 满足的全部 requestedVersion,初始值为 new Set(),通过该 setsize 可以分析出满足 requestedVersion 数量最多的 installedVersion,用于 fewer 策略。

补充 satisfiedBysatisfies 字段

// 遍历全部的 installedVersion
Object.keys(versions).forEach((version) => {
  const satisfies = versions[version].satisfies;
  // 逐个遍历 packageInstance
  packageInstances.forEach((packageInstance) => {
    // packageInstance 自身的 installedVersion 必定满足自身的 requestedVersion
    packageInstance.satisfiedBy.add(packageInstance.installedVersion);
    if (
      semver.satisfies(version, packageInstance.requestedVersion, {
        includePrerelease,
      })
    ) {
      satisfies.add(packageInstance);
      packageInstance.satisfiedBy.add(version);
    }
  });
});

根据 satisfiedBysatisfies 计算 bestVersion

packageInstances.forEach((packageInstance) => {
  const candidateVersions = Array.from(packageInstance.satisfiedBy);
  // 进行排序
  candidateVersions.sort((versionA, versionB) => {
    // 如果使用 fewer 策略,根据当前 satisfiedBy 中 `satisfies` 字段的 size 排序
    if (useMostCommon) {
      if (versions[versionB].satisfies.size > versions[versionA].satisfies.size)
        return 1;
      if (versions[versionB].satisfies.size < versions[versionA].satisfies.size)
        return -1;
    }
    // 如果使用 highest 策略,使用最高版本
    return semver.rcompare(versionA, versionB, { includePrerelease });
  });
  packageInstance.satisfiedBy = candidateVersions;
  packageInstance.bestVersion = candidateVersions[0];
});

return packageInstances;

这样,我们就找到了同一 package 不同版本的 installedVersion 和所需要的 bestVersion

完成 getDuplicatedPackages

const getDuplicatedPackages = (
  json,
  {
    includeScopes,
    includePackages,
    excludePackages,
    useMostCommon,
    includePrerelease = false,
  }
) => {
  const packages = extractPackages(
    json,
    includeScopes,
    includePackages,
    excludePackages
  );
  return Object.keys(packages)
    .reduce(
      (acc, name) =>
        acc.concat(
          computePackageInstances(
            packages,
            name,
            useMostCommon,
            includePrerelease
          )
        ),
      []
    )
    .filter(
      ({ bestVersion, installedVersion }) => bestVersion !== installedVersion
    );
};

结语

本文通过介绍 Yarn duplicate ,引出 yarn-deduplicate 作为解决方案,并且分析了内部相关实现,期待 Yarn v2 的到来。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant