まだプログラマーですが何か?

プログラマーネタとアスリートネタ中心。たまに作成したウェブサービス関連の話も http://twitter.com/dotnsf

タグ:node

以前にとある人から
 (例えば仮想マシン環境と比較して)コンテナ環境のデメリットはなんですか?
と質問され、一瞬返答に詰まってしまったことがありました。メリットを考えたことはあったけど、積極的にデメリットを考えたことがなく、即答できませんでした。ちゃんと正しく理解していないとなかなか難しい質問だと思っています。

改めて時間のある時に考えてみるといくつか思いつきます。まあ「デメリット」といえるかどうかはともかく、「VMでできてコンテナでできないこと」はいくつかあります。

その一つが "cron" ジョブだと思っています。特定時刻とか、何分おきにとか、実行タイミングのスケジュールを決めた上で実行する機能です。例えば kubernetes であれば CronJob を使って実現するなど、厳密には「コンテナで実現できない」わけではないのですが、そのコンテナ環境に合わせた対応が必要になるのはそれはそれでデメリットになりえますよね。

一方、アプリケーション開発レベルでは、これらのスケジュールジョブを併用することでアプリケーションとしての必要な機能を実現することは珍しくありません。1分毎にどこかからデータを取得して更新するとか、毎日○時に自動的にバックアップを取得するとかといった場合です。それらの機能が必要なアプリケーションをコンテナ環境で動かす可能性がある中で実装する場合、どういった方法を検討する必要があるでしょうか?


その答えの1つが「アプリケーションレベルで(アプリケーションの機能の一部として) cron ジョブを実装する」方法です。Node.js アプリケーションの場合は Node-Schedule ライブラリを使うと簡単に実現できそうだったので、その内容を以下で紹介します:
2020071200



Node-Schedule は Node.js で使えるライブラリで、cron ライクなスケジュールジョブを比較的簡単に(setTimeout とかを意識することなく)実現できます。またスケジュールの定義フォーマットは cron のものと互換性があるので、crontab に1行追加する感覚で、アプリケーション内にスケジュールジョブを追加・更新・削除できるものです。アプリケーションの中でスケジュールジョブを定義できるので、アプリケーションの実行環境(実機とか、VM とか、コンテナとか、・・)を意識する必要もありません。

例を1つ記述しておきます。以下のコードで1分おきにコンソールにメッセージを表示するウェブアプリケーションが作成できます(Node-Schedule に関係している部分のみ赤字):
// app.js
var schedule = require( 'node-schedule' );
var express = require( 'express' );
var app = express();

//. 毎分実行
schedule.scheduleJob( '* * * * *', function(){
  console.log( 'running a task every minute' );
});


app.get( '/', function( req, res ){
  res.write( JSON.stringify( { status: true }, null, 2 ) );
  res.end();
});


var port = process.env.PORT || 8080;
app.listen( port );
console.log( 'server started on ' + port );

上記コードから赤字部分を抜くと、ごくシンプルな Node.js + Express のウェブアプリケーションになるのがわかると思います。つまり Node-Schedule に関係しているのは赤字部分の実質4行だけです。

で、その赤字部分で何をしているのかというと、まず先頭行で require() して Node-Schedule ライブラリのモジュールを呼び出します:
// app.js
var schedule = require( 'node-schedule' );

そして scheduleJob() メソッドを使ってジョブを(イメージとしては cron に)登録します:
//. 毎分実行
schedule.scheduleJob( '* * * * *', function(){
  console.log( 'running a task every minute' );
});

この第一パラメータは cron に登録する時に指定する時刻フォーマットと互換性のある文字列表現を使います。'* * * * *' は「毎分実行」を意味しています。

そして第二パラメータには該当時刻になったら実行するコールバック関数を指定します。上の例では 'running a task every minute' とコンソールに表示するだけの内容にしていますが、実際にはここに crontab の最後に指定するコマンドを登録することになります。これで1分ごとに 'running a task every minute' という文字列がコンソールに表示され続けるジョブが登録できたことになります。


なお、一度登録したジョブをキャンセルするには登録時の scheduleJob() メソッドの実行結果オブジェクトを受け取り、そのオブジェクトの cancel() メソッドを実行します:
//. 毎分実行
var job = schedule.scheduleJob( '* * * * *', function(){
  console.log( 'running a task every minute' );
});

  :

job.cancel(); //. 登録したジョブをキャンセル

