前回、初めて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行分のデータを表すオブジェクト(Entity)を作成する
- Entityを操作するためのオブジェクト(Repository)を作成する
(※今回はシートのIOもRepositoryで行います。)
となります。
※DDDにあまり明るくないので、厳密な言葉の定義は自信がないので、ご容赦ください
Entityの実装
Entityは一行分のレコードを保存します。
各カラムの値を保持するためのフィールドと、
行番号を保持するためのフィールドを定義します。
(スプレッドシートでは、レコードは行番号で特定されるため)
また、カラム名と列番号の対応の設定も記載します。
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;
}
}
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はコンストラクタで受け取っています。
また、配列のインデックスと列番号の差 等は、ここで吸収しています。
また、特定のカラムのみ更新するようなメソッドや、削除するメソッドは今回使用しないため、作成していません。
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)
}
}
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;
}
}
class AuthorRepository extends AbstractSheetRepository {
constructor({sheet, Entity}) {
super({sheet, Entity});
}
}
メソッド名が馴染みのあるものになってたので、だいぶスッキリしましたね。
ここまでする必要があるのか、という気持ちはありますが、
ある程度汎用的な形にすることができました。
(その分泥臭く、効率的ではない処理だと思います。)
操作クラス
メインとなる処理はこのようになります。
各種クライアントとの通信が入っているので、まだごちゃごちゃしていますが、
シートの存在はほとんど意識せずにすむようになりました。
QiitaApiから記事を取得し、シートに登録する処理
/**
* スプレッドシートに新規に追加された
* 記事を登録する
* @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);
}
新着記事があるかをチェックし、新着記事がある場合には、通知を行う処理
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
結局、辛い