LoginSignup
9
3

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-06-23

前回、初めてGASを触ってみたところ、思ったより辛かったので、それならばと思い、、、、

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

TL;DR
結局、辛い

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

何が辛いか

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

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

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

例えば、こんなコード。

シートからデータを取得するとき、カラム番号で指定しています。

  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]}));
  }

シートにデータを保存するとき、列名や列番号を指定しています。

  /**
   * シートに新規記事リストを追加する
   * @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と大それた名前を言いましたが、やりたいことは2つ。

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

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

Entityの実装

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

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

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;
  }

}
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がサポートされていません。
 そのため、トップレベルで継承を使用しているので、読み込み順を気にする必要があります。
 親クラスは子クラスより先に読み込むようにしてください)

参考:

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

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

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)
  }
}
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;
  }
}
Repository/AuthorRepository
class AuthorRepository  extends AbstractSheetRepository {
  constructor({sheet, Entity}) {
    super({sheet, Entity});
  }
}

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

操作クラス

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

QiitaApiから記事を取得し、シートに登録する処理

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);
}

新着記事があるかをチェックし、新着記事がある場合には、通知を行う処理

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で、ここまでやる必要があったのだろうか・・・・・

TL;DR
結局、辛い

9
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
3