ジョブの実行条件や実行内容を更新する場合は一度キャンセルしてから再登録することで実現できます。


最近のクラウド環境は PaaS 化が進み、どういうコンテナ環境を使っているのかよくわからないことがあるかもしれません。アプリケーションを Node.js で記述するという条件はありますが、この方法で実装していればコンテナ環境に依存しないスケジュールジョブが実現できそうです。


Node.js + Express 環境でウェブアプリケーションを動かす際のリクエストタイムアウト値(デフォルトは2分)を変更する方法を調べました。

Node.js はシングルスレッドなので、あまりタイムアウト値を長くしすぎるとそれはそれで支障がでることも考慮する必要があります。その上でタイムアウト値を変更するには以下のようにします:
var express = require( "express" ),
    app = express();

  :
  :

var app_server = app.listen( 8080 );
app_server.timeout = 1000 * 60 * 3;   //. 3分

ポート番号(上例では 8080)を指定して listen() を実行した結果の返り値(app_server)を取得し、その timeout プロパティ値をミリ秒単位で指定するだけです。


くどいようですがもう一度。Node.js はシングルスレッドなので、この値を大きく設定しすぎると、リクエスト処理に待ち行列が発生しやすくなるので要注意を。


令和最初のブログエントリです。


Node.js アプリの中で git を使う方法を調べてみました。git で Node.js のソースコードを管理する、のではなく、アプリケーションの中で特定の git リポジトリに対して clone したり、pull したり、add して commit して push したり、、、といった操作を Node.js アプリ側から行う方法、という意味です。

この操作を実現するためのライブラリとして simple-git というものを使ってみました。npm を使って以下のコマンドを実行することで導入できます:
$ npm install simple-git

2019050201


そして、以下のコードで扱う git リポジトリは https://github.com/dotnsf/simple-git-sample.git であると仮定します。異なるリポジトリで実験する場合は( fork してコピーを作るなどして)適宜読み替えてください。またローカル側のワーキングフォルダ(git で管理するフォルダ)は ./work/ であると仮定します。


【初期化】
まず、このライブラリを使って Node.js アプリを作る際の初期化方法を紹介します(実はここが一番ややこしい所だったりします)。ここは「実行時に git clone して初期化」するのか、「実行時に git pull して初期化」するのかによって手順が異なります。

実行時に git clone して初期化する場合は以下のようにします:
//. git-clone.js
var git = require( 'simple-git' );

var git_url = 'https://github.com/dotnsf/simple-git-sample.git';
var local_folder = 'work';


//. clone
git().clone( git_url, local_folder );

このコードを node コマンドで実行すると、work/ というフォルダが新規に作成され、https://github.com/dotnsf/simple-git-sample.git のクローンがそのフォルダの中に作成されます。

一方、実行時に git pull して初期化する場合は以下のようにします:
//. git-pull.js
var git = require( 'simple-git' );

var git_url = 'https://github.com/dotnsf/simple-git-sample.git';
var local_folder = 'work';


//. pull
git( local_folder ).pull();

このコードを node コマンドで実行すると、既にワーキングフォルダとして存在している ./work/ フォルダの中に最新のリポジトリ状態が git pull されます。

ここでややこしいのが「どちらを使って初期化するべきか?」です。既にワーキングフォルダ ./work/ が存在している状態で git clone を実行するとエラーになってしまうし、一方ワーキングフォルダが存在していない状態では git pull してもエラーとなってしまうからです。というわけで、以下のように実行すべきだと思いました:
//. git-init.js
var git = require( 'simple-git' );
var fs = require( 'fs' );
var path = require( 'path' );

var git_url = 'https://github.com/dotnsf/simple-git-sample.git';
var local_folder = 'work';

//. フォルダの存在確認
var dirname = path.dirname( './' + local_folder );
fs.access( dirname, fs.constants.R_OK | fs.constants.W_OK, ( err ) => {
  if( err ){
    //. clone
    git().clone( git_url, local_folder );
  }else{
    //. pull
    git( local_folder ).pull();
  }
});

ワーキングフォルダが存在しているかどうかを確認し、存在していなかった場合は git clone を、存在していた場合は(git clone 済みと解釈して)git pull を、それぞれ実行して初期化しています。


【ファイル追加】
次にこの(初期化済みの)ライブラリを使って、リポジトリにファイルを追加する方法を紹介します。厳密にいうと「(git pull して、)git add して、git commit して、git push する」までの一連の方法を紹介します。

