全栈入门:以日志工具为例,搭建 TS 开发环境并发布 npm 包

199 阅读12分钟

在这篇文章中,我们将详细介绍如何从零开始搭建一个 TypeScript 开发环境,并发布一个名为 ts-logger-tools 的 npm 包。我们将涵盖项目初始化、安装 TypeScript 及开发工具、配置 TypeScript 编译器、创建项目结构和示例代码、编写测试(使用 Jest)、构建项目以及发布 npm 包的步骤。

搭建ts开发环境

1. 初始化项目

首先,我们需要创建一个新的项目文件夹并初始化一个 npm 项目。

mkdir ts-logger-tools
cd ts-logger-tools
npm init -y

npm init -y 命令会使用默认配置生成一个 package.json 文件。

2. 安装 TypeScript 和其他开发依赖

接下来,我们需要安装 TypeScript 和一些常用的开发工具。

npm install typescript ts-node @types/node --save-dev
tsc --init
  • typescript: TypeScript 编译器。
  • ts-node: 允许直接运行 TypeScript 文件,而无需先编译成 JavaScript。
  • @types/node: Node.js 的类型定义文件。

3. 配置 TypeScript

创建一个 tsconfig.json 文件来配置 TypeScript 编译器。

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./lib",
    "types": ["node", "jest"],
    "rootDir": "./src",
    "declaration": true
  },
  "include": ["src/**/*.ts", "test/**/*.test.ts"],
  "exclude": ["node_modules", "test"]
}
  • target: 指定 ECMAScript 目标版本为 ES2020,以支持现代 JavaScript 特性。
  • module: 将模块解析设置为 CommonJS,以便与 Node.js 兼容。
  • strict: 启用所有严格类型检查选项,以增强代码的类型安全性。
  • esModuleInterop: 启用对 ES 模块的互操作性,以便使用 import 语句导入 CommonJS 模块。
  • skipLibCheck: 跳过对声明文件(.d.ts 文件)的类型检查,以加快编译速度。
  • forceConsistentCasingInFileNames: 强制文件名一致大小写,以避免在不同操作系统上的文件名大小写问题。
  • outDir: 指定编译输出目录为 ./lib
  • types: 添加 Node 和 Jest 的类型定义。
  • rootDir: 指定输入文件的根目录为 ./src
  • declaration: 生成类型定义文件。
  • include: 包含 srctest 文件夹中的 TypeScript 文件。
  • exclude: 排除 node_modules 文件夹。

4.配置eslint

安装依赖

确保安装了所有必要的依赖:

npm install eslint-plugin-import eslint-config-airbnb-base eslint-plugin-node @typescript-eslint/parser @typescript-eslint/eslint-plugin --save-dev
生成配置
npx eslint --init
Need to install the following packages:
eslint@9.12.0
Ok to proceed? (y) yes

You can also run this command directly using 'npm init @eslint/config@latest'.
Need to install the following packages:
@eslint/create-config@1.3.1
Ok to proceed? (y) y


> ts-logger-tools@0.0.0 npx
> create-config

@eslint/create-config: v1.3.1

✔ How would you like to use ESLint? · problems
✔ What type of modules does your project use? · esm
✔ Which framework does your project use? · none
✔ Does your project use TypeScript? · typescript
✔ Where does your code run? · browser
The config that you've selected requires the following dependencies:

eslint, globals, @eslint/js, typescript-eslint
✔ Would you like to install them now? · No / Yes
✔ Which package manager do you want to use? · npm
示例配置文件

生成的配置文件eslint.config.mjs可能需要手动调整,以匹配配置:

import globals from "globals";
import tseslint from "@typescript-eslint/eslint-plugin";
import parser from "@typescript-eslint/parser";

export default [
  {
    files: ["**/*.{js,mjs,cjs,ts}"],
    ignores: ["node_modules/", "dist/", "build/"],
    languageOptions: {
      globals: globals.browser,
      parser: parser,
      parserOptions: {
        ecmaVersion: 2020,
        sourceType: "module"
      }
    },
    plugins: {
      "@typescript-eslint": tseslint
    },
    rules: {
      "@typescript-eslint/no-inferrable-types": "off",
      "no-case-declarations": "off",
      "@typescript-eslint/no-useless-constructor": "off",
      "@typescript-eslint/no-explicit-any": "off"
    }
  }
];

