You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

NestJS定时任务CSV处理失败重试的状态外置重构咨询

重构NestJS定时任务重试逻辑:将重试状态移至服务层外

当前代码把重试状态(attempt_no)耦合在业务方法里,导致重试逻辑和CSV处理逻辑混在一起,下面是几个可行的解耦方案,把重试状态完全抽离出FooService:


方案一:自定义重试装饰器(推荐)

把重试次数、间隔等配置通过装饰器传入,业务方法只关注核心的CSV解析和DB写入,重试状态由装饰器内部维护。

第一步:实现重试装饰器

// src/common/decorators/retry.decorator.ts
import { join } from 'path';
import { renameSync } from 'fs';

export function Retry(maxAttempts: number = 3, retryDelayMs: number = 1000) {
  return function (target: any, methodName: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;

    descriptor.value = async function (...args: any[]) {
      let attemptCount = 1;
      let lastError: Error;

      while (attemptCount <= maxAttempts) {
        try {
          const result = await originalMethod.apply(this, args);
          // 业务方法返回文件路径时,统一处理成功后的移动逻辑
          if (result?.sourcePath) {
            const processedPath = join('processed_files', result.sourcePath.split('/').pop());
            renameSync(result.sourcePath, processedPath);
            console.log(`文件已移至processed_files目录: ${processedPath}`);
          }
          return result;
        } catch (error) {
          lastError = error as Error;
          console.log(`第${attemptCount}次处理失败: ${lastError.message}`);
          
          attemptCount++;
          if (attemptCount > maxAttempts) break;
          // 重试前等待指定时间
          await new Promise(resolve => setTimeout(resolve, retryDelayMs));
        }
      }

      // 所有重试失败,处理文件移动
      if (lastError && (lastError as any).sourcePath) {
        const failedPath = join('failed_files', (lastError as any).sourcePath.split('/').pop());
        renameSync((lastError as any).sourcePath, failedPath);
        console.log(`文件已移至failed_files目录: ${failedPath}`);
      }

      throw lastError;
    };

    return descriptor;
  };
}

第二步:改造业务方法

去掉attempt_no参数,核心逻辑只负责CSV处理,成功时返回文件路径,失败时抛出带文件路径的错误:

// src/services/foo.service.ts
import { Retry } from '../common/decorators/retry.decorator';
import { join } from 'path';

@Injectable()
export class FooService {
  @Retry(3) // 指定最大重试次数为3
  async foo() {
    const sourcePath = join('csv_file_store', 'foo.csv');

    try {
      // 核心逻辑:读取CSV、逐行解析、写入数据库
      // ...

      return {
        status: { statusCode: 200, statusMessage: '处理成功' },
        sourcePath
      };
    } catch (error) {
      // 抛出错误时携带文件路径,供装饰器处理失败逻辑
      const customError = error as Error;
      (customError as any).sourcePath = sourcePath;
      throw customError;
    }
  }

  // bar、foo2等方法同理,只需添加@Retry装饰器即可
}

第三步:调度层无需修改

SchedularService的调用逻辑完全不变,重试逻辑由装饰器自动处理:

@Cron("* * * * * *")
async readSomeFiles(){
  await this.fooService.foo();
  await this.fooService.bar();
}

方案二:独立重试工具类

把重试逻辑抽象成通用工具方法,业务方法作为回调传入,重试状态由工具类内部维护,适合需要统一修改重试规则的场景。

实现重试工具类

// src/common/utils/retry.util.ts
import { join } from 'path';
import { renameSync } from 'fs';