といっても、実は結構簡単でこんな感じで一連の処理を実現できます:
//. git-push.js
var git = require( 'simple-git' );
var fs = require( 'fs' );
var path = require( 'path' );

var git_url = 'https://github.com/dotnsf/simple-git-sample.git';
var local_folder = 'work';

//. pull, add, commit, and push
git( local_folder ).pull()
  .add( 'README.md' )
  .commit( 'README.md updated.' )
  .push( [ '-u', 'origin', 'master' ] );

add() のパラメータに追加したいファイル名、commit() のパラメータにはコミットメッセージ、そして push のパラメータにはオプションを指定します(この例だと $ git push -u origin master を実行しているのと同じ処理をしています)。


実際にはブランチ切ったり、マージが必要になったりすることもあるので、全ての git 処理を全自動でというのはなかなか難しい所もあると思いますが、一連の決まった処理を(例外処理無しで)実現するにはシンプルで便利なライブラリだと思いました。


IBM Cloud から提供されている IBM CloudantApache CouchDB をベースとしたマネージドな NoSQL データベースのサービスです:
2018090300


ベース製品が同じなので、例えば REST API レベルでは互換性があります。注意が必要な点として自分が気づいた限りでは IBM Cloudant は標準で Apache Lucene ベースの検索機能が有効になっており、インデックスとなる Design Document を用意することでテキスト検索が可能になる、ということが挙げられますが、それ以外に大きな差はありません。 一方で IBM Cloud から提供されているライトアカウント(無料プラン)でも IBM Cloudant を利用することができるので、わざわざ Apache CouchDB を用意しなくても気軽に使うことができる DBaaS としてとても手軽で便利だと思っています:
2018090301


さて、自分は業務のプログラミングでは主に Node.js を使うのですが、Node.js のパッケージライブラリには IBM Cloudant 用のものと、Apache CouchDB 用のもの、両方が存在しています:
(IBM Cloudant)
2018090302

(Apache CouchDB)
2018090303


仮に対象となるデータベースが IBM Cloudant であれば前者の方が簡単に使えるという印象を持っています。ただし IBM Cloudant 用ライブラリは IBM Cloud 上の IBM Cloudant を想定していることもあり、例えばオンプレミス上の Apache CouchDB に対して使えるものではありません。

一方、Apache CouchDB 用ライブラリはローカルや社内サーバー、クラウド上にある Apache CouchDB データベース全般に対して利用することが可能です。この対象はクラウド上の IBM Cloudant であっても構いません。要するにこちらのライブラリを使えば Apache CouchDB だけでなく IBM Cloudant にも接続できる、ということです。


実際にこういった需要がどれだけあるのかわからないのですが、例えばあるシステムを作る際に、そのデータストアとして、
(1) 試しに動かす場合は IBM Cloud 上の IBM Cloudant を使って気軽に開発/テストを行い、
(2) 本番運用ではオンプレミスな Apache CouchDB を利用する(IBM Cloudant の独自機能は使わない想定)

といったことが接続先の切り替えだけでできると便利です。ただこれを実現するためには IBM Cloudant 用の便利なライブラリを使って開発しまうと (2) の本番の時に問題が起こってしまいます。以下では IBM Cloudant に対しても Apache CouchDB 用ライブラリ(以下 node-couchdb)を使ってアクセスするように実装してみたコードを紹介します。ベースが同じ製品なのでできることはできるんですが、そのための手順と注意点を含めて紹介します。


【準備】
まず Node.js のコードを記述する前に上述の node-couchdb を npm install しておきます:
$ npm install node-couchdb


【データベース接続】
node-couchdb を使って IBM Cloudant に接続します。こんな感じのコードを記述します:
var dblib = require( 'node-couchdb' );

var option = {
  auth: {
    user: 'username',
    pass: 'password'
  },
  'host': 'username.cloudant.com',
  'protocol': 'https',
  'port': 443
};
var db = dblib( option );

usernamepassword の部分にはそれぞれ IBM Cloudant の username と password を指定します(localhost の Apache CouchDB に接続する場合は option = {} で接続できます)。これで IBM Cloudant との接続ができました。ここで取得した db を使って、以下の主要な操作を行うことができます。


【主要な操作】