注意,新版的eslint忽略目录已经不用.eslintignore而是直接写到配置中了

运行 ESLint

package.json 中添加一个脚本,以便于运行 ESLint:

{
  "scripts": {
    "lint": "eslint"
  }
}

然后运行 ESLint:

npm run lint

这样,就可以在 TypeScript 项目中使用 ESLint 进行代码检查和风格规范了。

5. 创建项目结构

接下来,创建项目的基本结构。

mkdir src
touch src/index.ts

src/index.ts 文件中编写一些示例代码:

export const log = (message: string): void => {
  console.log(`[ts-logger-tools]: ${message}`);
};

6. 编写测试

为了确保我们的代码是正确的,我们需要编写测试。我们将使用 Jest 作为测试框架。

npm install jest ts-jest @types/jest --save-dev

创建一个 jest.config.js 文件来配置 Jest:

module.exports = {
  preset: "ts-jest",
  testEnvironment: "node",
  moduleFileExtensions: ["ts", "js"],
  transform: {
    "^.+\\.ts$": "ts-jest",
  },
  testMatch: ["**/test/**/*.test.ts"], // 匹配测试文件的模式
};

在根目录中创建一个 test 文件夹,并添加一个测试文件 index.test.ts

import { log } from '../src/index';

test("log function", () => {
  console.log = jest.fn();
  log("Hello, World!");
  expect(console.log).toHaveBeenCalledWith("[ts-logger-tools]: Hello, World!");
});

package.json 中添加一个测试脚本:

"scripts": {
  "test": "jest"
}

现在,可以运行 npm test 来执行测试。

7. 构建项目

package.json 中添加一个构建脚本:

"scripts": {
    "build": "tsc -p tsconfig.build.json"
}

运行 npm run build,TypeScript 编译器会将 src 目录中的文件编译到 lib 目录中。

8.发版脚本

这里不一定要用脚本,自己一步步操作也可以。下面是小编常用的发版脚本release.sh

#!/bin/bash

# 运行 ESLint 检查
echo "Running ESLint..."
npm run lint

# 检查 ESLint 是否通过
if [ $? -ne 0 ]; then
  echo "ESLint failed. Aborting release."
  exit 1
fi

# 运行测试
echo "Running tests..."
npm run test

# 检查测试是否通过
if [ $? -ne 0 ]; then
  echo "Tests failed. Aborting release."
  exit 1
fi

# 构建项目
echo "Building project..."
npm run build

# 检查构建是否成功
if [ $? -ne 0 ]; then
  echo "Build failed. Aborting release."
  exit 1
fi

# 生成依赖树
echo "Generating dependency tree..."
npm run tree

# 检查依赖树生成是否成功
if [ $? -ne 0 ]; then
  echo "Generating dependency tree failed. Aborting release."
  exit 1
fi

# 获取提交信息
read -p "Enter commit message: " commit_message

# 提交到 Git 仓库
echo "Committing to Git..."
git add .
git commit -m "$commit_message"

# 推送到远程仓库
echo "Pushing to remote repository..."
git push

# 升级版本
echo "Upgrading version..."
npm version patch

# 发布到 npm
echo "Publishing to npm..."
npm publish

echo "Release process completed successfully."

package.json中的script

{
    "scripts": {
    "test": "jest",
    "coverage": "jest --coverage",
    "build": "tsc -p tsconfig.build.json",
    "tree": "tree -l 10 -o tree-out.txt --ignore node_modules/ --ignore build/ --ignore dist/ --ignore migrations/",
    "lint": "eslint .",
    "push": "git add . && read -p '请输入本次更新: \n' msg && git commit -m \"$msg\" && git push -u origin master",
    "rele": "bash release.sh"
  }
}

9. 发布 npm 包

在发布 npm 包之前,确保已经登录到 npm:

npm login

然后,在 package.json 中添加以下字段:

"main": "lib/index.js",
"types": "lib/index.d.ts"

