🏠

Undo Redo機能をAngularで実装する 1

2022/03/17に公開

AngularでUndoRedo(元に戻す、やり直す)機能を実装したい、という相談があったので調査、検証実装したので解説する。

はじめに

解説するために以下の名称を定義した。

  • ステート:アプリで操作しているデータの状態のこと
  • ローカルストア:クライアント側でデータを保持しているもの

今回の検証では以下三種類の実装を行ったのでそれぞれ解説する。今回はCommandパターンを解説する。

  • Commandパターン
  • NgRXでCommandパターン
  • NgRXでMementoパターン

ソースコードは以下。
https://github.com/yooontheearth/angular-tour-of-heroes-undo-redo

以下タグを設定してあるので必要な個所を参照してもらいたい。今回はUndoRedoByCommandPatternの内容を解説する。

  • HeroTypeAdded:Hero型にenum型を追加
  • StoreServiceAdded:ローカルストア追加(HeroTypeAddedの派生)
  • UndoRedoByCommandPattern:Commandパターン(StoreServiceAddedの派生)
  • NgRxAdded:ローカルストアとしてNgRXを追加(HeroTypeAddedの派生)
  • UndoRedoByCommandPatternWithNgRx:NgRXでCommandパターン(NgRxAddedの派生)
  • UndoRedoByMementoPatternWithNgRx:NgRXでMementoパターン(UndoRedoByCommandPatternWithNgRxの派生)

UndoRedo機能の概要

Angularに限らずUndoRedo機能は要件によって実装可能な方法が異なる。デザインパターン的にはCommandパターン、もしくはMementoパターンが一般的だがどちらも一長一短なので要件にあわせて取捨選択する必要がある。考えられる要件を以下に列挙した。

  • データの永続化方法(ステート全体をDelete/Insertしてるのか、個別のフィールドで行うのか、差分で行うかなど)
  • UndoRedo処理の粒度(どの処理を対象とするかなど)
  • 永続化タイミング(ボタン押下時かフォーカスロスト時かなど)
  • ステートサイズの制限(貧弱なデバイスも考慮するならば複数世代保持されるステートサイズは小さいほうが望ましい)

ステート全体を常に永続化する方式のアプリケーションとMementoパターンの相性はとても良い。しかしステートサイズが問題になりやすい。Commandパターンは細かく処理を分けられるので柔軟性が高いと言えるがその分必要なコード量は増える。

サンプルアプリケーション Tour of Heroes

解説するサンプルアプリケーションはAngularのチュートリアルアプリケーションTour of Heroesを基にUndoRedo機能を追加している。

オリジナルのTour of Heroes

Tour of Heroesはチュートリアルなのでシンプルな構成になっているがInMemoryDbServiceを使用して疑似的なバックエンドが実装されている。ローカルストア等はなく各ComponentはHeroServiceを直接呼び出しデータの取得、永続化を行っている。Heroの追加、削除は即時永続化され、Heroの名称変更は保存ボタン押下時に永続化される。

Tour of Heroesは編集即永続化されるのと個別のフィールド(Hero単位)での永続化なのでステートを丸ごと入れ替える方式のMementoパターンとの相性は悪いと言える。

ローカルストア

UndoRedo機能を実装するには複数のステートを保持しなければならないのでクライアント側でステート管理を行うローカルストアが必要だ。Tour of Heroesにはその機能がないのでStoreServiceを実装した。

各Componentは直接HeroServiceを呼ぶのではなくStoreServiceに対してデータ関連の処理を要求する。StoreServiceは永続化処理とともに自身が保持するステートの更新処理を行い各Componentがステートを要求した場合は自身が保持するステートを返却する。そのためStoreServiceはステートのImmutabilityに注意が必要だ。なぜなら各Componentとステートのオブジェクトをシェアしてしまうと予期せぬ変更(mutate)が起こりえるからだ。また各ComponentもStoreServiceから取得したステートのSubscriptionを適切に処置しなければならない。SubscribeしたままComponentがDisposeされるとメモリーリークの原因となる。

