6.5 ファイル・アクセス、同期・非同期、JSON

(1/1)
ハードディスクのイラスト(コンピューター)
今回は、クライアントPCのストレージ(ローカル・ドライブ)のファイルを読み込んだり、ファイルを書き込むプログラムを作る。
5.1 配列とオブジェクト」で作った社員名簿データを JSON形式ファイルとして保存しておき、これを読み込んだり、一覧表のHTML部分をファイルに書き込むことを目指す。ファイル読み込みは非同期で処理が行われることに留意すること。
非同期処理にからんで、待ち時間処理を考える。
また、ファイル・サイズを表示したり、画像ファイルの高さ、幅を表示する方法を紹介する。

(2024年2月10日)記事「ファイル・サイズを表示する」「画像ファイル・サイズを表示する」「練習問題」を追加

サンプル・プログラムの実行例

社員名簿(ファイル読み書き)

目次

サンプル・プログラムを実行する

サンプル・プログラム

圧縮ファイルの内容
employeeList4.html社員名簿(ファイル読み書き)
employeeList.json社員名簿データ(JSON)
wai1.html待ち時間のサンプル・プログラム(1)
wai2.html待ち時間のサンプル・プログラム(2)
wai3.html待ち時間のサンプル・プログラム(3)
wai4.html待ち時間のサンプル・プログラム(4)
dispFileSize.htmlファイル・サイズを表示するサンプル・プログラム
dispImageSize.html画像ファイル・サイズを表示するサンプル・プログラム
employeeList4.html 更新履歴
バージョン 更新日 内容
2.1.0 2023/08/15 ループ処理などを見直した
2.0.0 2023/08/15 ファイル名変更
1.0 2021/08/29 初版

ファイル・アクセスとセキュリティ

JavaScriptはインターネット上で利用されることから、セキュリティ対策の一環として、他のプログラミング言語に比べてクライアントPCのストレージ(ローカル・ドライブ)にあるファイル・アクセス制限が厳しい。
HTML5になって、ユーザーが手動で指定したファイルに限り、読み込んだり書き込むことができるようになった。
今回は、この機能を利用してローカル・ドライブにあるファイルを読んだり、書き込んだりするプログラムを作っていくことにする。

JSON

JavaScriptの変数や配列、オブジェクトをテキストに表す方式として、JSON(JavaScript Object Notation)という形式がある。今回、社員名簿データを格納したテキスト・ファイル "employeeList.json" は JSON形式とした。なお、JSON形式ファイルにはコメント書くことはできない。

   1: [{
   2:     "number":       "社員番号",
   3:     "last_name":    "姓",
   4:     "first_name":   "名",
   5:     "sex":          "性別",
   6:     "hire":         "入社日",
   7:     "department":   "所属部署",
   8:     "duties":       "職掌",
   9:     "sales":        "今期売上<br>(千円)"
  10: }, {
  11:     "number":       "094011",
  12:     "last_name":    "相馬",
  13:     "first_name":   "豊司",
  14:     "sex":          "男",
  15:     "hire":         "1994/4/1",
  16:     "department":   "営業部",
  17:     "duties":       "営業",
  18:     "sales":        52600
  19: },{
  20:     "number":       "099004",
  21:     "last_name":    "原口",
  22:     "first_name":   "奈緒子",
  23:     "sex":          "女",
  24:     "hire":         "1999/7/5",
  25:     "department":   "人事部",
  26:     "duties":       "事務"
  27: },{
  28:     "number":       "012023",
  29:     "last_name":    "足立",
  30:     "first_name":   "俊康",
  31:     "sex":          "男",
  32:     "hire":         "2012/3/12",
  33:     "department":   "システム営業部",
  34:     "duties":       "技術"
  35: },{
  36:     "number":       "004002",
  37:     "last_name":    "畠山",
  38:     "first_name":   "忠秋",
  39:     "sex":          "男",
  40:     "hire":         "2004/4/1",
  41:     "department":   "経理部",
  42:     "duties":       "事務"
  43: },{
  44:     "number":       "013010",
  45:     "last_name":    "島崎",
  46:     "first_name":   "晴生",
  47:     "sex":          "男",
  48:     "hire":         "2013/4/1",
  49:     "department":   "営業部",
  50:     "duties":       "営業",
  51:     "sales":        23800
  52: },{
  53:     "number":       "096008",
  54:     "last_name":    "丹羽",
  55:     "first_name":   "麻樹",
  56:     "sex":          "女",
  57:     "hire":         "1996/4/1",
  58:     "department":   "営業部",
  59:     "duties":       "営業",
  60:     "sales":        31600
  61: },{
  62:     "number":       "096011",
  63:     "last_name":    "谷本",
  64:     "first_name":   "房実",
  65:     "sex":          "女",
  66:     "hire":         "1996/4/1",
  67:     "department":   "経理部",
  68:     "duties":       "事務"
  69: },{
  70:     "number":       "020017",
  71:     "last_name":    "深沢",
  72:     "first_name":   "つばさ",
  73:     "sex":          "男",
  74:     "hire":         "2020/4/1",
  75:     "department":   "システム営業部",
  76:     "duties":       "営業",
  77:     "sales":        42800
  78: },{
  79:     "number":       "007005",
  80:     "last_name":    "川島",
  81:     "first_name":   "武久",
  82:     "sex":          "男",
  83:     "hire":         "2007/4/1",
  84:     "department":   "営業部",
  85:     "duties":       "営業",
  86:     "sales":        32700
  87: },{
  88:     "number":       "091001",
  89:     "last_name":    "成田",
  90:     "first_name":   "祥三",
  91:     "sex":          "男",
  92:     "hire":         "1991/4/1",
  93:     "department":   ["開発部", "システム営業部"],
  94:     "duties":       "技術"
  95: }]