这将指定包的入口文件和类型声明文件。

最后,运行以下命令来发布包:

# 一系列npm XXX命令,然后
npm publish 
# 或者直接 npm run rele
# win同学脚本可以自己改一下

小节

通过以上步骤,已经成功地搭建了一个 TypeScript 开发环境,并发布了一个名为 ts-logger-tools 的 npm 包。这个过程包括了项目初始化、安装依赖、配置 TypeScript、编写代码和测试、构建项目以及发布 npm 包。

日志系统设计

相关概念

  • Trace: 一个完整的请求流,从开始到结束可能跨越多个服务和组件。每个 trace 由一个唯一的 traceId 标识。
  • Span: Trace 中的一个片段,通常代表一次服务调用或操作。每个 span 由一个唯一的 spanId 标识。
  • ParentSpanId: 如果一个 span 是由另一个 span 触发的,那么 parentSpanId 就是触发它的那个 spanspanId。这有助于构建调用链的层级关系。
  • 进入日志(Entry Log): 记录请求到达服务时的日志。
  • 退出日志(Exit Log): 记录服务完成处理并返回响应时的日志。

示例

假设有一个用户请求经过多个服务的处理流程:

  1. 用户请求到达网关服务(生成一个 traceIdspanId)。
  2. 网关服务调用身份验证服务(生成一个新的 spanId,并记录 parentSpanId)。
  3. 身份验证服务调用数据库服务(生成另一个新的 spanId,并记录 parentSpanId)。
  4. 网关服务调用订单服务(生成一个新的 spanId,并记录 parentSpanId)。

在每个服务中,都会记录进入和退出日志:

  • 进入日志:记录请求到达时的详细信息。
  • 退出日志:记录服务处理完成后的详细信息,包括处理时间和结果。

日志结构设计

基类
// src/base/index.ts
export interface Log {
  traceId: string;         // 追踪 ID
  timestamp: number;       // 时间戳
  level: string;           // 日志级别
  message: string;         // 日志消息
  logType: "entry" | "exit"; // 日志类型(进入或退出)
  [key: string]: any;      // 其他字段
}
访问日志表(Access Logs)
// src/base/index.ts

// 访问日志类型
export interface AccessLog extends Log {
  requestTime: number;      // 请求时间
  ipAddress: string;        // IP 地址
  requestUrl: string;       // 请求 URL
  httpMethod: string;       // HTTP 方法
  statusCode: number;       // HTTP 状态码
  userAgent: string;        // 用户代理
  responseTime: number;     // 响应时间
  userId?: string;          // 用户 ID(可选)
  role?: Role;              // 用户角色(可选)
}
应用日志表(Application Logs)
// src/base/index.ts

// 应用日志类型
export interface ApplicationLog extends Log {
  action: string;                // 具体操作
  userId?: string;               // 用户 ID(可选)
  details: any;                  // 详细信息
  eventType: "login" | "security" | "trace" | "operation"; // 事件类型
  loginResult?: "success" | "failure"; // 登录结果(仅用于登录事件)
  failureReason?: string;        // 失败原因
  spanId?: string;               // Span ID(仅用于追踪)
  parentSpanId?: string;         // 父 Span ID(仅用于追踪)
  service?: string;              // 服务名称(仅用于追踪)
  operation?: string;            // 操作名称(仅用于追踪)
  duration?: number;             // 操作持续时间(仅用于追踪)
  error?: boolean;               // 是否有错误(仅用于追踪)
}

功能设计

日志同步
  • 访问日志同步: 系统应支持将访问日志同步到指定的存储介质中。
  • 登录日志同步: 系统应支持将登录相关的应用日志同步到指定的存储介质中。
  • 应用日志同步: 系统应支持将应用操作相关的日志同步到指定的存储介质中。
  • 安全日志同步: 系统应支持将安全相关的应用日志同步到指定的存储介质中。
  • 追踪日志同步: 系统应支持将追踪相关的应用日志同步到指定的存储介质中。
日志查询
  • 查询访问日志: 系统应支持根据过滤条件、分页参数和排序条件查询访问日志。
  • 查询应用日志: 系统应支持根据过滤条件、分页参数和排序条件查询应用日志。
