在 Node.js 中使用 fp-ts 进行函数式编程

介绍

函数式编程 (FP) 因其可组合性、可测试性和稳健性而广受欢迎。在 JavaScript 生态系统中,**fp-ts** 等库将强大的 FP 概念引入 TypeScript,让您可以编写更简洁、更可靠的代码。

本文探讨了 **fp-ts** 概念,例如 `Option`、`Either`、`Task`、`Reader` 和 `ReaderTaskEither`。我们将使用 **fp-ts**、**pg**(PostgreSQL 客户端)和 **Express.js** 构建一个基本的 CRUD 应用程序,以了解这些抽象在实际应用程序中的表现。

关键概念

在深入研究该应用程序之前,让我们简单讨论一下主要概念:

  • 选项:模拟某个值的存在或不存在(某些或无)。
  • 任一:表示计算可以成功(右)或失败(左)。
  • 任务:表示懒惰的异步计算。
  • 读者:将依赖关系注入计算中。
  • ReaderTaskEither:结合 Reader、Task 和 Either,实现具有依赖关系和错误处理的异步操作。
  • 设置项目

    初始化项目

    mkdir fp-ts-crud && cd fp-ts-crud
    npm init -y
    npm install express pg fp-ts io-ts
    npm install --save-dev typescript @types/express ts-node-dev jest @types/jest ts-jest

    设置 TypeScript

    创建一个 `tsconfig.json`:

    {
      "compilerOptions": {
        "target": "ES2020",
        "module": "CommonJS",
        "outDir": "dist",
        "strict": true,
        "esModuleInterop": true
      },
      "include": ["src/**/*"]
    }

    项目结构

    src/
      index.ts        # Entry point
      db.ts           # Database setup
      models/         # Data models and validation
      services/       # Business logic
      controllers/    # CRUD operations
      utils/          # fp-ts utilities
      errors/         # Custom error classes

    实现 CRUD 应用程序

    数据库设置(db.ts)

    import { Pool } from 'pg';
    
    export const pool = new Pool({
      user: 'postgres',
      host: 'localhost',
      database: 'fp_ts_crud',
      password: 'password',
      port: 5432,
    });

    定义模型和验证 (models/User.ts)

    import * as t from 'io-ts';
    import { isRight } from 'fp-ts/Either';
    
    export const User = t.type({
      id: t.number,
      name: t.string,
      email: t.string,
    });
    
    export const validateUser = (data: unknown): t.TypeOf | null => {
      const result = User.decode(data);
      return isRight(result) ? result.right : null;
    };

    自定义错误处理 (errors/AppError.ts)

    export class AppError extends Error {
      constructor(public statusCode: number, public code: string, public message: string) {
        super(message);
        this.name = 'AppError';
      }
    }
    
    export const createAppError = (statusCode: number, code: string, message: string): AppError => {
      return new AppError(statusCode, code, message);
    };

    服务层 (services/UserService.ts)

    import { pool } from '../db';
    import { ReaderTaskEither, right, left } from 'fp-ts/ReaderTaskEither';
    import { pipe } from 'fp-ts/function';
    import { createAppError, AppError } from '../errors/AppError';
    import { validateUser } from '../models/User';
    
    type Dependencies = { db: typeof pool };
    type User = { name: string; email: string };
    
    export const createUser = (
      user: User
    ): ReaderTaskEither => (deps) => async () => {
      // Validate the incoming user data
      const validatedUser = validateUser(user);
    
      if (!validatedUser) {
        return left(createAppError(400, 'INVALID_USER_DATA', 'Invalid user data provided'));
      }
    
      try {
        const result = await deps.db.query(
          'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id',
          [validatedUser.name, validatedUser.email]
        );
        return right(`User created with ID: ${result.rows[0].id}`);
      } catch (error) {
        return left(createAppError(500, 'USER_CREATION_FAILED', 'Failed to create user'));
      }
    };
    
    export const getUser = (
      id: number
    ): ReaderTaskEither => (deps) => async () => {
      try {
        const result = await deps.db.query('SELECT * FROM users WHERE id = $1', [id]);
        return result.rows[0]
          ? right(result.rows[0])
          : left(createAppError(404, 'USER_NOT_FOUND', 'User not found'));
      } catch {
        return left(createAppError(500, 'USER_FETCH_FAILED', 'Failed to fetch user'));
      }
    };

    CRUD 操作 (controllers/UserController.ts)

    import { pipe } from 'fp-ts/function';
    import { createUser, getUser } from '../services/UserService';
    import { pool } from '../db';
    import { AppError } from '../errors/AppError';
    
    const errorHandler = (err: unknown, res: express.Response): void => {
      if (err instanceof AppError) {
        res.status(err.statusCode).json({ error: { code: err.code, message: err.message } });
      } else {
        res.status(500).json({ error: { code: 'UNKNOWN_ERROR', message: 'An unexpected error occurred' } });
      }
    };
    
    export const createUserHandler = (req: express.Request, res: express.Response): void => {
      pipe(
        createUser(req.body),
        (task) => task({ db: pool }),
        (promise) =>
          promise.then((result) =>
            result._tag === 'Left'
              ? errorHandler(result.left, res)
              : res.json({ message: result.right })
          )
      );
    };
    
    export const getUserHandler = (req: express.Request, res: express.Response): void => {
      pipe(
        getUser(parseInt(req.params.id, 10)),
        (task) => task({ db: pool }),
        (promise) =>
          promise.then((result) =>
            result._tag === 'Left'
              ? errorHandler(result.left, res)
              : res.json(result.right)
          )
      );
    };

    Express API(index.ts)

    import express from 'express';
    import { createUserHandler, getUserHandler } from './controllers/UserController';
    
    const app = express();
    app.use(express.json());
    
    // Routes
    app.post('/users', createUserHandler);
    app.get('/users/:id', getUserHandler);
    
    // Start Server
    app.listen(3000, () => {
      console.log('Server running on http://localhost:3000');
    });

    使用 Docker 和 Docker Compose 运行应用程序

    Dockerfile

    # Stage 1: Build
    FROM node:22 AS builder
    WORKDIR /app
    COPY package*.json .
    RUN npm install
    COPY . .
    RUN npm run build
    
    # Stage 2: Run
    FROM node:22
    WORKDIR /app
    COPY --from=builder /app/dist ./dist
    COPY package*.json ./
    RUN npm install --production
    CMD ["node", "dist/index.js"]

    docker-compose.yml

    version: '3.8'
    services:
      db:
        image: postgres:15
        environment:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: password
          POSTGRES_DB: fp_ts_crud
        ports:
          - "5432:5432"
        volumes:
          - db_data:/var/lib/postgresql/data
    volumes:
      db_data:

    运行应用程序-开发模式

    # Start the database
    docker-compose up -d
    
    # Run the app
    npx ts-node-dev src/index.ts

    运行应用程序 - 生产模式

    # Build the docker image
    docker build -t fp-ts-crud-app .
    
    # Start the database
    docker-compose up -d
    
    # Run the container
    docker run -p 3000:3000 fp-ts-crud-app

    编写测试

    设置 Jest

    更新 `package.json` 脚本:

    "scripts": {
      "test": "jest",
      "test:watch": "jest --watch"
    }

    示例测试(__tests__/UserService.test.ts)

    import { createUser, getUser } from '../services/UserService';
    import { pool } from '../db';
    
    jest.mock('../db', () => ({
      pool: {
        query: jest.fn(),
      },
    }));
    
    describe('UserService', () => {
      afterEach(() => {
        jest.clearAllMocks();
      });
    
      it('should create a user', async () => {
        (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [{ id: 1 }] });
    
        const result = await createUser({ name: 'Alice', email: 'alice@example.com' })({ db: pool })();
    
        expect(result._tag).toBe('Right');
        if (result._tag === 'Right') {
          expect(result.right).toBe('User created with ID: 1');
        }
      });
    
      it('should return error if user not found', async () => {
        (pool.query as jest.Mock).mockResolvedValueOnce({ rows: [] });
    
        const result = await getUser(1)({ db: pool })();
    
        expect(result._tag).toBe('Left');
        if (result._tag === 'Left') {
          expect(result.left.message).toBe('User not found');
        }
      });
    
      it('should return error for invalid user data during creation', async () => {
        const invalidData = { name: 123, email: 'invalid-email' };
        const result = await createUser(invalidData)({ db: pool })();
        expect(result._tag).toBe('Left');
    
        if (result._tag === 'Left') {
          expect(result.left.message).toBe('Invalid user data provided');
        }
      });
    });

    结论

    通过利用 **fp-ts**、Docker 和强大的错误处理功能,我们构建了一个功能齐全、可扩展且可维护的 Node.js CRUD 应用程序。使用函数式编程模式可让您的代码更加可预测和可靠,尤其是在处理异步工作流时。