ドキュメント追加
insert() メソッドにデータベース名(以下の例では 'testdb')を指定して、ドキュメントを追加します。取得前に db.uniqid() でユニーク ID を取得し、_id に設定している点に注意してください:
var doc = { name: 'dotnsf', height: 170.0 };  //. 追加するドキュメント
db.uniqid().then( function( id ){
  doc._id = id[0];
  db.insert( 'testdb', doc ).then( function( body, headers, status ){
    console.log( body );
  }).catch( function( err ){
    console.log( err );
  });
});

ドキュメント読み取り
同様に get() メソッドにデータベース名と id を指定してドキュメントを取得します:
db.get( 'testdb', id ).then( function( doc, headers, status ){
  console.log( doc );
}).catch( function( err ){
  console.log( err );
});

ドキュメント削除
del() メソッドにデータベース名と id と rev を指定して、データベースからドキュメントを削除します。以下の例では一度 get() メソッドを実行して id 値から rev 値を取り出してから del() を実行しています :
db.get( 'testdb', id ).then( function( doc, headers, status ){
  db.del( 'testdb', doc.data._rev ).then( function( data, headers, status ){
    console.log( data );
  }).catch( function( err ){
    console.log( err );
  });
}).catch( function( err ){
  console.log( err );
});

ビューを指定してドキュメント一覧取得
あらかじめ作成したビューを指定して、そのビューに含まれるドキュメントの一覧を取得します。以下の例ではデザイン名 : library, ビュー名 : byname というビューを指定して文書一覧を取得しています :
db.get( 'testdb', '_design/library/_view/byname', {} ).then( function( data, headers, status ){
  if( data && data.data ){
    var docs = data.data.rows;
    console.log( docs );
  }
}).catch( function( err ){
  console.log( err );
});


IBM Cloudant の npm だと最初にデータベース名を指定してそのデータベースのオブジェクトを取得した上で各種操作を行う、、という流れなんですが、Apache CouchDB 版だと毎回データベース名と一緒に各種操作を行う、、という点が大きな違いだと思いました。ただその辺りさえ理解していればまあ大丈夫かな。。


 

「エクセルファイルを扱えるライブラリ」といえば、Java であれば Apache POI などがありますが、Node.js ではどうだろう?? と思って調べてみました。その名もズバリの xlsx という npm ライブラリを見つけたので使ってみました:

npm - xlsx
https://www.npmjs.com/package/xlsx

2018080100


ライブラリ名は xlsx ですが、対応フォーマットは xls や XML に加えて ODS まで含まれていて、かなり柔軟に使えそうです。


【扱うサンプル】
こんな感じのエクセルファイルを用意して使うことにします:
2018080101


データとしては "A1:C14" の範囲にまとまっていて、その右に2軸の折れ線グラフが1つあります。この表の B14 セルは B2:B13 の合計(SUM)、C14 セルは C2:C13 の平均値(AVERAGE)がマクロで定義されています。まあ「よくあるシート」だと思っていますが、このエクセルファイルを xlsx で扱ってみます。ちなみに同じファイルがこちらからダウンロードできます:


【読み込み例】
まず npm で xlsx ライブラリをインストールします:
$ npm install xlsx

そして xlsx ライブラリを使ってエクセルファイルを読み込む Node.js コードを作成します。この例ではファイル名を指定して読み込み、"Sheet1" シートを取得して console.log() で出力しています:
var XLSX = require( 'xlsx' );

// ファイル読み込み
var book = XLSX.readFile( './SalesSample.xls' );

// シート
var sheet1 = book.Sheets["Sheet1"];
console.log( sheet1 );


実行結果はこんな感じになります。ダンプされたような感じです:
{ '!margins':
   { left: 0.7,
     right: 0.7,
     top: 0.75,
     bottom: 0.75,
     header: 0.3,
     footer: 0.3 },
  B1: { v: '売上', t: 's', w: '売上' },
  C1: { v: '前年比', t: 's', w: '前年比' },
  A2: { v: 1, t: 'n', w: '1' },
  B2: { v: 7370, t: 'n', w: '7370' },
  C2: { v: 0.87, t: 'n', w: '0.87' },
  A3: { v: 2, t: 'n', w: '2' },
   :
   :
  A12: { v: 11, t: 'n', w: '11' },
  B12: { v: 24380, t: 'n', w: '24380' },
  C12: { v: 0.812, t: 'n', w: '0.812' },
  A13: { v: 12, t: 'n', w: '12' },
  B13: { v: 28283, t: 'n', w: '28283' },
  C13: { v: 0.814, t: 'n', w: '0.814' },
  B14: { v: 156518, t: 'n', f: 'SUM(B2:B13)', w: '156518' },
  C14:
   { v: 0.8603333333333333,
     t: 'n',
     f: 'AVERAGE(C2:C13)',
     w: '0.860333333' },
  '!protect': false,
  '!ref': 'A1:C14',
  '!objects': [ , , , , { cmo: [Object], ft: [Object] } ] }