StoreServiceはオブジェクトを渡す、または受け取るときには必ずCloneをし、ステートをImmutableに保っている。

store-service.ts
export class StoreService {
  private cachedHeroes!: Hero[];
  private heroes$ = new BehaviorSubject<Hero[]>([]);
  
  constructor(
    private heroService:HeroService,
    private messageService:MessageService) {   }

  getHeroes(): Observable<Hero[]> {
    if(!this.cachedHeroes){
      this.heroService.getHeroes().subscribe(heroes => {
        this.log('getHeroes fetched from db');
        this.cachedHeroes = heroes;
        this.notifyHeroes();
        });
    }           
    // Return a cloned array
    return this.heroes$.pipe(
          map(heroes => [...heroes.map(h => Object.assign({}, h))])
        ); 
  }

  addHero(hero:Hero):void{
    this.heroService.addHero(hero)
              .subscribe(hero => {
                  // Cache a cloned one
                  this.cachedHeroes.push({...hero});
                  this.notifyHeroes();
              });
  }

  deleteHero(id:number):void{
    this.heroService.deleteHero(id).subscribe(() => {
      this.cachedHeroes = this.cachedHeroes.filter(h => h.id !== id);
      this.notifyHeroes();
    });
  }

  getHero(id: number): Observable<Hero | undefined> {
    // Return a cloned one
    return this.getHeroes().pipe(
                    map(heroes => Object.assign({}, heroes.find(h => h.id === id)) )
                  );
  }

  updateHero(hero:Hero):Observable<any>{
    return this.heroService.updateHero(hero).pipe(
                  tap(_ => {
                    // Update a hero in the cached heroes with a cloned one
                    this.cachedHeroes[this.cachedHeroes.findIndex(h => h.id === hero.id)] = {...hero};
                    this.notifyHeroes();
                  }));
  }

  private notifyHeroes(){
    this.heroes$.next(this.cachedHeroes);
  }
  
  private log(message:string){
    this.messageService.add(`StoreService: ${message}`);
  }
}

StoreServiceからステートを取得するComponentはDestroyableComponentを継承している。DestroyableComponentはComponent破棄時にSubscribeしているObservableへtakeUntilを利用し一斉にUnsubscribeするよう通知を出す。

destoryable.component.ts
 export class DestroyableComponent implements OnDestroy {
  // To avoid memory leak, track a subscription from the data store and unsubscribe it on destroy
  protected unsubscriber$ = new Subject<void>(); 

  protected unsubscribeOnDestroy<T>(source:Observable<T>):Observable<T>{
    return source.pipe(takeUntil(this.unsubscriber$));
  }

  ngOnDestroy(): void {
    this.unsubscriber$.next();
    this.unsubscriber$.complete();
  }
}

Componentはステート取得時にunsubscribeOnDestroyを実行する必要がある。

heroes.component.ts
  export class HeroesComponent extends DestroyableComponent implements OnInit {
  heroes:Hero[] = [];
  HeroType=HeroType;

  constructor(private storeService:StoreService, private messageService:MessageService) { 
    super();
  }
  
  getHeroes(): void {
    this.storeService.getHeroesKeepingUpdated()
        .pipe((s) => this.unsubscribeOnDestroy(s))
        .subscribe(heroes => this.heroes = heroes);
  }

  ngOnInit(): void {
    this.getHeroes();
  }

  add(name:string):void{
    name = name.trim();
    if(!name) {
      return;     
    }
    const type = HeroType.Classical;
    this.storeService.addHero({ name, type } as Hero).subscribe();
  }

  delete(hero:Hero):void{
    // It would be better to block UI interaction while deleting a hero
    this.storeService.deleteHero(hero.id).subscribe();
  }
}

Commandパターン

ローカルストアの実装が済んだのでCommandパターンを利用してUndoRedo機能の実装を行う。

UndoRedo機能の追加されたTour of Heroes

