001/* 002 * Copyright (c) 2016 The EUROMAP63.jp Project. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 013 * either express or implied. See the License for the specific language 014 * governing permissions and limitations under the License. 015 */ 016package org.opengion.fukurou.fileexec; 017 018import java.util.function.Consumer; 019import java.util.Locale; 020import java.util.List; 021import java.util.ArrayList; 022import java.nio.file.Path; 023import java.nio.charset.Charset; 024import static java.nio.charset.StandardCharsets.UTF_8; 025 026/** 027 * LineSplitter は、1行分のデータを順次分割するクラスです。 028 * 029 *<pre> 030 * ファイルは、『改行』で行分割して、カンマかタブでカラム分割します。 031 * 032 * 応答ファイルの解析処理を簡素化するため、以下のルール(禁止事項)を定めます。 033 * 1.ダブルクオートの中に、ダブルクオート、改行、を含まないこと。 034 * (カンマとスペースは含めることが出来ます。) 035 * 2.1行の定義は、『改行』とします。 036 * 3.スペース分割時は、複数スペースの場合でも、1つの区切り文字として扱います。 037 * (A B C D → 「A」、「B」、「C」、「D」 に分割されます。) 038 * 4.カンマ分割は、ダブルクオート間のカンマは分解しません。 039 * 混在した場合でも、最初に見つけた方が優先されます。 040 * 5.カンマ分割時は、複数カンマの場合は、それぞれ空文字列に分割されます。 041 * カンマ分割後、それぞれの文字列は、前後スペースを削除(trim)します。 042 * (A, B , , C , D → 「A」、「B」、「」、「C」、「D」 に分割されます。) 043 * 6.カラム分解後の、ダブルクオートは、削除します。 044 * ((A, B , , "CC C,C" , D → 「A」、「B」、「」、「CC C,C」、「D」 に分割されます。) 045 * 046 * 処理手順 047 * 1.ファイルより、1行づつ(改行コードで分割)読み込みます。 048 * 2.読み込んだ1行について、先頭が、『#』の行はコメント行としてスキップします。 049 * 3.先頭から、区切り文字(スペースかカンマかタブ)が見つかるまでを、1カラムとして取得します。 050 * 4.その間、ダブルクオートが見つかったら、次のダブルクオートまで、取り込みます。 051 * 5.カラム分割された単語の前後スペースと、前後ダブルクオートを削除します。 052 * trim()が先で、ダブルクオートの削除は、後から行います。(ダブルクオート内のtrim()は行いません。) 053 * 6.個々のカラムを配列にして返します。 054 * 7.これを、ファイルが終了するまで繰り返します。 055 * 056 * 並行性 057 * このクラスは、staticメソッドのみのユーティリティークラスのため、スレッドに対して、安全です。 058 * また、ファイルの読み取りに関して、FileChannelのtryLockを行っています。 059 *</pre> 060 * 061 * @og.rev 1.0.0 (2016/04/28) 新規作成 062 * 063 * @version 1.0 064 * @author Kazuhiko Hasegawa 065 * @since JDK1.8, 066 */ 067public final class LineSplitter { 068 private static final XLogger LOGGER= XLogger.getLogger( LineSplitter.class.getSimpleName() ); // ログ出力 069 070 private final Charset chset ; // ファイルのエンコード 071 private String[] clms = new String[0]; // オリジナルのカラム列(ゼロ文字列も含む) 072 073 private static final String NAME_KEY = "#NAME"; 074 private static final int NAME_LEN = NAME_KEY.length(); 075 076 /** 077 * デフォルトコンストラクター 078 * 079 * ファイル読み取りのCharsetは、UTF-8になります。 080 * @see java.nio.charset.StandardCharsets#UTF_8 081 */ 082 public LineSplitter() { 083 this( UTF_8 , null ); 084 } 085 086 /** 087 * Charsetに対応した文字列を指定して、オブジェクトを作成します。 088 * 089 * @param chStr ファイルを読み取るときのCharset文字列 090 * @param inClms 外部指定カラム文字列(CSV形式) 091 */ 092 public LineSplitter( final String chStr , final String inClms ) { 093 this( Charset.forName( chStr ) , inClms ); 094 } 095 096 /** 097 * Charsetを指定して、オブジェクトを作成します。 098 * 099 * @param chObj ファイルを読み取るときのCharsetオブジェクト 100 * @param inClms 外部指定カラム文字列(CSV形式) 101 */ 102 public LineSplitter( final Charset chObj , final String inClms ) { 103 chset = chObj; 104 if( inClms != null && !inClms.isEmpty() ) { 105 clms = inClms.split( "," ); // CSV形式のカラム列を一旦分解します。 106 } 107 LOGGER.debug( () -> "[LineSplitter] Charset=" + chObj + " , inClms=" + inClms ); 108 } 109 110 /** 111 * #NAME が存在すれば、そこから名前配列を返します。 112 * 113 * ここでは、オリジナルのカラム列(ゼロ文字列も含む)ではなく、 114 * 存在するカラム名だけのカラム列を返します。 115 * 116 * 外部指定カラムがあれば、そちらを優先します。 117 * 無ければ、長さゼロの配列 が返されます。 118 * 119 * @return あれば名前配列、無ければ、長さゼロの配列 120 */ 121 public String[] getColumns() { 122 final List<String> clmList = new ArrayList<>(); // 1行分の分割したトークンのリスト 123 for( final String clm : clms ) { 124 if( !clm.isEmpty() ) { clmList.add( clm ); } // ゼロ文字列以外のカラムのみ登録します。 125 } 126// return clmList.toArray( new String[clmList.size()] ); 127 return clmList.toArray( new String[0] ); // 8.5.4.2 (2024/01/12) PMD 7.0.0 OptimizableToArrayCall 対応 128 } 129 130 /** 131 * 1行づつ処理を行った結果のトークンをConsumerにセットする繰り返しメソッドです。 132 * 1行単位に、Consumer#action が呼ばれます。 133 * セットされるリストは、1行をトークンに分割したリストで、空行の場合は、SKIPします。 134 * また、オリジナルのカラム列がゼロ文字列の場合は、その列データを返しません。 135 * つまり、存在するカラム名だけの値列を返します。 136 * 137 * ファイルを順次読み込むため、内部メモリを圧迫しません。 138 * 139 * @param inPath 処理対象のPathオブジェクト 140 * @param action 行を区切り文字で分割した文字列のリストを引数に取るConsumerオブジェクト 141 * @throws RuntimeException ファイル読み込み時にエラーが発生した場合 142 * @see FileUtil#lockForEach(Path,Consumer) 143 */ 144 public void forEach( final Path inPath , final Consumer<List<String>> action ) { 145 FileUtil.lockForEach( 146 inPath , 147 chset , 148 line -> { 149 final List<String> list = split( line ); // 行末カット、コメントカット、trim されます。 150 if( !list.isEmpty() ) { action.accept( list ); } // 空のリストオブジェクトの場合、SKIPします。 151 } 152 ); 153 } 154 155 /** 156 * 1行分の分割したトークンのリストを返します。 157 * 158 * ファイルの読み込みを、単独または、別に行った場合に、1行データとして、処理できます。 159 * このクラスの特徴である、先頭が、『#』の行は、コメントとみなして、削除します。 160 * 1行分をtrim()する処理も、行います。 161 * trim()の結果が、空文字列のみの場合は、空のリストオブジェクトを返します。 162 * 163 * @og.rev 7.2.1.0 (2020/03/13) カラム列がデータより少ない場合の対応 164 * 165 * @param orgLine 1行データ(オリジナル) 166 * @return 1行分の分割したトークンのリスト(行末、コメント、trim処理済み) 167 */ 168 public List<String> split( final String orgLine ) { 169 final List<String> list = new ArrayList<>(); // 1行分の分割したトークンのリスト 170 final String line = cmntCut( orgLine ); // 行末カット、コメントカット、trim されます。 171 if( line.isEmpty() ) { return list; } // Zero文字列の場合、空のリストオブジェクトを返します。 172 173 final int maxPosition = line.length(); 174 int currentPosition = 0; 175 int listNo = 0; 176 177 // "<" では末尾の項目が空(カンマで1行が終わる)場合、正しく処理できない。 178 while( currentPosition <= maxPosition ) { 179 // boolean fstSpace = true; // 先頭にスペースがある場合は、削除します 180 boolean inquote = false; // ダブルクオート内部のカンマは、スキップする。 181 // boolean inkakko = false; // 『[』と『]』の間のカンマは、スキップする。 182 183 int position = currentPosition; 184 final int from = position; 185 186 char ch = 0; 187 while( position < maxPosition ) { 188 ch = line.charAt( position ); 189 // if( fstSpace ) { 190 // if( ch <= ' ' ) { position++ ; continue; } // UTF-8 で、' ' より小さい文字は、空白文字(trim対象)です。 191 // fstSpace = false; 192 // from = position ; // 最初に見つけた、空白文字以外の文字の位置 193 // } 194 195 // if( !inquote && !inkakko && ( ch == ',' || ch <= ' ') ) { break; } 196 if( !inquote && ( ch == ',' || ch <= ' ') ) { break; } 197 else if( '"' == ch ) { inquote = !inquote; } // 『"』クオート処理を行う 198 // else if( '[' == ch && !inkakko ) { inkakko = true; } // 『[』の開始処理 199 // else if( ']' == ch && inkakko ) { inkakko = false; } // 『]』の終了処理 200 position++; 201 } 202 203 // 分割トークン 204 String token = line.substring( from,position ); 205 206 // トークンの前後が '"' なら、削除します。 207 final int len = token.length(); 208 209 if( len >=2 && token.charAt(0) == '"' && token.charAt(len-1) == '"' ) { 210 token = token.substring( 1,len-1 ); 211 } 212 213 // 超特殊処理。tokenにnullは含まれないが、文字列が、"null"の場合は、ゼロ文字列と置き換えます。 214 if( "null".equalsIgnoreCase( token ) ) { token = ""; } 215 216 // #NAME 列を削除します。 ゼロカラム列を削除します。 217// if( !clms[listNo].isEmpty() ) { 218 if( listNo < clms.length && !clms[listNo].isEmpty() ) { // 7.2.1.0 (2020/03/13) 219 list.add( token ); 220 } 221 listNo++ ; 222 223 // ch の終わり方で、スペースの場合、CSV形式かも知れないので、もう少し様子を見る。 224 while( ch <= ' ' && ++position < maxPosition ) { 225 ch = line.charAt( position ); 226 } 227 228 // 最後がカンマでなければ、進めすぎている。 229 currentPosition = ch == ',' || position == maxPosition ? position+1 : position ; 230 } 231 232 return list; 233 } 234 235 /** 236 * 先頭文字が、'#' の行を削除した文字列を返します。 237 * 238 * このメソッド上で、#NAME があれば、カラム配列を作成します。 239 * カラム配列は、最初の一度のみ、セット可能とします。 240 * 241 * @param line 1行分の文字列(not null) 242 * @return コメント削除後の行 243 * @throws NullPointerException 引数lineが、nullの場合。 244 */ 245 public String cmntCut( final String line ) { 246 final boolean isCmnt = !line.isEmpty() && line.charAt(0) == '#'; 247 248 // カラム配列は、最初の一度のみ、セット可能とします。 249 if( isCmnt && clms.length == 0 && line.toUpperCase( Locale.JAPAN ).startsWith( NAME_KEY ) ) { 250 final String sep = line.substring( NAME_LEN,NAME_LEN+1 ); // 区切り文字。タブかカンマ 251 clms = line.split( sep ); // 区切り文字で配列化します。 252 clms[0] = ""; // 統一的に処理を行うため、#NAME を、ゼロ文字列化します。 253 } 254 255 return isCmnt ? "" : line; 256 } 257}