日志计数
  • 计数访问日志: 系统应支持根据过滤条件计算访问日志的数量。
  • 计数应用日志: 系统应支持根据过滤条件计算应用日志的数量。
用户统计
  • 统计访问日志中的不同用户数量: 系统应支持根据过滤条件统计访问日志中不同用户的数量。
  • 统计应用日志中的不同用户数量: 系统应支持根据过滤条件统计应用日志中不同用户的数量。
追踪日志查询
  • 查询指定链路ID的所有日志: 系统应支持根据追踪ID查询所有相关的访问日志和应用日志。

抽象日志同步器基类

// src/base/LogSyncer.ts

import { AccessLog, ApplicationLog, Sort } from ".";

// 抽象日志同步器基类
export abstract class LogSyncer {
  // 同步日志方法
  abstract syncAccessLog(log: AccessLog): Promise<void>;
  abstract syncLoginLog(log: ApplicationLog): Promise<void>;
  abstract syncApplicationLog(log: ApplicationLog): Promise<void>;
  abstract syncSecurityLog(log: ApplicationLog): Promise<void>;
  abstract syncTraceLog(log: ApplicationLog): Promise<void>;

  // 查询日志方法
  abstract queryAccessLogs(
    filter: any,
    skip?: number,
    limit?: number,
    sort?: Sort
  ): Promise<AccessLog[]>;
  abstract queryApplicationLogs(
    filter: any,
    skip?: number,
    limit?: number,
    sort?: Sort
  ): Promise<ApplicationLog[]>;

  // 计数日志方法
  abstract countAccessLogs(filter: any): Promise<number>;
  abstract countApplicationLogs(filter: any): Promise<number>;

  // 统计不同用户数量的方法
  abstract countDistinctUsersAccessLogs(filter: any): Promise<number>;
  abstract countDistinctUsersApplicationLogs(filter: any): Promise<number>;

  // 查询指定链路ID的所有日志
  abstract queryLogsByTraceId(
    traceId: string
  ): Promise<{ accessLogs: AccessLog[]; applicationLogs: ApplicationLog[] }>;
}

具体实现

MongoDB

安装MongoDB:

npm i mongodb

具体实现:

// src/syncer/mongodb.ts

import { MongoClient, Db, Collection, Filter } from "mongodb";
import { AccessLog, ApplicationLog, Sort } from "../base";
import { LogSyncer } from "../base/LogSyncer";

export class MongoDBLogSyncer extends LogSyncer {
  private client: MongoClient;
  private db: Db;
  private accessLogs: Collection<AccessLog>;
  private applicationLogs: Collection<ApplicationLog>;

  constructor(url: string, dbname: string) {
    super();
    this.client = new MongoClient(url);
    this.db = this.client.db(dbname);
    this.accessLogs = this.db.collection<AccessLog>("accessLogs");
    this.applicationLogs =
      this.db.collection<ApplicationLog>("applicationLogs");
  }

  async connect(): Promise<void> {
    await this.client.connect();
    console.info("日志MongoDB数据库初始化完毕");

    // 创建索引
    await this.createIndexes();
  }
  private async createIndexes(): Promise<void> {
    if (this.accessLogs) {
      await this.accessLogs.createIndex({ traceId: 1 });
      await this.accessLogs.createIndex({ timestamp: 1 });
      await this.accessLogs.createIndex({ ipAddress: 1 });
      await this.accessLogs.createIndex({ userId: 1 });
      await this.accessLogs.createIndex({ userId: 1, timestamp: -1 });
    }

    if (this.applicationLogs) {
      await this.applicationLogs.createIndex({ traceId: 1 });
      await this.applicationLogs.createIndex({ timestamp: 1 });
      await this.applicationLogs.createIndex({ userId: 1 });
      await this.applicationLogs.createIndex({ spanId: 1 });
      await this.applicationLogs.createIndex({ parentSpanId: 1 });
      await this.applicationLogs.createIndex({ eventType: 1 });
      await this.applicationLogs.createIndex({ userId: 1, timestamp: -1 });
    }
  }