Commandのinterfaceは以下になる。executeは次のステートへの変更を行い、undoは現在のステートへの(戻す)変更を行う。redoはexecuteを呼ぶだけだ。UndoRedo処理を行うときセマンティックになるようにexecuteとredoを分けているが、気にならない人は分ける必要はないだろう。descriptionはこのアプリケーションでCommand内容をを分かりやすく表示するために使っている。

command.ts
export interface Command<t>{
        execute():Observable<t>;
        undo():Observable<t>;
        redo():Observable<t>;
        description:string;
    }

Commandは各処理のステートを保持し、UndoまたはRedo処理に応じて適切なステートとともにReceiver[1](ここではStoreService)を呼び出す。処理の詳細はCommandの責任ではない。各Componentは変わらずStoreServiceへデータ関連の処理を要求する。

command.ts
export class AddHeroCommand implements Command<Hero>{
    description:string;
    hero:Hero;
    constructor(
        hero:Hero,
        private storeService:StoreService
        ){
            this.hero = {...hero} as Hero;  // Clone a state
            this.description = `Add ${hero.name}`;
        }
    execute(): Observable<Hero> {
        return this.storeService.addHeroCore(this.hero)
                    .pipe(
                        tap(hero => {
                            this.hero.id = hero.id;   // We need a hero's ID to delete him/her later!
                        })
                    );
    }
    undo(): Observable<Hero> {
        return this.storeService.deleteHeroCore(this.hero.id);
    }
    redo(): Observable<Hero> {
        return this.execute();
    }    
}

export class DeleteHeroCommand implements Command<Hero>{
    description:string;
    constructor(
        private hero:Hero,
        private storeService:StoreService
        ){
            this.description = `Delete ${hero.name}`;
        }
    execute(): Observable<Hero> {
        return this.storeService.deleteHeroCore(this.hero.id);
    }
    undo(): Observable<Hero> {
        return this.storeService.addHeroCore(this.hero);
    }
    redo(): Observable<Hero> {
        return this.execute();
    }    
}

export class UpdateHeroCommand implements Command<Hero>{
    description:string;
    constructor(
        private oldHero:Hero,
        private newHero:Hero,
        private storeService:StoreService
        ){
            this.description = `Update ${oldHero.name} to ${newHero.name}`;
        }
    execute(): Observable<Hero> {
        return this.storeService.updateHeroCore(this.newHero);
    }
    undo(): Observable<Hero> {
        return this.storeService.updateHeroCore(this.oldHero);
    }
    redo(): Observable<Hero> {
        return this.execute();
    }    
}

StoreServiceは要求された各種処理のCommandを生成し、Invoker[1:1](またはCallerとも呼ぶ。ここではUndoRedoService)へとCommandを渡しInvokerを実行する。各種処理の詳細はStoreServiceの責任のままだ。

