【GAS】GAS+スプレッドシートが思ったより辛かったので、EntityとRepositoryを導入する

[前回](https://qiita.com/taimax/items/d5e25214c9a2a6b43c4f)、初めてGASを触ってみたところ、思ったより辛かったので、それならばと思い、、、、

※言うまでもないですが、**個人の感想**です

:::note warn
TL;DR 
結局、**辛い**
:::

※本記事は下記の記事の続きとなります

https://qiita.com/taimax/items/d5e25214c9a2a6b43c4f


## 何が辛いか

GASはスプレッドシートと組み合わせると、　VBAのように使用することができます。

JavaScriptベースなので、非常に書き味よく表操作できて嬉しいな、というところなのですが、結局の所、扱うデータがスプレッドシートに紐付いているため、行番号であったり、列番号にガッツリと依存しているため、中々辛いところがあります。

特に気をつけないで書くと、マジックナンバーの嵐となり、万が一表の構造が変わったら？と思うと、直すのが本当に大変です。
（実際に前回の記事で、記事の重複チェックをURLから記事IDに変更しようとしたら結構たいへんでした・・・・・）

例えば、こんなコード。

シートからデータを取得するとき、カラム番号で指定しています。
```javascript
  const fetchNewArticle = function(){
    return articleSheet.getDataRange().getValues()
    .map((row,index) => ([...row, index + 1]))
    .filter(row => row[6] == 0)
    .map(row => ({no: row[0], title: row[1], url:row[3], author:row[4], created:row[5], rowNum:row[7]}));
  }
```
シートにデータを保存するとき、列名や列番号を指定しています。

```javascript
  /**
   * シートに新規記事リストを追加する
   * @param newArticle
   * @void
   */
  const addArticle = function(newArticle){
    //　追加する行番号を取得する
    const rowNum = articleSheet.getLastRow()+1;

    // No
    articleSheet.getRange("A" + rowNum).setValue(rowNum-2);
    articleSheet.getRange("B" + rowNum).setValue(newArticle.title);
    articleSheet.getRange("C" + rowNum).setValue(newArticle.id);
    articleSheet.getRange("D" + rowNum).setValue(newArticle.url);
    articleSheet.getRange("E" + rowNum).setValue(newArticle.author);
    articleSheet.getRange("F" + rowNum).setValue(newArticle.created);
    // 通知済みか（デフォルト0）
    articleSheet.getRange("G" + rowNum).setValue(0);
  }
```

行番号・列番号にガッツリ依存してしまっています・・・・・。

上記の例では列名をアルファベットで指定しますが、
相対位置や絶対位置で指定していると、マジックナンバーの嵐になってしまいます。
これだとシート側に構造の変更なんてできたものではありません。
（列名の数だけ定数セットするのもそれはそれで・・・・・・）


## 解決方法

普段プログラミングをしている身からすると、列番号とか列番号とか、スプレッドシートを操作しているのはなるべく隠蔽したいです。

こんなときに、他の言語ではどう解決していたかな、と考えることです。例えば、私は先日まで、SpringBootを教えていました。DBとの連携でJPAを使用しましたが、まさにJPAってテーブルを隠蔽していたんですよね。

なので、EntityとRepository（的なもの）を導入します。

## やること

EntityとRepositoryと大それた名前を言いましたが、やりたいことは２つ。

1. 1行分のデータを表すオブジェクト(Entity)を作成する
1. Entityを操作するためのオブジェクト(Repository)を作成する
(※今回はシートのIOもRepositoryで行います。)

となります。
※DDDにあまり明るくないので、厳密な言葉の定義は自信がないので、ご容赦ください

## Entityの実装

Entityは一行分のレコードを保存します。
各カラムの値を保持するためのフィールドと、
行番号を保持するためのフィールドを定義します。
（スプレッドシートでは、レコードは行番号で特定されるため）

また、カラム名と列番号の対応の設定も記載します。


```javascript:Entity/Article
class Article {
  constructor({row, no, title, articleId, url, author, created, posted}) {
    this._row = row;
    this._no = no;
    this._title = title;
    this._articleId = articleId;
    this._url = url;
    this._author = author;
    this._created = created
    this._posted = posted;
  }

  // Entityと列の対応を書く
  // フィールド名：列番号
  static get columns(){
    return {
      no:1,
      title:2,
      articleId:3,
      url:4,
      author:5,
      created:6,
      posted:7.
    }
  }
  
  get row() {
    return this._row;
  }
  get no() {
    return this._no;
  }
  get title() {
    return this._title;
  }
  get articleId() {
    return this._articleId;
  }
  get url() {
    return this._url;
  }
  get author() {
    return this._author;
  }
  get created() {
    return this._created;
  }
  get posted() {
    return this._posted;
  }

}
```
```javascript:Entity/Author
class Author {
  constructor({row, id}) {
    this._row = row;
    this._id = id;
  }

  // Entityと列の対応を書く
  static get columns(){
    return {
      id:1,
    }
  }
  get row() {
    return this._row;
  }
  get id() {
    return this._id;
  }
 
}
```


## Repositoryの実装

RepositoryではEntityを操作を行います。
特に永続化コンテキストのような概念ではなく、
ガッツリとデータの永続化ロジックを呼び出しています。

必要最低限のロジック

- 全件取得
- 行データ→Entityの変換
- Entityの保存

はAbstractSheetRepositoryという名の普通のクラスに記載し、それを継承する形でシート別のRepositoryを作成しました。(~~JavaScript辛い~~)
（注：GASではexport/importがサポートされていません。
　そのため、トップレベルで継承を使用しているので、読み込み順を気にする必要があります。
　親クラスは子クラスより先に読み込むようにしてください）

参考：

https://zenn.dev/tacck/articles/20211218_gas


シート別のRepositoryでは、共通の操作に加え、固有のロジックを記載しています。

~~各実装はだいぶ泥臭いことになっていますが・・・・~~
操作対象のクラスと、操作対象のEntityはコンストラクタで受け取っています。
また、配列のインデックスと列番号の差　等は、ここで吸収しています。
また、特定のカラムのみ更新するようなメソッドや、削除するメソッドは今回使用しないため、作成していません。

```javascript:Repository/AbstractSheetRepository
class AbstractSheetRepository {
  constructor({sheet, Entity}) {
    this._sheet = sheet;
    this._Entity = Entity;
  }

  get sheet(){
    return this._sheet
  }

  save(entity){
    // Entitiyで設定した、キーと列の対応を使用して、1カラムずつ保存する
    for (const prop in this._Entity.columns) {
      if (this._Entity.columns.hasOwnProperty(prop)) {
        this.sheet.getRange(entity.row,this._Entity.columns[prop]).setValue(entity[prop] ?? "");
      }
    }
    return entity;
  }

  findAll(){
    return this.sheet.getDataRange().getValues()
    .map((rowData,index) => ({rowData, row:index + 1}))
    .map(rowData => this.createEntityByRowData(rowData));
  }

  createEntityByRowData({rowData, row}){
    let rowObj={ row };
    // Entitiyで設定した、キーと列の対応を使用して、Entityを生成する
    for (const prop in this._Entity.columns) {
      if (this._Entity.columns.hasOwnProperty(prop)) {
        rowObj[prop] = rowData[this._Entity.columns[prop] -1];
      }
    }
    return new this._Entity(rowObj)
  }
}
```


```javascript:Repository/ArticleSheetRepository
class ArticleRepository extends AbstractSheetRepository {
  constructor({sheet, Entity}) {
    super({sheet, Entity});
  }

  /**
   * 新規記事を作成する
   */
  create({title, articleId, url, author, created, posted}) {
    return new Article({title, articleId, url, author, created, posted});
  }

  save(article){
    // 新規登録の場合、行番号とNoが不明のため個々で生成する
    const row = article.row ?? this.sheet.getLastRow() +1;
    const no = article.no ?? this.sheet.getLastRow() -1;
    const saveArticle = new Article({title:article.title, articleId:article.articleId, url:article.url, author:article.author, created:article.created, posted:article.posted,row, no})
    return super.save(saveArticle);
  }

  /**
   * 通知済みの状態で絞り込む
   */
  findByPosted(posted){
    return this.findAll().filter(article => article.posted == posted);
  }

  /**
   * 通知済みの状態に変更する
   */
  markPosted(article){
    const postedArticle = new Article({row:article.row, no:article.no, title:article.title, articleId:article.articleId, url:article.url, author:article.author, created:article.created, posted:1});
    this.save(postedArticle);
    return postedArticle;
  }
}
```
```javascript:Repository/AuthorRepository
class AuthorRepository  extends AbstractSheetRepository {
  constructor({sheet, Entity}) {
    super({sheet, Entity});
  }
}
```

メソッド名が馴染みのあるものになってたので、だいぶスッキリしましたね。
ここまでする必要があるのか、という気持ちはありますが、
ある程度汎用的な形にすることができました。
（その分泥臭く、効率的ではない処理だと思います。）

## 操作クラス

メインとなる処理はこのようになります。
各種クライアントとの通信が入っているので、まだごちゃごちゃしていますが、
シートの存在はほとんど意識せずにすむようになりました。


QiitaApiから記事を取得し、シートに登録する処理
```javascript:UseCase/AddArticle
/**
 * スプレッドシートに新規に追加された
 * 記事を登録する
 * @void
 */
function addArticle() {
  // プロパティを取得
  const prop = PropertiesService.getScriptProperties();
  const URL = 'https://qiita.com/api/v2/items?page=1&per_page=30';
  const spreadSheat = SpreadsheetApp.openById(prop.getProperty("SHEET_ID"));
  const articleSheet = spreadSheat.getSheetByName(prop.getProperty("SHEET_NAME_ARTICLE"));
  const articleRepository = new ArticleRepository({sheet:articleSheet, Entity:Article});
  const authorSheet = spreadSheat.getSheetByName(prop.getProperty("SHEET_NAME_USER"));
  const authorRepository = new AuthorRepository({sheet:authorSheet, Entity:Author});

  /**
   * ユーザーが書いた記事を検索するためのクエリストリングを生成する
   */
  const createUserQueryString = function(users){
    let queryString="";
    users.forEach((user, index) => {
      if(index != 0 ){queryString += "+OR+"}
      queryString +=  `user:${user}`})
    return queryString;
  }

  /**
  * 対象ページから記事一覧を取得する
  * @return [title, url, author, created ]
  */
  const fetchNewArticles = function(authors){
    // リクエストオプションの指定
    const options = {
      "method" : "get"
    };
    // 検索文字列の作成
    const queryString = '&query=' + createUserQueryString(authors);
    // リクエスト実行
    const response = UrlFetchApp.fetch(URL + queryString,options).getContentText('UTF-8');
    Logger.log(URL + queryString);
    // JSONにParse
    const articles = JSON.parse(response);
    // フォーマットして返却
    return articles.map(article => articleRepository.create({articleId:article.id,title:article.title, url:article.url, author:article.user.id, created:article.created_at, posted:0}));
  }

  /**
   * 記事一覧をシートにマージする。
   * すでに登録されているURLの場合は追加しない。
   * @param  newArticles 新規追加記事
   * @void
   */
  const mergeArticles = function(newArticles){
    // シートに記載されている記事のURL一覧を取得する(ヘッダー行はあっても支障がないため無視する)
    const articleIds = articleRepository.findAll().map(article => article.articleId);
    // 新記事リストのURLが存在しない場合にはデータを追加する
    newArticles.filter(newArticle => !articleIds.includes(newArticle.articleId))
    .forEach(newArticle => articleRepository.save(newArticle));
  }

  }

  // 検索対象のユーザーを取得
  const authors = authorRepository.findAll().map(author => author.id);
  // 記事一覧を取得する
  const newArticles = fetchNewArticles(authors);
  // シートに追加する
  mergeArticles(newArticles);
}

```

新着記事があるかをチェックし、新着記事がある場合には、通知を行う処理
```javascript:UseCase/PostChat
function postArticleToChat() {
  // プロパティを取得
  const prop = PropertiesService.getScriptProperties();
  const spreadSheet = SpreadsheetApp.openById(prop.getProperty("SHEET_ID"));
  const articleSheet = spreadSheet.getSheetByName(prop.getProperty("SHEET_NAME_ARTICLE"));
  const articleRepository = new ArticleRepository({sheet:articleSheet, Entity:Article});

  /**
   * Chat送信用のクライアントを取得する
   */
  const createChatClient = function(){
    if(prop.getProperty("ENABLE_SEND_CHAT") == 1){
      const token = prop.getProperty("CHATWORK_TOKEN");
      return ChatWorkClient.factory({token: token});
    }else{
      const mockClient = class{
        sendMessage({room_id, body}){ Logger.log(body);}
      };
      return new mockClient();
    }
  }

  const sendArticleToChat = function(client, article){
    articleRepository.markPosted(article);

    const message =  `
【Qiita新着投稿】
Qiitaに新規記事が投稿されました。
[info]
${article.title}
URL：${article.url}
[/info]
※本メッセージは自動投稿です。
`;
    const room_id = prop.getProperty("CHATWORK_ROOM_ID");
    // チャットに送信
    client.sendMessage({
          room_id: room_id,
          body: message
        });
  }

  // 対象記事一覧を取得する
  newArticles = articleRepository.findByPosted("0");
  if(newArticles.length　==　0){
    // 対象が0件のときは通知しない
    Logger.log("通知対象:0件");
    return;
  }

  // チャットで通知する
  const client = createChatClient();
  newArticles.forEach(article => sendArticleToChat(client, article));
}
```

## まとめ

- EntityとRepositoryを導入することで、シートの存在を隠蔽することができた
- ただし、JavaScriptでクラス扱うの辛い
- 他のプログラム・シートでも応用できそう？（要検証）
- そもそもそんなにたくさんのシートを扱うのか
- 可読性は・・・・どうなのでしょう？（馴染みやすいメソッド！）
- 結局JavaScriptが辛いので、TypeScriptを使いましょう
- どうせシートにべったり依存するならActiveRecord的な発想でも良かったか
- そもそもカラムとフィールドのマッピングだけできればいいのでは？

~~果たして、気軽に作りたいと思って始めたGASで、ここまでやる必要があったのだろうか・・・・・~~

:::note warn
TL;DR 
**結局、辛い**
:::