  async syncAccessLog(log: AccessLog): Promise<void> {
    await this.accessLogs.insertOne(log);
  }

  async syncLoginLog(log: ApplicationLog): Promise<void> {
    await this.syncApplicationLog(log);
  }

  async syncApplicationLog(log: ApplicationLog): Promise<void> {
    await this.applicationLogs.insertOne(log);
  }

  async syncSecurityLog(log: ApplicationLog): Promise<void> {
    await this.syncApplicationLog(log);
  }

  async syncTraceLog(log: ApplicationLog): Promise<void> {
    await this.syncApplicationLog(log);
  }

  async queryAccessLogs(
    filter: Filter<AccessLog>,
    skip: number = 0,
    limit: number = 10,
    sort?: Sort
  ): Promise<AccessLog[]> {
    const mongoSort = this.convertSort(sort);
    return this.accessLogs
      .find(filter)
      .skip(skip)
      .limit(limit)
      .sort(mongoSort)
      .toArray();
  }

  async queryApplicationLogs(
    filter: Filter<ApplicationLog>,
    skip: number = 0,
    limit: number = 10,
    sort?: Sort
  ): Promise<ApplicationLog[]> {
    const mongoSort = this.convertSort(sort);
    return this.applicationLogs
      .find(filter)
      .skip(skip)
      .limit(limit)
      .sort(mongoSort)
      .toArray();
  }

  async countAccessLogs(filter: Filter<AccessLog>): Promise<number> {
    return this.accessLogs.countDocuments(filter);
  }

  async countApplicationLogs(filter: Filter<ApplicationLog>): Promise<number> {
    return this.applicationLogs.countDocuments(filter);
  }

  async countDistinctUsersAccessLogs(
    filter: Filter<AccessLog>
  ): Promise<number> {
    return this.accessLogs
      .distinct("userId", filter)
      .then((users) => users.length);
  }

  async countDistinctUsersApplicationLogs(
    filter: Filter<ApplicationLog>
  ): Promise<number> {
    return this.applicationLogs
      .distinct("userId", filter)
      .then((users) => users.length);
  }

  async queryLogsByTraceId(
    traceId: string
  ): Promise<{ accessLogs: AccessLog[]; applicationLogs: ApplicationLog[] }> {
    const accessLogs = await this.accessLogs.find({ traceId }).toArray();
    const applicationLogs = await this.applicationLogs
      .find({ traceId })
      .toArray();
    return { accessLogs, applicationLogs };
  }

  async close(): Promise<void> {
    await this.client.close();
  }

  private convertSort(sort?: Sort): any {
    if (!sort) return {};
    const mongoSort: any = {};
    for (const [field, order] of Object.entries(sort)) {
      mongoSort[field] = order === "asc" ? 1 : -1;
    }
    return mongoSort;
  }
}

注意:这里的实现多了一层convertSort处理排序,因为MongoDB和正常的库排序入参不一致

单元测试

MongoDB同步器MongoDBLogSyncer
// test/syncer/mongodb.test.ts

import { MongoMemoryServer } from "mongodb-memory-server";
import { MongoDBLogSyncer } from "../../src/syncer/mongodb";
import { AccessLog, ApplicationLog } from "../../src/base";

let mongoServer: MongoMemoryServer;
let syncer: MongoDBLogSyncer;

beforeAll(async () => {
  mongoServer = await MongoMemoryServer.create();
  const uri = mongoServer.getUri();
  syncer = new MongoDBLogSyncer(uri, "testdb");
  await syncer.connect();
});

afterAll(async () => {
  await syncer.close();
  await mongoServer.stop();
});

