【Node.js】 単純なXMLファイルを読み込む
更新日:2020/06/19
今どきXMLデータを読む機会なんてないと思っていたのですが、xmlで記述されたサイトマップデータを処理することになりました。
今回はブラウザではなくて、Node.jsを使用します。
xml2jsでオブジェクトに変換する
XMLデータをパースして操作する方法として、xmldomというパッケージでブラウザライクなDOM要素に展開。
さらにXpathというパッケージにDOM要素を渡して、XMLデータを検索するのがいいらしい。
しかしXpathというのは、もともとXMLデータを抽出するための独自の言語らしく、いまさら覚えるのも面倒。
というか、いまいち理解できない。
それに、単純なXMLデータを読むだけなのに、DOMを取り扱うような規模の大きいパッケージは冗長に感じる。
そこで、なんでもいいからXMLデータをオブジェクトに展開してくれるパッケージを探してみたら、xml2jsというものがありました。
npmで簡単にインストールできます。
$ npm install xml2js
XMLを文字列で読み込んだ後、xml2js.parseString()メソッドを実行することで、オブジェクトに変換できる。
const fs = require( "fs" );
const xml2js = require( "xml2js" );
const xmlData = fs.readFileSync( "./test.xml","utf-8" );
xml2js.parseString( xmlData , function (err, result) {
if( err ) {
console.log( err.message )
}else{
console.log( result );
}
});
結果はコールバック関数で受け取ります。
XMLのフォーマットにエラーがあると、コールバック関数の第一引数にErrorオブジェクトが引き渡されるので、ここでエラーチェックをおこなう。
エラーが無ければ、第二引数がオブジェクトに変換されたXMLデータです。
実際に次のXMLデータをオブジェクトに変換してみます。
test.xml
<?xml version="1.0" encoding="utf-8"?>
<Site xmlns="http://xxxx.com/xxx" version="1.0">
<Name>アフィ+サポ! 開発ノート</Name>
<Info>
<Url>https://note.affi-sapo-sv.com/</Url>
<Description>プログラム作成やサーバー構築などをしていて調べたことなどをきままに書いていきます。</Description>
</Info>
<Page>
<Category cid="001">
<Name id="1">JavaScript1</Name>
<Name id="2">JavaScript2</Name>
</Category>
<Category2 cid="001">
<Name>JavaScript</Name>
</Category2>
<Category>
PHP
</Category>
<Category cid="003">
<Name>Node.js</Name>
</Category>
</Page>
</Site>
取得したオブジェクトがこちら。
Site: {
$: {
xmlns: "http://xxxx.com/xxx",
version: "1.0"
},
Name: [ "アフィ+サポ! 開発ノート" ] ,
Info: [
{
Url: [ "https://note.affi-sapo-sv.com/"] ,
Description: [ "プログラム作成やサーバー構築などをしていて調べたことなどをきままに書いていきます。"]
}
],
Page: [
{
Category: [
{
$: { cid: "001" },
Name: [
{ _: "JavaScript1", $: { id: "1" } },
{ _ : "JavaScript2", $ : { id : "2" } }
]
},
"\r\n PHP\r\n ",
{
$: {
cid: "003"
},
Name: [ "Node.js" ]
}
],
Category2: [
{
$: { cid: "001" },
Name: [ "JavaScript" ]
}
]
}
]
}
同一名の兄弟要素は、配列にまとめられています。
またオブジェクトの特性上、別名の兄弟要素の順番は不定です。
参考記事:【JavaScript】 配列と連想配列の要素順序とMapオブジェクト
上のオブジェクトを見ながら、値を表示してみます。
result.Site.Name[0]; // "アフィ+サポ! 開発ノート"
result.Site.Page[0].Category[0].Name[0]._ // "JavaScript1"
値を取得できましたが、使いづらいですね。
セレクタ―を実装する
使いやすくするために、セレクターで検索できるようにヘルパーオブジェクトを実装してみます。
まずはxml2jsで変換したデータの各要素を、ヘルパーオブジェクトでラップします。
xml2jshelper.js
const xml2jsAttrbuteName = "$",
xml2jsValueName = "_";
/**
* xml2jsヘルパーオブジェクト
* @param xml2js xml2jsで変換したXMLデータ
*/
const xml2jsHelper = function ( xml2js ) {
this.child = null;
if( typeof xml2js !== "object" ) {
this[xml2jsValueName] = xml2js;
}else{
this.child={};
Object.keys(xml2js).forEach(
e => {
switch ( e ) {
case xml2jsAttrbuteName:
case xml2jsValueName:
this[e] = xml2js[e]; break;
default:
// 子要素をxml2jsHelperでラップ
this.child[e] = Array.isArray(xml2js[e]) ?
xml2js[e].map( eItem => new xml2jsHelper( eItem )) :
[ new xml2jsHelper( xml2js[e] ) ];
}
}
);
}
};
次のように、xml2jsヘルパーオブジェクトのコンストラクタにxml2jsオブジェクトを渡して、オブジェクトを生成します。
const x2j = new xml2jsHelper( xml2jsオブジェクト );
生成されるオブジェクトは次のような形式になっています。
xml2jsヘルパーオブジェクト{ _ : 値 ( 元データに存在しているもののみ) $ : { 属性値 ( 元データに存在しているもののみ) } child: { 子要素名: [ 子要素のxml2jsヘルパーオブジェクト配列 ] 子要素名: [ 子要素のxml2jsヘルパーオブジェクト配列 ] } }
次にセレクターメソッドをプロトタイプに実装します。
xml2jshelper.js
xml2jsHelper.prototype={
/**
* xmlデータの検索 上位要素から順番に指定する必要あり
* @param selctor 要素名をスペースで区切る
* 要素名[属性="属性値"]での指定も可能
* @returns 要素の配列
*/
select( selctor ){
if( this.child === null ) return null;
let current=[this];
// 1.複数スペースを一つに変換
// 2.スペースで区切り配列に格納
// 3.配列の各要素を処理
selctor.trim().replace(/ /g," ")
.split(" ").every(
e => {
// 要素名[属性="属性値"]かどうかチェック
const r = /(.*?)\[(.*?)=(["'])(.*?)\3]/.exec( e );
const [nm,atr,atrval] = r ? [ r[1] , r[2] , r[4] ]
: [ e , null , null ];
let res =[];
current.forEach(
ce => res = res.concat( ce.selectChild(nm,atr,atrval) )
);
current = res;
return current.length !== 0;
}
);
return current.length === 0 ? null : current;
},
/**
* 条件に合う子要素を取得
* @param childName 子要素名
* @param attr 属性名
* @param attrVal 属性値
* @returns 要素の配列
*/
selectChild( childName , attr = null , attrVal = null){
const child = this.child;
if( child === null || !(childName in child ) ) return [];
return attr === null ? child[childName] :
child[childName].filter(
e => e.isAttrValue( attr , attrVal)
);
},
/**
* 値を取得
* @returns 値
*/
text(){
return this[xml2jsValueName] !== undefined ? this[xml2jsValueName] : null;
},
/**
* 指定した属性を持っているかチェック
* @param attrName 属性名
* @param value 属性値
* @returns {boolean}
*/
isAttrValue( attrName , value ){
const attr = this[xml2jsAttrbuteName];
return attr !== undefined && attrName in attr && attr[attrName] === value;
}
};
次のように使用します。
test.js
const fs = require( "fs" );
const xml2js = require( "xml2js" );
const xml2jsHelper = require( "./<a href="#xml2jshelper">xml2jshelper</a>" );
const xmlData = fs.readFileSync( "./<a href="#testxml">text.xml</a>" , "utf-8" );
xml2js.parseString(xmlData,function (err, result) {
const x = new xml2jsHelper( result );
const s = x.select( "Site Page Category[cid='001'] Name[id='1']" );
console.log( s[0].text() ); // JavaScript1
});
select()メソッドの引数は、要素名を最上位から順番に記述します。
また[]で囲むことで、属性値を指定できます。
検索された要素は、配列で返ります。
記述漏れや存在しない要素名を記述すると、nullが返ります。
次のようにselect()の返り値に対して、select()を実行することも可能です。
const s2 = x.select( "Site Info" )[0].select( "Url" )[0].text();
パッケージを使用しないで独自に読み込む
軽いと持っていたxml2jsですが、実はxmlbuilderという少し重いパッケージに依存しています。
不特定なXMLファイルを読んだり書き出したりするなら有効ですが、単純なタグの組み合わせを読み込むだけならやはり冗長に感じます。
そこでXMLのオブジェクト変換も、自分でやってみます。
なお、XMLデータにエラーがないという前提なので、もし自分も同じことをやりたいという人がいたら、エラー処理等を追加してください。
いないと思いますが…
xml2obj.js
module.exports = {
/**
* XMLデータをオブジェクトに変換
* @param xmlData
* @param callBack
*/
parseString : function( xmlData , callBack ) {
if( typeof callBack !== "function" ) throw new Error( "xml2Obj:callBack is not function" );
const obj = new xml2ObjNode( xmlData , callBack );
if( !obj.error ) callBack( null , obj );
}
};
// タグを検索(開始タグのみ)
const getNexTag = xmlData => /<([^> ]*)([\d\D]*?)>([\d\D]*?)$/.exec( xmlData );
// getNexTagの結果から終了タグを検索し、タグの子要素と終了タグ以降にわける
const getTagInner = (xmlData,tag) => new RegExp(`([\\d\\D]*?)</${ tag } *?>([\\d\\D]*?)$`).exec( xmlData );
// attr="attrvalue" 形式をオブジェクトに変換
const getAttrObj = xmlData => {
if( !(typeof (xmlData) == "string") && !(xmlData instanceof String) ) return null;
const result = {};let r;
const rg = /(.+?)="([\d\D]+?)"/g;
while( (r = rg.exec( xmlData )) !== null ){
result[r[1].trim()] = r[2];
}
return Object.keys(result).length === 0 ? null : result;
};
// タグの先頭が!または?かチェック(該当するならその先頭文字列。該当しないなら"")
const isSpecialTag = tag => ["!","?"].indexOf(tag.substring(0,1)) < 0 ? "" : tag.substring(0,1);
/**
* xmlデータオブジェクト本体のコンストラクタ
* @param xmlData XMLデータ
* @param callBack コールバック
* @param attr 属性テキスト
* @param name タグ名
*/
const xml2ObjNode = function ( xmlData , callBack , attr = null , name = "") {
this.name = name;
this.child = null;
this.attr = getAttrObj( attr );
this.type = isSpecialTag(name);
// nameが!または?タグなら、終了
if( this.type.length > 0 ) { this.value = attr; return; }
// 子要素のタグを一つ得る r[1] タグ r[2]属性 r[3] 後に続くXMlデータ
let r = getNexTag( xmlData );
// 子要素がないなら、xmlDataは値。終了。
if( r === null ) { this.value = xmlData; return; }
this.child = [];
do{
const tag = r[1];
const [ childObj , nextText ] = (()=>{ // エラー時:childObj=null
if( isSpecialTag(tag).length > 0 ) // 子要素が!または?なら終了タグを検索しない
return [ new xml2ObjNode( null ,callBack , r[2] , tag ) , r[3] ];
// 終了タグの検索
const tagInner = getTagInner( r[3] , tag );
if( tagInner === null ){ // 終了タグがないエラー
callBack( new Error( "xml2Obj:" + tag + "が閉じていない" ) , null );
return [null,null];
}
// 子要素をオブジェクトに変換
const c = new xml2ObjNode( tagInner[1] ,callBack , r[2] , tag );
return ( c.error ) ? [null,null] : [ c , tagInner[2] ];
})();
if( childObj === null ) {this.error=true;return;}
this.child.push( childObj );
// 次の子要素を取得
r = getNexTag( nextText );
}while( r !== null );
};
上のコードは、正規表現を使用してXMLデータからタグを抽出しています。
XMLデータのチェックをしないと言いつつ、閉じタグの有無をチェックしているので、想定よりコードが長くなってしまいました。
xml2jsを意識してコードを組んでいるので、xml2jsと同じように使用します。
const fs = require( "fs" );
const xml2obj = require( "./<a href="#xml2obj">xml2obj</a>" );
const xmlData = fs.readFileSync("./<a href="#testxml">text.xml</a>","utf-8");
xml2obj.parseString(xmlData,function (err, result) {
if( err ) { console.log( err.message );return;}
console.log( result );
});
resultは、次のようなxml2ObjNodeオブジェクトです。
xml2ObjNodeオブジェクト{ name : タグ名 type: タグタイプ ( "!" or "?" or "" ) value : 値 attr : オブジェクト { 属性名:属性値 , 属性名:属性値 } child: 配列 [ { 子要素のxml2ObjNodeオブジェクト } { 子要素のxml2ObjNodeオブジェクト } ] }
xml2ObjNodeオブジェクトは、子要素を順番に配列格納しているので、出現順を意識したデータ取得が可能です。
また、!や?で始まるタグも保存しています。
XMLデータを独自オブジェクトに変換できたので、次はセレクターで検索できるようにします。
xml2jsHelper のプロトタイプを、xml2ObjNodeオブジェクトの内容に合わせているだけで、ほぼ同じです。
xml2obj.js続き
xml2ObjNode.prototype = {
/**
* xmlデータの検索 上位要素から順番に指定する必要あり
* @param selctor 要素名をスペースで区切る
* 要素名[属性="属性値"]での指定も可能
* @returns 要素の配列
*/
select( selctor ){
if( this.child === null ) return null;
let current=[this];
// 1.複数スペースを一つに変換
// 2.スペースで区切り配列に格納
// 3.配列の各要素を処理
selctor.trim().replace(/ /g," ")
.split(" ").every(
e => {
// 要素名[属性="属性値"]かどうかチェック
const r = /(.*?)\[(.*?)=(["'])(.*?)\3]/.exec(e);
const [nm,atr,atrval] = r ? [ r[1],r[2] ,r[4]] : [ e , null , null ];
let res =[];
current.forEach(
ce => res = res.concat( ce.selectChild(nm,atr,atrval) )
);
current = res;
return current.length !== 0;
}
);
return current.length === 0 ? null : current;
},
/**
* 条件に合う子要素を取得
* @param childName 子要素名
* @param attr 属性名
* @param attrVal 属性値
* @returns 要素の配列
*/
selectChild( childName , attr = null , attrVal = null){
return this.child === null ? [] : this.child.filter(
e => e.name === childName && e.isAttrValue( attr , attrVal)
);
},
/**
* 値を取得
* @returns 値
*/
text(){
return this.value !== undefined ? this.value : null;
},
/**
* 指定した属性を持っているかチェック
* @param attrName 属性名
* @param value 属性値
* @returns {boolean}
*/
isAttrValue( attrName , value ){
if( attrName === null ) return true;
const attr = this.attr;
return attr !== null && attrName in attr && attr[attrName] === value;
}
};
select()メソッドの引数は、要素名を最上位から順番に記述します。
また[]で囲むことで、属性値を指定できます。
検索された要素は、配列で返ります。
記述漏れや存在しない要素名を記述すると、nullが返ります。
const fs = require( "fs" );
const xml2obj = require( "./<a href="#xml2obj">xml2obj</a>" );
const xmlData = fs.readFileSync("./<a href="#testxml">text.xml</a>","utf-8");
xml2obj.parseString(xmlData,function (err, result) {
if( err ) { console.log( err.message );return;}
const s = result.select( 'Site Page Category[cid="001"] Name[id="1"]' );
console.log( s[0].text() );// 結果:JavaScript1
const s2 = result.select( "Site Info" )[0].select( "Url" )[0].text();
console.log( s2 ); // 結果:https://note.affi-sapo-sv.com/
const s3 = result.select( "?xml" )[0].text();
console.log( s3 ); // 結果: version="1.0" encoding="utf-8"?
});
更新日:2020/06/19
関連記事
スポンサーリンク
記事の内容について
こんにちはけーちゃんです。
説明するのって難しいですね。
「なんか言ってることおかしくない?」
たぶん、こんなご意見あると思います。
裏付けを取りながら記事を作成していますが、僕の勘違いだったり、そもそも情報源の内容が間違えていたりで、正確でないことが多いと思います。
そんなときは、ご意見もらえたら嬉しいです。
掲載コードについては事前に動作確認をしていますが、貼り付け後に体裁を整えるなどをした結果動作しないものになっていることがあります。
生暖かい視線でスルーするか、ご指摘ください。
ご意見、ご指摘はこちら。
https://note.affi-sapo-sv.com/info.php
このサイトは、リンクフリーです。大歓迎です。