Developing a Monorepo BFF Demo with NPM Workspaces and Next.js

monorepo 和 npm workspaces

 

在现代的Web开发中,项目变得越来越复杂。你可能有前端应用、后端API、共享的工具库等。如果所有这些都在不同的仓库中管理,会很难同步更新和依赖管理。Monorepo 和 npm workspaces 可以帮助解决这些问题。

什么是 Monorepo?

Monorepo 是“Mono Repository”的缩写,意味着所有的项目都存储在一个仓库中。这意味着,不同的项目或团队可以共享代码、依赖关系和其他资源,同时还能维护各自的开发和发布流程。

什么是 npm workspaces?

npm workspaces 是 npm 提供的一个功能,让你在 Monorepo 结构中更轻松地管理多个包或项目。在一个使用 npm 工作区(workspaces)的 monorepo 结构中,通常只有根目录下会生成一个package-lock.json文件。这个文件会包含 monorepo 中所有工作区的依赖信息。标准的 npm 工作区结构不会为每个子工作区生成自己的package-lock.json

 

一个具体的例子,以说明在一个 monorepo 结构中使用单一的 package-lock.json 的好处。

假设您有一个名为“MegaApp”的大型项目,它是一个 monorepo,包含以下三个工作区或子项目:

  1. frontend:一个React前端应用。
  1. backend:一个Node.js的后端API。
  1. utils:共享的工具库,前端和后端都依赖于它。

这三个子项目都依赖于一个名为“AwesomeLib”的第三方库。在项目开始时,所有子项目都使用的是“AwesomeLib”的1.0.0版本。

现在,假设frontend团队发现了“AwesomeLib”的一个新功能,并决定升级到1.1.0版本。如果每个子项目都有它自己的 package-lock.json,则frontend工作区可以轻松进行这种升级,而不影响其他部分。但很快,backend团队可能会遇到与1.0.0版本的“AwesomeLib”相关的一个已知错误,并决定也要升级到1.1.0。这时,你就会得到一个状态,其中两个工作区使用的是1.1.0版本,而utils工作区仍然使用的是1.0.0版本,这可能会导致难以追踪的错误和不一致。

有了单一的package-lock.json,当frontend团队升级到“AwesomeLib”的1.1.0版本时,整个 monorepo 都会跟随升级。这确保所有子项目都使用相同版本的依赖项,从而减少了潜在的版本冲突。

此外,当其他团队成员或新的团队成员拉取项目并运行npm install时,由于有一个统一的锁定文件,他们会得到确切相同的依赖版本,确保了项目的一致性和稳定性。

 

最后说一下 npm workspaces 的一些主要特点:

优点:

  1. 统一的依赖管理:所有子项目在同一个 node_modules 中共享依赖,这有助于确保整个 monorepo 中的所有项目使用相同版本的依赖项。
  1. 代码重用:共享公共逻辑或组件变得更加简单,因为所有的代码都在同一个代码库中。
  1. 原子提交:对多个项目的更改可以在一个单独的提交中完成,这使得跨项目的变更更容易跟踪和管理。
  1. 简化的版本管理:在 monorepo 中,所有子项目的版本可以一起更新,这简化了版本控制的过程。
  1. 跨项目的工具集成:例如,你可以在整个 monorepo 中使用一个统一的 linting 或测试设置。

缺点:

  1. 增大的代码库大小:随着时间的推移和项目的增加,代码库可能会变得非常大,这可能会导致一些工具(如某些 CI/CD 系统)运行变慢。
  1. 更复杂的CI/CD配置:需要更细致的策略来只构建和测试受到更改影响的项目。
  1. 潜在的版本冲突:如果不同的子项目需要不同版本的依赖,管理它们可能会变得更加困难。
  1. 学习曲线:对于那些不熟悉 monorepo 的开发者来说,初次设置和管理可能需要一些时间来适应。
  1. 隔离性问题:在单一的代码库中,一个子项目中的错误或问题可能会影响其他项目。

尽管如此,许多大型组织和项目(如 Facebook、Google 和 Babel)已经成功地采用了 monorepo,并证明了其可行性和效益。

 

Docker与NPM Workspaces: 如何平衡部署和依赖管理

我们常常会有多个子项目在一个大项目中。在这种情境下,如何管理和部署每个子项目变得至关重要。特别是,当我们想要结合Docker和npm workspaces时,这种管理变得更为复杂。

 