describe("MongoDBLogSyncer", () => {
  it("should insert an access log", async () => {
    const log: AccessLog = {
      traceId: "trace123",
      timestamp: Date.now(),
      level: "info",
      message: "Access log message",
      logType: "entry",
      requestTime: Date.now(),
      ipAddress: "127.0.0.1",
      requestUrl: "/api/test",
      httpMethod: "GET",
      statusCode: 200,
      userAgent: "Mozilla/5.0",
      responseTime: Date.now(),
      userId: "user1", // 添加 userId
    };

    await syncer.syncAccessLog(log);

    const insertedLog = await (syncer as any).accessLogs.findOne({
      traceId: "trace123",
    });
    expect(insertedLog).toMatchObject(log);
  });

  it("should insert an application log", async () => {
    const log: ApplicationLog = {
      traceId: "trace456",
      timestamp: Date.now(),
      level: "info",
      message: "Application log message",
      logType: "entry",
      action: "testAction",
      details: { key: "value" },
      eventType: "operation",
      userId: "user2", // 添加 userId
    };

    await syncer.syncApplicationLog(log);

    const insertedLog = await (syncer as any).applicationLogs.findOne({
      traceId: "trace456",
    });
    expect(insertedLog).toMatchObject(log);
  });

  it("should query access logs", async () => {
    const logs = await syncer.queryAccessLogs({ traceId: "trace123" });
    expect(logs.length).toBeGreaterThan(0);
    expect(logs[0].traceId).toBe("trace123");
  });

  it("should query application logs", async () => {
    const logs = await syncer.queryApplicationLogs({ traceId: "trace456" });
    expect(logs.length).toBeGreaterThan(0);
    expect(logs[0].traceId).toBe("trace456");
  });

  it("should count access logs", async () => {
    const count = await syncer.countAccessLogs({ traceId: "trace123" });
    expect(count).toBeGreaterThan(0);
  });

  it("should count application logs", async () => {
    const count = await syncer.countApplicationLogs({ traceId: "trace456" });
    expect(count).toBeGreaterThan(0);
  });

  it("should count distinct users in access logs", async () => {
    // 插入多个日志以确保有不同的 userId
    await syncer.syncAccessLog({
      traceId: "trace789",
      timestamp: Date.now(),
      level: "info",
      message: "Another access log",
      logType: "entry",
      requestTime: Date.now(),
      ipAddress: "127.0.0.1",
      requestUrl: "/api/another",
      httpMethod: "POST",
      statusCode: 201,
      userAgent: "Mozilla/5.0",
      responseTime: Date.now(),
      userId: "user3",
    });

    const count = await syncer.countDistinctUsersAccessLogs({});
    expect(count).toBeGreaterThan(0);
  });

  it("should count distinct users in application logs", async () => {
    // 插入多个日志以确保有不同的 userId
    await syncer.syncApplicationLog({
      traceId: "trace789",
      timestamp: Date.now(),
      level: "info",
      message: "Another application log",
      logType: "entry",
      action: "anotherAction",
      details: { key: "anotherValue" },
      eventType: "operation",
      userId: "user4",
    });

    const count = await syncer.countDistinctUsersApplicationLogs({});
    expect(count).toBeGreaterThan(0);
  });

  it("should query logs by trace ID", async () => {
    const result = await syncer.queryLogsByTraceId("trace123");
    expect(result.accessLogs.length).toBeGreaterThan(0);
    expect(result.accessLogs[0].traceId).toBe("trace123");

    const resultAppLogs = await syncer.queryLogsByTraceId("trace456");
    expect(resultAppLogs.applicationLogs.length).toBeGreaterThan(0);
    expect(resultAppLogs.applicationLogs[0].traceId).toBe("trace456");
  });
});

发布版本

npm run rele

总结

在这篇文章中,我们详细介绍了如何从零开始搭建一个 TypeScript 开发环境,并发布一个名为 ts-logger-tools 的 npm 包。首先,我们初始化了项目并安装了 TypeScript 和相关开发工具,然后配置了 TypeScript 编译器和 ESLint。接着,我们创建了项目结构并编写了示例代码和测试,最后通过构建脚本将项目编译并发布到 npm。

在日志系统设计部分,我们介绍了日志系统的相关概念,如 Trace、Span、进入日志和退出日志。设计了日志结构,包括访问日志和应用日志,并详细描述了日志同步和查询功能。我们还提供了 MongoDB 的具体实现,包括日志同步器的基类和具体实现类,以及相关的单元测试,确保日志功能的正确性。

通过这些步骤,可以轻松搭建一个 TypeScript 项目,设计并实现一个功能齐全的日志系统,并将其发布为 npm 包。