この結果の見方を少し説明します。例えば sheet1["!ref"] の値は 'A1:C14' となっていて、このシートの中で有効なセルとして認識されている範囲は A1:C14 とされています。つまりグラフ部分は完全に無視されていて、このライブラリでは現在は扱えない情報ということになります。xlsx はあくまで表部分の読み書きを対象としています。

次に sheet1["C2"] の値は { v: 0.87, t: 'n', w: '0.87' } となっています。この意味は以下のようになります:
 v: 0.87(数値としての値は 0.87)
 t: 'n'(数値のセルとして認識されている)
 w: '0.87'(表示されているテキストは '0.87')

なお、t の値は以下のいずれかになります:
 b: Boolean
 n: 数値
 d: 日付時刻
 s: 文字列
 z: スタブ
 e: エラー

したがって sheet1["C2"] の { v: 0.87, t: 'n', w: '0.87' } の意味は「値が 0.87 の数値セルで、画面上では '0.87' と表示されている」ということになります。

また sheet1["B14"] の値は { v: 156518, t: 'n', f: 'SUM(B2:B13)', w: '156518' } となっています。この中の f: 'SUM(B2:B13)' は値が数式で定義されていて、その式が SUM(B2:B13) であることを意味しています(sheet1["C14"] も同様です)。表としてはこのレベルで各セルの値を取得できている、ということがわかります。

この読み込み例のサンプル(app1.js)はこちらからダウンロードできます。こちらのファイルは(後述の書き込み機能の結果を確認できるように)上記のコードに少し機能を追加していて、コマンドラインパラメータで読み込むエクセルファイルを指定可能にしています(無指定の場合は SalesSample.xls を読み込みます):
$ node app1.js (xxx.xls)


【書き込み例】
xlsx ライブラリを使ってファイルの書き込みを行うサンプルです。この例では SalesSample.xls を読み込んだあとに "C13" セルの値を上書きして、SalesSample2.xlsx というファイル名で保存しています:
var XLSX = require( 'xlsx' );


// ファイル読み込み
var book = XLSX.readFile( './SalesSample.xls' );

// シート
var sheet1 = book.Sheets["Sheet1"];
console.log( sheet1 );

// セル更新
sheet1["C13"] = { v: 1.01, t: 'n', w: '1.01' };

// シート更新
book.Sheets["Sheet1"] = sheet1;

// ファイル書き込み
XLSX.writeFile( book, './SalesSample2.xlsx', { type: 'xlsx' } );


(注1 最後の XLSX.writeFile() 実行時の最後のオプション { type: 'xlsx' } を指定しないとマクロ関数が無効な状態で保存されてしまいます)

この書き込みのサンプル(app2.js)はこちらからダウンロードできます。実行はそのまま node コマンドで実行します:
$ node app2.js

実行すると SalesSample2.xlsx というファイルが出来ているはずです。試しにこのファイルをエクセルで開いてみるとこのようになります:
2018080201



"C13" セルの値は { v: 1.01, t: 'n', w: '1.01' } に上書きしましたが、たしかに 0.814 から 1.01 に更新されています。同時に "C14" セルの値も(AVERAGE関数が再計算されて)変わっています。一方でグラフが完全に消えてしまいました。まあ読み込みを実行した時点でグラフの情報は消えていたので、その内容で保存するとこのようになってしまうのだと思います。xlsx ライブラリの制限事項になると思いますが、実際に使う際にはご注意ください。

(注2 厳密には SalesSample2.xlsx ファイルが生成された時点では C14 セルの値は変わっていませんが、このファイルをエクセルで開くと AVERAGE 関数が再計算されて開くので、そこで値が正しく変わったように見えます)


以上、詳細は本家のドキュメントを参照いただきたいのですが、少なくともグラフを操作せずにシートの中身を取り出す用途であれば充分につかえて、対応フォーマットも多そうだな、、という印象を持っています。自然言語処理機械学習の学習データとしてエクセル資産を活用する、なんて話になった時に活躍できそうなライブラリですね。



このページのトップヘ