问题背景

  1. 当使用Docker来生成docker镜像以进行环境部署时,通常需要在每个子项目里都有package-lock.json
  1. 然而,npm workspaces建议在子项目中不要有各自的package-lock.json
 

这两点看似矛盾,那么如何找到一个平衡点?

 

解决方案

为了平衡这两个需求,我们可以考虑在构建docker镜像时,从根目录复制package-lock.json供每个子项目使用。

 

踩的坑以及如何爬出来 (bushi

  1. 使用根目录的package-lock.json,会导致子项目安装了不必要的依赖吗?
      • 答案: 不会。利用根目录下的package-lock.json来执行npm install,系统只会安装子项目自己需要的包。因此,构建或部署的时间不会因为多余的依赖而增加。
  1. 更新某个子项目的包版本时,会不会导致所有子项目的依赖都需要同时更新和测试?
      • 答案: 应该不会。在docker内部,我们可以使用特定指令(例如npm run dev -w myapp)来为特定子项目单独安装或更新依赖。这确保了更新某个子项目的依赖版本不会对其他子项目造成影响。
 

参考

Docker setup for yarn workspaces

Using npm Workspaces with Docker

 

来创建一个demo吧

首先,创建一个新的空的目录并初始化一个新的NPM项目:

mkdir monorepo-demo
cd monorepo-demo
npm init -y

在项目的根目录中创建工作区的目录结构:

mkdir packages
cd packages
mkdir frontend
mkdir bff

这样就创建了 frontend 和 bff 两个工作区。

 

现在我们需要在每个工作区中创建对应的Next.js和Nest.js应用:

 

在frontend工作区中创建一个Next.js应用:

cd frontend
npx create-next-app .
 

在bff工作区中创建一个Nest.js应用: 首先全局安装@nestjs/cli

npm install -g @nestjs/cli
 

然后在bff工作区中创建一个新的Nest.js应用:

cd ../bff
nest new .
 

现在你的目录结构应该是这样的:

monorepo-demo

  • packages
    • frontend (Next.js 应用)
    • bff (Nest.js 应用)
    •  

      接下来我们需要配置npm工作区。在 package.json 中,我们将配置npm工作区。将以下内容添加到你的 package.json

      {
        "name": "monorepo-demo",
        "private": true,
        "workspaces": ["packages/*"]
      }
       

      首先,我们需要创建用户服务。在 Nest.js 应用中,添加一个新的 users 模块:

      cd packages/bff
      nest generate module users
      nest generate service users
      nest generate controller users

      这将会创建 users 模块,服务和控制器。

       

      users.service.ts 中,我们会创建一个模拟的用户数据:

      import { Injectable } from '@nestjs/common';
      
      @Injectable()
      export class UsersService {
        private readonly users = [
          { id: 1, name: 'John Doe' },
          { id: 2, name: 'Alice Cullen' },
          { id: 3, name: 'Bob Hope' },
        ];
      
        findAll() {
          return this.users;
        }
      }
       

      然后我们在 users.controller.ts 中添加一个新的API端点,通过调用 UsersService 来获取所有的用户:

      import { Controller, Get } from '@nestjs/common';
      import { UsersService } from './users.service';
      
      @Controller('users')
      export class UsersController {
        constructor(private usersService: UsersService) {}
      
        @Get()
        findAll() {
          return this.usersService.findAll();
        }
      }
       

      现在,你可以启动 Nest.js 服务,然后在浏览器中访问 http://localhost:3001/users,你应该可以看到我们模拟的用户数据。

       

      然后我们需要在 Next.js 应用中创建一个新的页面来展示这些用户。在 pages/users.js 中添加以下内容:

      import axios from 'axios';
      
      export default function Users({ users }) {
        return (
          <div>
            <h1>Users:</h1>
            {users.map((user) => (
              <div key={user.id}>{user.name}</div>
            ))}
          </div>
        );
      }
      
      export async function getServerSideProps() {
        const res = await axios.get('http://localhost:3001/users');
        return { props: { users: res.data } };
      }
       

      最后,你可以在根目录下运行以下命令来分别启动你的应用:

      npm run dev -w frontend
      npm run start -w bff
       

      然后你可以在浏览器中访问 http://localhost:3000/users,你将看到从 BFF 获取的用户数据被正确地显示在页面上。

       

      之后可以自己添加一些数据交互来丰富bff的功能。比如删除user, 重置user数据, 添加user等。

       
      Notion image
       

      源码已经公开,请参照下面的github repo: