read-stream/FileReadStream.ts

import { Subject, Observable } from 'rxjs';
import {
  FileReadEventType,
  FileReadEvent,
  FileReadState,
  FileReadConfig,
  FileReadResponseType,
} from './types';

/**
 * 파일 스트림 읽기.
 * FileReader 사용시 한번에 못 읽는 용량 끊어서 읽기 용.
 * @class FileReadStream
 */
export class FileReadStream {
  /**
   * @param {File} file FileReader 로 읽을 파일
   * @param {FileReadConfig} [config={}] 스트림 옵션
   */
  constructor(file: File, config: FileReadConfig = {}) {
    this.readFile = file;
    this.reader = new FileReader();
    this.reader.onload = this.onReaderLoaded.bind(this);
    this.reader.onerror = this.onReaderError.bind(this);
    this.subjectState = new Subject();
    const { chunkSize = 1024 * 1024 * 10, responseType = FileReadResponseType.BLOB } = config;
    this.readChunkSize = chunkSize;
    this.responseType = responseType;
  }

  // 변경사항 알리미 @see https://rxjs-dev.firebaseapp.com
  protected subjectState: Subject<FileReadEvent>;
  // 파일 리더
  protected reader: FileReader;
  // 읽을 파일
  protected readFile: File;
  // 읽기 시작 여부
  protected readStarted: boolean = false;
  // 1회 읽을량
  protected readChunkSize: number;
  // 현재 읽기 offset
  protected readOffset: number = 0;
  // 현재 읽어야할 offset
  protected readOffsetNext: number = 0;
  // 현재 읽은량
  protected readCurrent: number = 0;
  // 현재 취소 상태인지 확인용
  protected readStateAbort: boolean = false;
  // 읽은 버퍼
  protected buffers: ArrayBuffer[] = [];
  // 응답시 파일 타입
  protected responseType: FileReadResponseType;
  // subjectState 로 진행(완료) 상태를 받을 수 있지만, 편의용 async/await 도 제공
  protected readPromise: Promise<FileReadEvent>;
  protected readPromiseResolve: (value: FileReadEvent) => void;
  protected readPromiseReject: (reason?: any) => void;

  /**
   * 파일 읽기 1회 완료
   * @protected
   * @param {Event} evt
   */
  protected onReaderLoaded(evt: Event) {
    this.readCurrent = this.readOffsetNext;
    this.readOffset += this.readChunkSize;
    this.buffers.push(this.reader.result as ArrayBuffer);
    this.stateChanged();
    this.next();
  }

  /**
   * 파일 읽기 1회 오류
   * @protected
   * @param {Event} evt
   */
  protected onReaderError(evt: Event) {
    this.resultReject(new Error('file reader onerror event'));
  }

  /**
   * 다음 구간 읽기
   * @protected
   */
  protected next() {
    if (this.readStateAbort) {
      return;
    }
    try {
      const maxSize = this.readFile.size;
      if (maxSize <= this.readOffset) {
        this.resultResolve();
        return;
      }
      let expectNext = this.readOffset + this.readChunkSize;
      if (maxSize <= expectNext) expectNext = maxSize;
      this.readOffsetNext = expectNext;
      const sliceBlob = this.readFile.slice(this.readOffset, this.readOffsetNext);
      this.reader.readAsArrayBuffer(sliceBlob);
    } catch (err) {
      this.onReaderError(err);
    }
  }

  /**
   * state 변경 시 알림
   * @protected
   */
  protected stateChanged() {
    this.subjectState.next({
      type: FileReadEventType.CHANGE,
      state: this.getState(),
    });
  }

  /**
   * 완료 시 알림
   * @protected
   */
  protected resultResolve() {
    const result: FileReadEvent = {
      type: FileReadEventType.RESOLVE,
      state: this.getState(),
    };
    this.reader.onload = null;
    this.reader.onerror = null;
    this.readPromiseResolve(result);
    this.subjectState.next(result);
  }

  /**
   * 실패 시 알림
   * @protected
   * @param {*} reason
   */
  protected resultReject(reason: any, type: FileReadEventType = FileReadEventType.REJECT) {
    const result: FileReadEvent = {
      type,
      state: null,
      error: reason,
    };
    this.reader.onload = null;
    this.reader.onerror = null;
    this.readPromiseReject(result);
    this.subjectState.next(result);
  }

  /**
   * 읽기 시작
   * @returns {Promise<FileReadEvent>}
   */
  start(): Promise<FileReadEvent> {
    if (this.readStarted) return;
    this.readStarted = true;
    this.readPromise = new Promise((resolve, reject) => {
      this.readPromiseResolve = resolve;
      this.readPromiseReject = reject;
    });
    this.next();
    return this.readPromise;
  }

  /**
   * 상태변경 옵저버
   * @returns {Observable<FileReadEvent>}
   */
  observeState(): Observable<FileReadEvent> {
    return this.subjectState.asObservable();
  }

  /**
   * 현재 상태
   * @returns {FileReadState}
   */
  getState(): FileReadState {
    const { name = null, type = null, size = 0, lastModified = Date.now() } = this.readFile || {};
    const state: FileReadState = {
      fileName: name,
      fileType: type,
      fileSize: size,
      fileLastModified: lastModified,
      total: size,
      readed: this.readCurrent,
      responseType: this.responseType,
      chunkSize: this.readChunkSize,
    };
    switch (this.responseType) {
      case FileReadResponseType.BLOB:
        state.blob = new Blob(this.buffers, { type });
        break;
      case FileReadResponseType.BUFFER:
        state.buffers = this.buffers;
        break;
    }
    return state;
  }

  /**
   * 파일 읽기 중단
   */
  abort(): void {
    this.readStateAbort = true;
    if (this.reader) {
      this.reader.abort();
      this.reader.onload = null;
      this.reader.onerror = null;
      this.resultReject(new Error('file reader abort event'), FileReadEventType.ABORT);
    }
  }

  /**
   * 파기
   */
  destroy() {
    if (this.reader) {
      this.reader.abort();
      this.reader.onload = null;
      this.reader.onerror = null;
      this.reader = null;
    }
    this.readFile = null;
  }
}