こんにちは、おかちゃんせんせいです!
今回は、
GASで大量データの二次元配列をGoogleスプレッドシートに反映させる方法
について解決したので、ブログにまとめることにします。
目次
実行時間制限をクリアするためには?
GASでよく話題になるのが、6分の壁問題。
6分以上の処理を実行すると、エラーで処理が中断されてしまいます。
一方、Google Workspaceを契約している場合は、6分ではなく30分となります。
5倍も実行時間が長くなるのはかなり有難いですが、それでも大量データ(約15万行以上)の二次元配列をGoogleスプレッドシートに反映させようとすると30分以上処理がかかってしまうようになり、エラーになる可能性が高くなります。
15万行以上のデータをsetValueメソッドで一行ずつ反映させる方法では、かなり時間がかかってしまいます。
そこで、二次元配列データをsetValuesメソッドで反映させればよいのではないかと試みたのですが、、、
データ数が多くなると、それでもエラーが発生するようになってしまいました。
では、どのようにエラーを回避すればよいのでしょうか?
ネットで検索したところ、大きく分けて方法は下記2つが紹介されています。
- HTMLを用いた方法
- トリガーを用いた方法
前者については、HTMLの知識が多少なりとも必要になってしまうため、極力属人化を避ける意味でも後者の方を検討することにしました。
トリガーを用いた方法で実装してみる
用意したスプレッドシートは2つで、
・顧客情報データ(元となるデータ)
・男データ(反映させるデータ)
です。
顧客情報データが入力されているスプレッドシートから性別が「男」のデータだけを抽出して、男データに反映させるフローを想定してサンプルは作りました。
別に元となるデータはスプレッドシートでなくても、最終的にスプレッドシートに反映させるデータを二次元配列にすれば問題ありません。
下記にサンプルコードを記載しますが、こちらは元となるスプレッドシートのApps Scriptに記載します。
/**
* 顧客情報データから男データのみを抽出して、スプレッドシートに反映させるメイン関数
*/
function setData() {
/* ステップ0: 大量データを処理するための事前準備 */
// 実行開始時刻(ミリ秒)を取得する
const startTime = Date.now();
// メイン関数がトリガーに残っていたら削除する
deleteTriggers();
/* ステップ1: 実行するスプレッドシートの情報を取得する */
// 処理するシートをセットする
const pasteSPID = "16E4gyAcIzO2f1BQwE5pH4fax9y2F27GjPn6zkjfNUMg";
const shName = "男"
const ss1 = SpreadsheetApp.getActiveSpreadsheet();
const sh1 = ss1.getActiveSheet();
const ss2 = SpreadsheetApp.openById(pasteSPID);
const sh2 = ss2.getSheetByName(shName);
// スクリプトプロパティを取得する
let setLASTROW = Number(PropertiesService.getScriptProperties().getProperty("LASTROW")); // 最終行
let setCnt = Number(PropertiesService.getScriptProperties().getProperty("CNT")); // 分割回数
// 顧客情報データから「性別」(D列)が男のデータ(ヘッダー項目含む)を抽出する
let values;
let setValues;
values = sh1.getDataRange().getValues();
setValues = values.filter(x => (x[3] === "男" || x[3] === "性別"));
setDATA = setValues;
console.log("ステップ1完了");
/* ステップ2: 大量データをスプレッドシートに貼りつけるためのデータを取得する */
let data = [];
let num = 0
let lastRow = 0;
// 必要な列データだけ抽出する(連番を除くデータ)
data = setValues.map(elm => [elm[1],elm[2],elm[3],elm[4],elm[5]]);
// スクリプトプロパティ「SETLASTROW」にデータが格納されていない場合
if(setLASTROW === 0){
// データを一旦初期化する
lastRow = sh2.getLastRow();
if(lastRow !== 0){
sh2.getRange(1,1,lastRow,data[0].length).clearContent();
}
// スクリプトプロパティにデータが格納されている場合
}else{
// dataから分割を開始するインデックス番号を設定する
num = setLASTROW;
}
// 分割回数および最後に残る余りの数を取得する
const limitNumber = 10000;
let actNum = Math.ceil(data.length / limitNumber);; // 分割回数
let fraction = 0; // 分割処理して最後に余る数
// 分割回数が2回以上、かつ、データ数が上限数より小さい場合
if(setCnt >= 2 && data.length < limitNumber){
fraction = data.length;
}else{
fraction = data.length - (actNum-1)*limitNumber;
}
console.log("ステップ2終了");
/* ステップ3: 必要なタイムログデータをスプレッドシートに貼りつける */
// 分割分繰り返しデータをセットして、スプレッドシートに貼りつける
for(let i = setCnt; i <= actNum; i++){
// もし規定時間を超えた場合には、スクリプトプロパティの保存・トリガーの作成を実施する
if(Date.now() - startTime > (10 * 1000)){
lastRow = sh2.getLastRow();
PropertiesService.getScriptProperties().setProperty("LASTROW",lastRow);
PropertiesService.getScriptProperties().setProperty("CNT",i);
createTrigger();
return;
}
// スプレッドシートに反映させる分だけデータを分割する
let divData;
// 繰り返し回数 ≠ 分割回数
if(i !== actNum){
divData = data.slice(num,(i-1)*limitNumber + limitNumber + (i-1));
}else{
divData = data.slice(num,(i-1)*limitNumber + fraction);
}
// エラー処理
try{
// スプレッドシートに反映させる
sh2.getRange(num+1,1,divData.length,divData[0].length).setValues(divData);
}catch(e){
// スクリプトプロパティを初期化する
initialization();
console.error('エラー内容:'+e.message);
return;
}
num = i * limitNumber + (i-1);
console.log("分割処理:" + i + " 回目終了");
}
// スクリプトプロパティを初期化する
initialization();
console.log("ステップ3終了");
console.log("データのインポート処理を完了しました。");
}
/**
* スクリプトプロパティを初期化する関数
*/
function initialization(){
PropertiesService.getScriptProperties().setProperty("LASTROW",0);
PropertiesService.getScriptProperties().setProperty("CNT",1);
}
/**
* メイン関数を実行するトリガーを削除する関数
*/
function deleteTriggers(){
const triggers = ScriptApp.getProjectTriggers();
for(const trigger of triggers){
if(trigger.getHandlerFunction() === "setData") ScriptApp.deleteTrigger(trigger);
}
}
/**
* メイン関数を実行するトリガーを新規作成する関数
*/
function createTrigger() {
ScriptApp.newTrigger("setData")
.timeBased()
.after(1 * 60 * 1000)
.create();
}
今回ポイントとなるのは3つあります。
1つ目は、トリガーの設定。
2つ目は、スクリプトプロファイルの設定。
3つ目は、二次元配列の取り扱い方。
以上の3つについて、それぞれ順を追って解説します。
ステップ0: 大量データを処理するための事前準備
まずは、トリガーの設定についてです。
なぜ6分の壁を突破するためにトリガーが必要になるのかというと、エラーになる前にトリガー作成することを繰り返して、エラーを回避するためです。
そのために、重要になる設定も3つあります。
- 実行開始時刻の計測
- トリガーの削除
- トリガーの作成
実行時間について計測するためには、下記計算式で求めます。
現在時刻 – 実行開始時刻 = 実行時間
そこで、7行目でまずは実行開始時刻をDate.now()で取得します。
次は、メイン関数のトリガーが残っている場合に削除するdeleteTriggers関数を呼び出します。
/**
* メイン関数を実行するトリガーを削除する関数
*/
function deleteTriggers(){
const triggers = ScriptApp.getProjectTriggers();
for(const trigger of triggers){
if(trigger.getHandlerFunction() === "setData") ScriptApp.deleteTrigger(trigger);
}
}
ステップ1: 実行するスプレッドシートの情報を取得する
ここでのポイントは、
スクリプトプロパティを取得する
ところです。
Apps Script > [プロジェクト設定]の中で設定できるスクリプトプロパティ。
簡単に言うと、変数のようにデータの保存・取得ができるように定義できます。
スクリプトプロパティは、PropertiesServiceクラスのgetScriptPropertiesメソッドを用いることで取得や保存ができます。
ステップ1の段階では、開始時のスクリプトプロパティに保存してあるデータを取得して、変数に格納しておきます。
// スクリプトプロパティを取得する
let setLASTROW = Number(PropertiesService.getScriptProperties().getProperty("LASTROW"));
let setCnt = Number(PropertiesService.getScriptProperties().getProperty("CNT"));
また、メインのポイントではありませんが、
二次元配列から特定のデータを抽出する場合にはfilterメソッドを活用します。
// 顧客情報データから「性別」(D列)が男のデータ(ヘッダー項目含む)を抽出する
let values;
let setValues;
values = sh1.getDataRange().getValues();
setValues = values.filter(x => (x[3] === "男" || x[3] === "性別"));
まず、顧客情報データからデータ行をgetValuesで取得して、valuesに格納。
「性別」が“男”、または、“性別”のデータを抽出したいので、filterで絞り込みします。
「性別」はD列(列番号:4)にあるので、インデックス番号は3(4-1)となります。
もしfilterメソッドの使い方がよくわからない方は、下記サイトを参考にしてみてくださいね。
ステップ2: 大量データをスプレッドシートに貼りつけるためのデータを取得する
別のスプレッドシートに反映させるデータには、特定の列だけ分割して変数dataに格納します。
特に分割する必要がなければ、この処理は不要です。
// 必要な列データだけ抽出する(連番を除くデータ)
data = setValues.map(elm => [elm[1],elm[2],elm[3],elm[4],elm[5]]);
次に、スクリプトプロパティ「SETLASTROW」にデータが格納されているかどうかで条件分岐させています。
格納されていない場合は、一旦反映先(今回でいえば、男データ)のシート全体をクリア。
格納されている場合は、すでにdataの中から反映された分を除く必要があるので、dataから分割を開始するインデックス番号を設定します。
ここでポイントなのが、インデックス番号であって、行番号ではないということです。
※配列のインデックス番号は「0」から始まります。
// スクリプトプロパティ「SETLASTROW」にデータが格納されていない場合
if(setLASTROW === 0){
// データを一旦初期化する
lastRow = sh2.getLastRow();
if(lastRow !== 0){
sh2.getRange(1,1,lastRow,data[0].length).clearContent();
}
// スクリプトプロパティにデータが格納されている場合
}else{
// dataから分割を開始するインデックス番号を設定する
num = setLASTROW;
}
ステップ2の最後は、
二次元配列dataを分割するための処理です。
なぜ分割するのかというと、
大量データを一発でsetValesで反映しようとすると、上限が6分の場合あっという間にタイムアウトでエラーになってしまう可能性があるからです。
そのため、分割する上限値をlimitNumberという変数に格納し、分割回数をactNum、分割して最後に余る数をfractionという変数で設定。
条件分岐で適切な数値をそれぞれに格納します。その際に、分割回数が2回以上、かつ、データ数が上限数より小さい場合には、データ数がそのままfractionになります。
// 分割回数および最後に残る余りの数を取得する
const limitNumber = 10000;
let actNum = Math.ceil(data.length / limitNumber);; // 分割回数
let fraction = 0; // 分割処理して最後に余る数
// 分割回数が2回以上、かつ、データ数が上限数より小さい場合
if(setCnt >= 2 && data.length < limitNumber){
fraction = data.length;
}else{
fraction = data.length - (actNum-1)*limitNumber;
}
ステップ3: 必要なタイムログデータをスプレッドシートに貼りつける
最後のステップでは、分割回数分for文で繰り返し処理します。
まず、for文の冒頭には規定時間(実行時間)を超えた場合に、処理を中断するようにif文を用意します。
今回規定時間は5分で設定しているため、
5 * 60 * 1000
としています。
もし規定時間を超えた場合には、スクリプトプロパティへの保存とトリガーの作成(createtrigger関数)を実施します。
// もし規定時間を超えた場合には、スクリプトプロパティの保存・トリガーの作成を実施する
if(Date.now() - startTime > (5 * 60 * 1000)){
lastRow = sh2.getLastRow();
PropertiesService.getScriptProperties().setProperty("LASTROW",lastRow);
PropertiesService.getScriptProperties().setProperty("CNT",i);
createTrigger();
return;
}
createtrigger関数では、メイン関数であるsetDataをトリガーの関数として設定。
1分後に実施されるようにafter(1 * 60 * 1000)とします。
/**
* メイン関数を実行するトリガーを新規作成する関数
*/
function createTrigger() {
ScriptApp.newTrigger("setData")
.timeBased()
.after(1 * 60 * 1000)
.create();
}
規定時間を超えていない場合には、スプレッドシートに反映させる分だけのデータをsliceメソッドで分割して、divData変数に格納します。
分割するデータについては、繰り返し回数 = 分割回数の場合にはfraction分だけ反映させるようにif文で条件分岐させます。
// スプレッドシートに反映させる分だけデータを分割する
let divData;
// 繰り返し回数 ≠ 分割回数
if(i !== actNum){
divData = data.slice(num,(i-1)*limitNumber + limitNumber + (i-1));
}else{
divData = data.slice(num,(i-1)*limitNumber + fraction);
}
最後に、エラー処理(try~catch)を設定します。
エラーが発生しない場合は、分割した配列divDataをスプレッドシートに反映させます。
もしエラーが発生した場合には、スクリプトプロパティを初期化して処理を中断します。
仮に想定外のエラーが発生して処理が途中で中断してしまうと、スクリプトプロパティが初期化されていない状態のままになってしまい次回実行時に支障が出てしまうので、この処理は必須となります。
反映が終わったら、次に分割を開始するインデックス番号をnum変数に格納します。
// エラー処理
try{
// スプレッドシートに反映させる
sh2.getRange(num+1,1,divData.length,divData[0].length).setValues(divData);
}catch(e){
// スクリプトプロパティを初期化する
initialization();
console.error('エラー内容:'+e.message);
return;
}
num = i * limitNumber + (i-1);
/**
* スクリプトプロパティを初期化する関数
*/
function initialization(){
PropertiesService.getScriptProperties().setProperty("LASTROW",0);
PropertiesService.getScriptProperties().setProperty("CNT",1);
}
今回の方法はあくまでも一例。
実現したいことのフローによっては、変えた方が良い箇所は出てくると思います。
たとえば、大量データというほどではなく、ギリギリタイムアウトでエラーになってしまう場合にはsetValuesで配列を一発反映しなくても、setValueで一行ずつ反映させた方がコードは簡単になります。
ただし、setValuesと比べるとsetValueの場合には処理時間が圧倒的にかかってしまいますので、その点はご注意ください。
今回の情報が何かお役に立てば幸いです。
最後まで読んでいただき、ありがとうございました!
- トリガーの設定
- スクリプトプロファイルの設定
- 二次元配列の取り扱い方
独学で勉強する際に、ネットで検索しながら記事を参考にするだけで、ある程度はなんとかなってしまいます。
しかし、メソッドの使い方や、配列の扱い方などピンポイントの情報について調べるときに、欲しい情報がパッと見つからず時間がかかってしまうことが多いのではないでしょうか。
そんなときには、辞書的な位置づけで使える本を、手元に用意しておくと良いかもしれません。
とはいえ、独学で勉強しようとしても全然理解ができず、途中で挫折してしまうという方の話をよく聞きます。
そんなときには、オンラインで受講できる講座やレッスンで習得を検討してみてはいかがでしょうか。
お勧めのサービスは2つあります。
侍エンジニア塾
です。
VBAやJavaScript関連でネット検索したことがある方であれば、一度は見かけたことある名前かと思います。
JavaやPython、C言語、VBA、JavaScriptなど、様々なプログラミング言語をオンラインで習得するためのサービスを提供しているところなので、自分が習得したい言語がもしありましたら、無料体験レッスンを受けてみると良いかもしれません。
そこでプロ講師に相談をして、習得するプログラミング言語に対して直接質問するのがベストです。
ストアカ
です。
こちらはあえてプログラミング学習専門ではなく、もっと手軽に学ぶ機会を得るサービスをチョイスしてみました。
VBAやJavaScriptなどのワンツーマンレッスンを開催している講師が多数登録しているので、まずは出費を抑えて学習したいと考えている方にお勧めです。