HTML部分

 252: <body>
 253: <h2 id="title"></h2>
 254: 
 255: 名簿ファイル&nbsp;
 256: <input type="file" name="infile" id="infile" onChange="loadAndDispTable(this)" />
 257: <input type="hidden" name="success" id="success" value="" />
 258: 
 259: <table id="tableEmployeeList"></table>
 260: <a id="save" href="#" onClick="saveTable()"></a><br />
 261: <p id="error"></p>

HTML部分では、ファイル読み込みを行うのに使う <input type="file"> を追加した。ファイル読み込みボタンがクリックされたとき、後述するユーザー関数 loadAndDispTable を呼び出す。

ファイルへの書き込みは、<a id="save"> タグを用いる。リンクがクリックされたとき、後述するユーザー関数 saveTable を呼び出す。

ファイル読み込み

 160: /**
 161:  * 社員名簿データを読み込む.
 162:  * @param   Object filelist 社員名簿データ・ファイル(filelistオブジェクト)
 163:  * @return  Object 社員名簿データ
 164: */
 165: function loadAndDispTable(filelist) {
 166:     //表示クリア
 167:     reset();
 168: 
 169:     //ファイルが複数ある場合を想定
 170:     Array.from(filelist.files).forEach(function (file) {
 171:         //FileReaderオブジェクト生成
 172:         let reader = new FileReader();
 173: 
 174:         //読み込みエラー処理
 175:         reader.addEventListener('error', () => {
 176:             document.getElementById('error').innerHTML = 'エラー:社員名簿ファイルの読み込みに失敗しました.';
 177:             return;
 178:         });
 179: 
 180:         //JSONファイル以外はエラー
 181:         if (! file.type.match(/json/i)) {
 182:             document.getElementById('error').innerHTML = 'エラー:正しい社員名簿を用意してください.';
 183:             return;
 184:         }
 185: 
 186:         //ファイル読み込み
 187:         reader.readAsText(file);
 188: 
 189:         //読み込み完了後の処理
 190:         reader.onload = function () {
 191:             let persons = JSON.parse(reader.result);    //JSON→配列変換
 192:             document.getElementById('tableEmployeeList').innerHTML = getTable3(persons);        //一覧表示
 193:             document.getElementById('save').innerHTML = '社員一覧表保存';
 194:         }
 195:     });
 196: }

ユーザー関数 loadAndDispTable には、<input type="file"> で入力された FileListオブジェクトが渡る。ここでは multiple 属性を付与していないので、受け取るファイルは1つだけだが、念のため、ファイルが複数ある場合を想定して forEachメソッド を使って1つ1つのファイルを処理するようにしている。
ここで、FileListオブジェクトは配列でないことに留意したい。プロパティ files から fromメソッドを使って新たに配列を生成し、この生成された配列に対して forEachメソッド を適用してやる。