export class RetryUtil {
  static async execute<T>(
    task: () => Promise<T>,
    config: {
      maxAttempts: number;
      sourcePath: string;
      processedDir: string;
      failedDir: string;
      retryDelayMs?: number;
    }
  ): Promise<T> {
    const { maxAttempts, sourcePath, processedDir, failedDir, retryDelayMs = 1000 } = config;
    let attempt = 1;
    let lastError: Error;

    while (attempt <= maxAttempts) {
      try {
        const result = await task();
        // 成功移动文件
        const targetPath = join(processedDir, sourcePath.split('/').pop()!);
        renameSync(sourcePath, targetPath);
        console.log(`文件已移至${processedDir}目录`);
        return result;
      } catch (error) {
        lastError = error as Error;
        console.log(`第${attempt}次尝试失败: ${lastError.message}`);
        
        attempt++;
        if (attempt > maxAttempts) break;
        await new Promise(resolve => setTimeout(resolve, retryDelayMs));
      }
    }

    // 失败移动文件
    const targetPath = join(failedDir, sourcePath.split('/').pop()!);
    renameSync(sourcePath, targetPath);
    console.log(`文件已移至${failedDir}目录`);
    throw lastError;
  }
}

改造业务方法

调用工具类的execute方法,传入核心处理逻辑和配置:

// src/services/foo.service.ts
import { RetryUtil } from '../common/utils/retry.util';
import { join } from 'path';

@Injectable()
export class FooService {
  async foo() {
    const sourcePath = join('csv_file_store', 'foo.csv');

    return RetryUtil.execute(
      async () => {
        // 核心逻辑:读取CSV、解析、写入DB
        // ...
        return { status: { statusCode: 200, statusMessage: '处理成功' } };
      },
      {
        maxAttempts: 3,
        sourcePath,
        processedDir: 'processed_files',
        failedDir: 'failed_files'
      }
    );
  }
}

方案三:NestJS拦截器(全局场景)

如果需要为多个服务的方法统一添加重试策略,可以用NestJS拦截器结合元数据实现,不过文件移动逻辑需要额外配合异常过滤器处理:

实现重试拦截器和元数据装饰器

// src/common/interceptors/retry.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, throwError } from 'rxjs';
import { retryWhen, delay, scan } from 'rxjs/operators';
import { Reflector } from '@nestjs/core';

@Injectable()
export class RetryInterceptor implements NestInterceptor {
  constructor(private reflector: Reflector) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const maxAttempts = this.reflector.get<number>('retry_max_attempts', context.getHandler()) || 3;
    const retryDelayMs = this.reflector.get<number>('retry_delay', context.getHandler()) || 1000;

    return next.handle().pipe(
      retryWhen(errors =>
        errors.pipe(
          scan((attempts) => {
            if (attempts >= maxAttempts) {
              throw new Error(`超过最大重试次数(${maxAttempts})`);
            }
            console.log(`第${attempts + 1}次重试`);
            return attempts + 1;
          }, 0),
          delay(retryDelayMs)
        )
      )
    );
  }
}

// 配套装饰器
export function RetryConfig(maxAttempts: number, retryDelayMs?: number) {
  return function (target: any, methodName: string) {
    Reflect.defineMetadata('retry_max_attempts', maxAttempts, target[methodName]);
    if (retryDelayMs) {
      Reflect.defineMetadata('retry_delay', retryDelayMs, target[methodName]);
    }
  };
}

注册拦截器并标记方法

在模块中注册拦截器,然后在业务方法上添加装饰器:

// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { RetryInterceptor } from './common/interceptors/retry.interceptor';

@Module({
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: RetryInterceptor,
    },
    FooService,
    SchedularService
  ],
})
export class AppModule {}

// src/services/foo.service.ts
@Injectable()
export class FooService {
  @RetryConfig(3)
  async foo() {
    const sourcePath = join('csv_file_store', 'foo.csv');
    try {
      // 核心逻辑
      // ...
      const processedPath = join('processed_files', 'foo.csv');
      renameSync(sourcePath, processedPath);
      return { status: { statusCode: 200, statusMessage: '处理成功' } };
    } catch (error) {
      // 结合异常过滤器处理失败后的文件移动
      throw error;
    }
  }
}

选型总结

  • 装饰器方案:最简洁,侵入性最低,业务代码干净,适合单个或少量方法的重试需求。
  • 工具类方案:灵活性高,方便统一修改重试规则,适合大量同类业务方法的场景。
  • 拦截器方案:适合全局统一重试策略,需要配合异常过滤器处理文件移动,配置稍复杂。

内容的提问来源于stack exchange,提问作者Sidharth Gopalakrishnan

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.16 13:33:04