store.service.ts
 export class StoreService {
  private cachedHeroes!: Hero[];
  private heroes$ = new BehaviorSubject<Hero[]>([]);
  
  constructor(
    private heroService:HeroService,
    private messageService:MessageService,
    private undoRedoService:UndoRedoService) {   }

  getHeroesKeepingUpdated(onlyOnce:boolean=false): Observable<Hero[]> {
    if(!this.cachedHeroes){
      this.heroService.getHeroes().subscribe(heroes => {
        this.log('getHeroes fetched from db');
        this.cachedHeroes = heroes;
        this.notifyHeroes();
      });
    }           
    // Return a cloned array
    return this.heroes$.pipe(
          (onlyOnce ? take(1) : identity),  // Notify only once or keep updated
          map(heroes => [...heroes.map(h => Object.assign({}, h))])
        ); 
  }

  getHero(id: number): Observable<Hero | undefined> {
    // TODO : Fix returning an empty hero when a detail page is requested first
    return this.getHeroesKeepingUpdated(true).pipe(
                    map(heroes => heroes.find(h => h.id === id))
                  );
  }

  addHero(hero:Hero):Observable<Hero>{
    const command = new AddHeroCommand(hero, this);
    return this.undoRedoService.execute(command);
  }

  addHeroCore(hero:Hero):Observable<Hero>{
    return this.heroService.addHero(hero)
                    .pipe(
                        tap(hero => {
                            // Cache a cloned one
                              this.cachedHeroes.push({...hero});
                              this.notifyHeroes();
                        })
                    );
  }

  deleteHero(id:number):Observable<Hero>{
    return this.getHero(id).pipe(
                  filter(hero => !!hero),
                  map(hero => hero as Hero),
                  switchMap(hero => {
                      const command = new DeleteHeroCommand(hero, this);
                      return this.undoRedoService.execute(command);
                    })
                );      
  }

  deleteHeroCore(id:number):Observable<Hero>{
    return this.heroService.deleteHero(id)
                    .pipe(
                        tap(_ => {
                          this.cachedHeroes = this.cachedHeroes.filter(h => h.id !== id);
                          this.notifyHeroes();
                        })
                    );
  }

  updateHero(hero:Hero):Observable<any>{
    return this.getHero(hero.id).pipe(
                  filter(hero => !!hero),
                  map(hero => hero as Hero),
                  switchMap(old => {
                      const command = new UpdateHeroCommand(old, Object.assign({}, hero), this);
                      return this.undoRedoService.execute(command);
                    })
                 );
  }

  updateHeroCore(hero:Hero):Observable<any>{
    return this.heroService.updateHero(hero)
            .pipe(
              tap(_ => {
                // Update a hero in the cached heroes with a cloned one
                this.cachedHeroes[this.cachedHeroes.findIndex(h => h.id === hero.id)] = {...hero};
                this.notifyHeroes();
             }));   
  }

  private notifyHeroes(){
    this.cachedHeroes = this.cachedHeroes.sort((a, b) => a.id - b.id);
    this.heroes$.next(this.cachedHeroes);
  }
  
  private log(message:string){
    this.messageService.add(`StoreService: ${message}`);
  }
}

UndoRedoServiceはexecuteで渡されたCommandをUndoスタックに積み、UndoまたはRedoの要求に従ってUndoスタック、Redoスタックを適切に管理し取り出されたCommandを実行する。スタックの上限設定の要件がある場合はスタックに積む際にスタックをpopして不要なCommandを破棄すればよい。

undo-redo.service.ts
export class UndoRedoService {

  private isRedoable$ = new BehaviorSubject<boolean>(false);
  private isUndoable$ = new BehaviorSubject<boolean>(false);

  undoStack: Array<Command<any>> = [];
  redoStack: Array<Command<any>> = [];

  constructor() { }

  getRedoable():Observable<boolean>{
    return this.isRedoable$.asObservable();
  }
  getUndoable():Observable<boolean>{
    return this.isUndoable$.asObservable();
  }

  execute<T>(command:Command<T>):Observable<T>{
    return command.execute().pipe(
      tap(_ => {
        this.undoStack.unshift(command);
        this.redoStack.length = 0;
        this.updateStacks();
      })
    );    
  }
  undo<T>():Observable<T>{
    const command = this.undoStack.shift();
    if(command){
      return command.undo().pipe(
        tap(_ => {
          this.redoStack.unshift(command);
          this.updateStacks();
        }));
    }
    return of();
  }
  redo<T>():Observable<T>{
    const command = this.redoStack.shift();
    if(command){
      return command.redo().pipe(
        tap(_ => {
          this.undoStack.unshift(command);
          this.updateStacks();
        }));
    }
    return of();
  }
  private updateStacks(){
    this.isUndoable$.next(this.undoStack.length > 0);
    this.isRedoable$.next(this.redoStack.length > 0);
  }
}

おわりに

Commandパターン自体は非常に簡素なのが理解できたかと思う。ただローカルストアのImmutabilityの実装と利用者である各Component側でSubscriptionへの配慮は注意が必要だ。前述したがここらへんの実装で手を抜くとメモリーリークや予期せぬステートの変更が発生するだろう。次回はローカルストアをNgRXに変更したCommandパターンを解説する。

脚注
  1. https://refactoring.guru/design-patterns/command のStructureの画像を参照してもらいたい ↩︎ ↩︎

Discussion