ファイル読み込みで肝になるのは FileReader オブジェクトである。FileReaderオブジェクトを使うと、ローカル・ドライブのファイルを非同期に読み取ることができる。
テキストファイルを読み込むには、readAsText メソッドを使うだけでいい。引数にはファイル名――fileオブジェクトで渡された filelist.files[i] を渡せばいい。
読み込んだテキストは、result プロパティを使って取り出す。

ファイル読み込み時に発生するエラーは、error プロパティにイベントハンドラを設定することで処理する。ここでは無名関数を用意し、エラー・メッセージを表示し、loadAndDispTable 関数を強制終了するようにした。

読み込んだファイルが JSON形式かどうかは、typeプロパティに "application/json" がセットされているかどうかを見れば分かる。ここでは正規表現のパターンマッチングで判断し、JSON でなければエラー・メッセージを表示し、このあとの処理をスキップする。

非同期処理

同期処理
FileReaderオブジェクトはローカル・ドライブのファイルを非同期に読み取る」と書いたが、この非同期の部分が重要である。

C言語などでテキスト・ファイルを1行ずつ読み込んで処理するフローは左図の通り。処理する都度、エラー・チェックを行い、ファイル終端かどうかを調べて繰り返し処理する。このように処理フローを1本の流れで表せる場合を同期処理と呼ぶ。
これに対し、FileReaderオブジェクトは下図のように、処理が2つに分かれる。つまり、ファイルを読み込んでいる間、メインの流れは勝手に先へ進む。これを非同期処理と呼ぶ。
非同期処理
なぜファイル読み込みを非同期処理にしているかというと、C言語が開発された当時と異なり、現在、システム処理で一番時間がかかるのは入出力処理だからだ。ファイル入出力も例外ではなく、同期処理にすると、入出力処理が終わるまでシステムの大部分が待ちぼうけを食わされることになる。
そこで、ファイル入出力が行われている間、メイン処理はできる限り先へ進めてしまおうという考え方である。

ファイル処理が終わったら、非同期処理をメイン処理に合流させなければならない。
そこでJavaScriptの場合、FileReaderオブジェクトonload プロパティが用意されており、ここにファイル読み込み終了後のイベントを関数として登録することで、メイン処理と足並み合わせをすることができるようになっている
ここでは、読み込んだ内容を result プロパティから取り出し、JSON.parse メソッドを使って配列 directory に代入。それを、以前作った make_table2 関数で一覧表示させるとともに、社員一覧表保存ボタンを表示するようにした。

ファイル書き込み

 141: /**
 142:  * 社員名簿をテキストファイルで保存する.
 143:  * @param   なし
 144:  * @return  なし
 145: */
 146: function saveTable() {
 147:     //一覧表部分を取得
 148:     let html = document.getElementById('tableEmployeeList').innerHTML;
 149: 
 150:     //Blobオブジェクト生成
 151:     let blob = new Blob([html], {type:"text/plan"});
 152:     let link = document.getElementById('save');
 153:     link.href = URL.createObjectURL(blob);
 154:     link.download = 'employeeList.txt';     //保存ファイル名
 155: 
 156:     //ファイル保存
 157:     link.click();
 158: }

ユーザー関数 saveTable は、<a id="save"> タグがクリックされたときに呼び出される。つまり、ファイル書き込みに関してもセキュリティの関係で、ユーザーが手動でアクションを起こさなければ保存できない。

ファイル書き込みの肝は、Blob オブジェクトである。コンストラクタの引数は、書き込みたいデータを配列にしたものと、データ型の2つである。

Blobオブジェクトが生成できたら、それを、URL.createObjectURL メソッドに渡す。すると、BlobオブジェクトDOMString に変換される。これをaタグのhref属性に代入することで、aタグをクリックしたときに渡したデータをダウンロードできるようになる。ファイル名はdownload属性に代入しておく。

待ち時間処理

  22: <script>
  23: document.getElementById('ss').innerHTML +'1番目<br>';
  24: document.getElementById('ss').innerHTML +'2番目<br>';
  25: document.getElementById('ss').innerHTML +'3番目<br>';
  26: </script>

順番に表示する(正しい例)
このプログラムは、「1番目」「2番目」「3番目」を順に並べるだけのモノである。プログラムの流れは左図の通りで、正常に表示するだろう。
次に、「1番目」と「2番目」の間に1秒だけ待ち時間を空けるプログラムを作ってみる。
ところが、JavaScript には、他の言語によくある wait のような待ち時間命令がない。
似たような働きをする setTimeoutメソッドがあるので、これを使ってみることにする。

  22: <script>
  23: document.getElementById('ss').innerHTML +'1番目<br>';
  24: //1秒待つ
  25: setTimeout(function() {
  26:     document.getElementById('ss').innerHTML +'2番目<br>';
  27: }, 1000);
  28: document.getElementById('ss').innerHTML +'3番目<br>';
  29: </script>

1秒待つ(間違い例)
ところが、「1番目」の次に「3番目」を表示してしまい、1秒の間隔を空けて、最後に「2番目」を表示してしまう。これは、左図のように、setTimeoutメソッドが非同期で実行されるために起きた間違いである。
このように JavaScriptの関数やメソッドは、だいたいが非同期処理であるため、単純に1秒待つという命令が存在しない。
では、どうしたらいいか‥‥。

  22: <script>
  23: document.getElementById('ss').innerHTML +'1番目<br>';
  24: 
  25: //1秒待つ
  26: new Promise(function(resolve) {
  27:     setTimeout(function() {
  28:         document.getElementById('ss').innerHTML +'2番目<br>';
  29:         resolve();
  30:     }, 1000);
  31: }).then(function() {
  32:     document.getElementById('ss').innerHTML +'3番目<br>';
  33: });
  34: </script>

1秒待つ(正しい例)
ここでは Promiseオブジェクトを使う。
「約束」という名が付けられた Promiseオブジェクトは、非同期処理の完了結果をもっている。
つまり、Promiseの中で setTimeoutメソッドを実行すれば、setTimeoutメソッドが完了(1秒待つ)したことを resolveメソッドを使って知らせることができる。resolveが実行されると、thenメソッドを実行する。
こうして左図のように、1秒待った上で正しい順番で表示することができるようになった。

  23: /***
  24:  * 指定した秒数だけwaitする.
  25:  * @param   Number sec waitしたい秒数
  26:  * @return  なし
  27: */
  28: const wait = async (ms) => {
  29:     return new Promise(function(resolve) {
  30:         setTimeout(function() {
  31:             resolve();
  32:         }, ms)
  33:     });
  34: }

最後に、Promiseオブジェクトを使って指定した秒数だけwaitする関数を掲げる。

ファイル・サイズを表示する

ファイル・サイズを表示する
読み込もうとしているファイルのサイズを調べるには、Fileオブジェクトの sizeプロパティを使って簡単に取得できる

  46: /**
  47:  * ファイル・サイズを表示する
  48:  * @param   Object filelist ファイルリスト(filelistオブジェクト)
  49:  * @return  なし
  50: */
  51: function dispFileSize(filelist) {
  52:     //表示クリア
  53:     reset();
  54: 
  55:     //ファイルが複数ある場合を想定
  56:     Array.from(filelist.files).forEach(function (file) {
  57:         let fileSize = file.size;
  58:         document.getElementById('fileSize').innerHTML = fileSize.toLocaleString();      //数字のカンマ区切り
  59:         return;     //先頭ファイルを表示したらリターン
  60:     });
  61: }

画像ファイル・サイズを表示する

画像ファイル・サイズを表示する
読み込もうとしている画像ファイルの幅、高さピクセル数を調べるには、Imageオブジェクトweidthプロパティheightプロパティを参照する。

  47: /**
  48:  * 画像の縦横サイズを表示する
  49:  * @param   Object filelist ファイルリスト(filelistオブジェクト)
  50:  * @return  なし
  51: */
  52: function dispImageSize(filelist) {
  53:     //表示クリア
  54:     reset();
  55: 
  56:     //ファイルが複数ある場合を想定
  57:     Array.from(filelist.files).forEach(function (file) {
  58:         //FileReaderオブジェクト生成
  59:         let reader = new FileReader();
  60: 
  61:         //読み込みエラー処理
  62:         reader.addEventListener('error', () => {
  63:             document.getElementById('error').innerHTML = 'エラー:ファイルの読み込みに失敗しました.';
  64:             return;
  65:         });
  66: 
  67:         //imageファイル以外はエラー
  68:         if (! file.type.match(/image/i)) {
  69:             document.getElementById('error').innerHTML = 'エラー:画像ファイルを指定してください.';
  70:             return;
  71:         }
  72: 
  73:         //画像ファイルを読み込む
  74:         reader.readAsDataURL(file);
  75: 
  76:         //読み込み完了後の処理
  77:         reader.onload = function () {
  78:             let image = new Image();
  79:             image.src = reader.result;
  80:             //画像をデコードする
  81:             image
  82:             .decode()
  83:             .then(() => {
  84:                 document.getElementById('width').innerHTML = image.width.toLocaleString();      //数字のカンマ区切り
  85:                 document.getElementById('height').innerHTML = image.height.toLocaleString();        //数字のカンマ区切り
  86:             });
  87:         }
  88:     });
  89: }

file.type.matchメソッドを使ったエラー処理までは、前述の loadAndDispTable関数と同じだ。
FileReader に画像ファイルを読み込むには、readAsDataURLメソッドを使う。読み込みは非同期に行われるので、onload を使って、読み込んだ画像ファイルを Imageオブジェクトに流し込む。
流し込み(decodeメソッド)も非同期で行われるので、thenメソッドを使って、次の処理を書く。ここで、weidthプロパティheightプロパティを参照する。

練習問題

コラム:同期と非同期

今回は同期処理非同期処理という耳慣れない言葉が出てきたが、実際の会社の仕事を思い浮かべてほしい。お客様にスムーズに品質の高いサービス・製品を売るために、ほとんどの現場は分業制になっている。このタスクの分業作業を非同期処理、最後にタスクの足並み合わせすることを同期処理と呼んでいる。
つまり、非同期処理の目的は、複数のタスクを分業してスムーズに品質の高いアウトプットを得ることにある。逆に考えれば、タスクが1つであったり、スピードが求められない仕事を非同期処理にする必要はない。
分業
たとえばコンビニ弁当の製造工場を思い浮かべてほしい。
工場内では在庫の食材を加工して弁当を製造する。醤油やソースといった調味料を製造するには別の工場が必要になるから、これは専門業者から調達しているとする。この場合、専門業者に外注するタスクは自社工場とは独立して動いているから非同期処理である。餅は餅屋――専門の調味料製造会社から調達した方が、弁当の味(品質)を保つことができる。
最後に、調味料を弁当に加えて1つの弁当として検査して梱包、出荷するところが同期処理である。
このとき、自社工場の製造が早すぎても、調味料の搬入が遅すぎても、出荷までの待ち時間が発生してしまう。最初に行う製造計画では、同期処理で足並み合わせができるよう綿密な計画を立てる。この部分がプログラム設計にかかわる。
プログラム開発で言えば、たとえばクラウドサービスを利用する部分は外注のようなもので、たいていは非同期処理になる。ファイル入出力や、カメラやマイクといった周辺機器とのデータのやり取りも、自社内の別部門にタスクを割り当てるようなもので、非同期処理になるケースが多い。
非同期処理は、C言語ができる以前、シングルタスク・シングルスレッドだった時代からあった。当時も入出力を含む周辺機器の処理が遅かったから、それらは非同期処理としてフローを分離し、処理が終わるとメイン・プログラムへ割り込み信号を伝えて足並みを合わせていた。
デバイス・プログラムの開発経験がある方は、割り込み処理を書いたことがあるだろう。

IoT時代においても、同期・非同期処理は付きまとう。
たとえばドローンのセンサや駆動系デバイスは非同期処理で動いており、メイン処理が適切に足並み合わせを行いながら制御しないと、たちまちコントロールを失ってしまう。
マルチスレッド処理も非同期処理の延長にある。スーパーコンピュータのプログラミングも非同期処理の延長だ。
大量のデータ処理を行う中で、後に投入したスレッドの処理が軽く、“たまたま”先に投入したスレッドより速く終わってしまった場合、きちんと同期処理が行われていないと、後続のスレッド処理でエラーが発生したり、後勝ちでデータを書き換えてしまう恐れがある。
繰り返し処理の中でオブジェクト配列を変更したり、データベース処理する場合には注意が必要だ。とくにデータベース処理には時間がかかるため、開発環境では正しい順序に処理されたとしても、実運用環境では順序が前後したりする。トランザクションが多い金融や行政のネットサービスでは注意が必要だ。
二人三脚のイラスト(スーツ)
システムを実環境で動かすと、温度や電磁的なノイズといった環境によって周辺機器の動作が遅れたり、CPU処理そのものが遅くなることがある。テスト環境では正常に動いているのに実環境で稼動させているうちに原因不明の不具合を起こすようなときは、同期・非同期の足並み合わせが取れているかどうか疑い、必要に応じて今回紹介した Promiseオブジェクトを使って足並み合わせを実施しよう。
(この項おわり)
header