Javaで書いたJSONパーサのプログラムです。
GitHubにも置いてあります。
https://github.com/kmaebashi/jsonparser
RFC8259準拠のつもりです。以下の例にあるように、「3.1E5」のような指数表現や、「\uD867\uDE3D」(𩸽)のようなサロゲートペアを含むUnicodeエスケープ文字列もパースできます。
test_input以下に入っているtest01.json:
{ "array": [1, 2.0, 123, 123.456, -1, -2.0], "array2": [3e5, 3E5, 3.1e5, 3.1E5, 3.1e+5, 3.1e-5, true, false, null], "string": "abcあいうえお", "string2": "\"\\\/\t", "string3": "\u3042", "string4": "\u3042\u3044\u3046", "string5": "\uD867\uDE3D", "string6": "\uD867\uDE3D\r\n", "string7": "\uD867\uDE3D\u3042\u3044\u3046", "objSub": { "sub1": 1, "sub2": "abc" }, "objInArray": [ { "objInArray1": 1, "objInArray2": "abc" } ], "arrayInArray": [ [ 1, true, { "arrayInArray1": 1, "arrayInArray2": "abc" } ] ] }
以下のようにして使います。
try (var parser = JsonParser.newInstance("test.json")) { JsonElement elem = parser.parse(); ... }
parse()の戻り値として得られるJsonElementは、上記の通り、どのようなJSONの内容でも保持できるクラスです。実際にJSONを解釈するアプリケーションは、決まった形式のJSONしか受け付けないことが多く、決まった形式のJSONならJavaのクラスにマッピングしてくれた方がミスした時にコンパイルエラーになってくれたりして便利でしょう。そのクラスマッピングを行うのがClassMapperクラスです。
// オブジェクトからJSONへの変換 String jsonStr = ClassMapper.toJson(obj); // JSONからオブジェクトへの変換 Test1 test1 = ClassMapper.toObject(Test1.class);
staticメソッドのtoJson()とtoObject()で、それぞれクラスのオブジェクトからJSONへ、JSONからクラスのオブジェクトへの変換ができます。
この変換は内部的にはJsonElementを経由しています。なのでJsonElementからクラスに変換したりクラスからJsonElementに変換するメソッドもあります。
すみません、以下のソースはClassMapper実装前のもので情報が古いので、GitHubを見てください。
com.kmaebashi以下 ├─jsonparser │ JsonArray.java … JSONの配列を表現する │ JsonElement.java … JSONの配列、オブジェクト、値を表現する │ JsonObject.java … JSONのオブジェクトを表現する │ JsonParseException.java … パース時に発生する例外 │ JsonParser.java … パーサのインタフェース │ JsonValue.java … 数値、文字列、booleanの値を表現する │ JsonValueType.java … JsonValueの値の型を区別する列挙型 │ └─jsonparserimpl Constant.java … 定数を定義する(たいしたものは入ってません) JsonArrayImpl.java … JsonArrayの実装 JsonObjectImpl.java … JsonObjectの実装 JsonParserImpl.java … JsonParserの実装 JsonValueImpl.java … JsonValueの実装 Lexer.java … レキシカルアナライザ Token.java … JSONのトークンを表現する TokenType.java … トークンの種類を表現する列挙型 Util.java … ユーティリティメソッド(1本しかありませんが)
私が作るJavaのプログラムはたいていそうなのですが、利用者に対して公開するソースと、公開しないソースとでパッケージを分けています。
この例では、利用者に公開するパッケージはcom.kmaebashi.jsonparserで、その中にJsonParser.javaをはじめとするいくつかのinterfaceがあります。それらの実装は、JsonParserImpl.javaのような名前でjsonparameterimplパッケージの方にいます。こうして、公開するインタフェースと実装を分離しています。
まずは利用者に公開するインタフェースのソースから挙げていきます。最初に載せるのは、パーサのインタフェースであるJsonParser.javaです。
JsonParser.java:
package com.kmaebashi.jsonparser; import java.io.BufferedReader; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStreamReader; import java.io.Reader; import java.nio.charset.StandardCharsets; import com.kmaebashi.jsonparserimpl.JsonParserImpl; public interface JsonParser extends AutoCloseable { static JsonParser newInstance(String path) throws IOException { Reader reader = new BufferedReader(new InputStreamReader( new FileInputStream(path), StandardCharsets.UTF_8)); return new JsonParserImpl(reader); } static JsonParser newInstance(Reader reader) { return new JsonParserImpl(reader); } JsonElement parse() throws IOException, JsonParseException; void close() throws IOException; }
使い方のサンプルにもあるとおり、newInstance()でインスタンスを作って、pasre()を呼んでパースします。
パースして得られるのが、JsonElementです。
JsonElement.java
com.kmaebashi.jsonparser; public interface JsonElement { String stringify(); }
JsonElementは実体としてはJsonArrayかJsonObjectかJsonValueのいずれかなので、instanceofで型を特定し、ダウンキャストして内容を参照する、という使い方を想定しています。
JsonElementのstringifyメソッドは、JSONをそれっぽいインデントを付けて文字列化するメソッドです。上の方に挙げてあるtest01.jsonについて、パースしてstringify()すると、以下のようになります。
{ "array":[ 1, 2.0, 123, 123.456, -1, -2.0 ], "array2":[ 300000.0, 300000.0, 310000.0, 310000.0, 310000.0, 3.1E-5, true, false, null ], "string":"abcあいうえお", "string2":"\"\\/\b\f\n\r\t", "string3":"あ", "string4":"あいう", "string5":"𩸽", "string6":"𩸽\r\n", "string7":"𩸽あいう", "objSub":{ "sub1":1, "sub2":"abc" }, "objInArray":[ { "objInArray1":1, "objInArray2":"abc" }, [ ], { } ], "arrayInArray":[ [ 1, true, { "arrayInArray1":1, "arrayInArray2":"abc" } ] ] }
インデントは入力のJSONとは異なりますし、指数表記が普通の実数表記になったり、Unicodeエスケープが「𩸽」になったりするので「元のJSON文字列に戻る」わけではありませんが、これはこれで正しく、内容は等しいJSONにはなっているかと思います。
JsonElementのサブインタフェースとしてJsonArray, JsonObject, JsonValueがあります。まずはJsonArrayから。
JsonArray.java
package com.kmaebashi.jsonparser; import java.util.List; public interface JsonArray extends JsonElement { List<JsonElement> getArray(); int getLeftBracketLineNumber(); int getRightBracketLineNumber(); @Override String stringify(); }
getArray()メソッドで、JsonElementのListが取得できますので、ここからこの配列以下のJsonElementを取得できます。このListは、Collections.unmodifiableList()で変更不能にしてありますので、利用者側での変更はできません。
getLeftBracketLineNumber()とgetRightBracketLineNumberは、「[」と「]」の元JSON内での行番号を返します。JSONとしてパースはできてもアプリケーションレベルではエラーになるJSONというものがあるでしょうから、そのような場合にエラーメッセージに適切な行番号を出すために用意しています。
JsonObjectも基本的に同様です。
JsonObject.java:
package com.kmaebashi.jsonparser; import java.util.Map; public interface JsonObject extends JsonElement { Map<String, JsonElement> getMap(); int getLeftBraceLineNumber(); int getRightBraceLineNumber(); int getKeyLineNumber(String key); @Override String stringify(); }
JSON中の値(数値、文字列、boolean値、null)を表現するのがJsonValueです。getType()で型を確認し、getInt(), getReal(), getString(), getBoolean()のいずれかのメソッドで値を取得します。
package com.kmaebashi.jsonparser; public interface JsonValue extends JsonElement { JsonValueType getType(); int getInt(); double getReal(); String getString(); boolean getBoolean(); int getLineNumber(); @Override String stringify(); }
前述の通り実装はcom.kmaebashi.jsonparserimplパッケージにあります。
このパーサは、レキシカルアナライザ(Lexer)が入力をトークン(Token)に分割し、再帰下降パーサでそれをパースする、という(標準的な、あるいはクラシックな)構造になっています。このパーサは1トークンだけ先読みする再帰下降パーサなので、同じ原理で「LL(1)」という範囲の文法はパースできます。たとえばこちらで作ったsamplanは同様のパーサでパースしていますし、PascalなどもLL(1)の範囲内です。「プログラミング言語を作る」というと難しそうに思えるかもしれませんが、このJsonParserと同じ手法で、そこそこ実用的な言語が作れます。
RFC8259によれば、JSONでのトークンには、6つの構造文字(structual characters)と、文字列と数値と3つのリテラル名(true、false、null)がある、と書いてあります。つまり、全部でこれだけあります。
このトークンの種類を、TokenTypeという列挙型で表しています。JSONとしては整数型と実数型の区別はなくどちらも数値型ですが(JSONの元となったJavaScriptも同様ですが)、Javaで使う分には区別がいると思うので、トークンの種類も分けています。
TokenType.java
package com.kmaebashi.jsonparserimpl; public enum TokenType { LEFT_BRACKET, RIGHT_BRACKET, LEFT_BRACE, RIGHT_BRACE, COLON, COMMA, TRUE, FALSE, NULL, INT, REAL, STRING, END_OF_FILE }
レキシカルアナライザは、入力のJSONを、トークンの並びに分割します。そのそれぞれのトークンを表現するクラスがTokenです。
Token.java
package com.kmaebashi.jsonparserimpl; public class Token { final TokenType type; final String tokenString; int intValue; double realValue; final int lineNumber; Token(TokenType type, String tokenString, int lineNumber) { this.type = type; this.tokenString = tokenString; this.lineNumber = lineNumber; } Token(TokenType type, String tokenString, int intValue, int lineNumber) { this(type, tokenString, lineNumber); this.intValue = intValue; } Token(TokenType type, String tokenString, double realValue, int lineNumber) { this(type, tokenString, lineNumber); this.realValue = realValue; } }
TokenTypeで種別を保持し、整数、実数、文字列といった値を保持できるようになっています。行番号もここに持っています。
JSONからこれを切り出すプログラムのことをレキシカルアナライザ(lexical analyzer)、あるいは略してlexer(レクサ)と呼びます。このJSONパーサのLexerは以下です。
Lexer.java
package com.kmaebashi.jsonparserimpl; import com.kmaebashi.jsonparser.JsonParseException; import java.io.IOException; import java.io.Reader; import java.util.ArrayList; import java.util.HashMap; public class Lexer { private int currentLineNumber = 1; private final Reader reader; private enum Status { // 初期状態 INITIAL, // 数値のマイナス記号読み込み直後 MINUS, // 数値の小数点より前 INT_PART, // 数値の小数点読み込み直後 DECIMAL_POINT, // 小数点以後の数字読み込み中 AFTER_DECIMAL_POINT, // 指数表記のE(またはe)読み込み直後 // 次には指数の符号が来る可能性がある EXP_SIGN, // 指数表記の指数部の値 EXP, // 予約語の可能性のある文字列の途中 ALNUM, // 文字列内部 STRING, // 文字列内の「\」取得直後 STRING_ESCAPE, // 「\」の後、「u」が来て、16進数四桁を読み込むところまで // 4桁読み込むとSTRING_UNICODE2に遷移する。 STRING_UNICODE, // Unicodeの16進4桁を読み込んだ直後。 // この後に「\」と「u」が続く場合はサロゲートペアの可能性があるので // 継続して読み込み、unicodeCodePointsに蓄積する必要がある。 STRING_UNICODE2, // Unicodeの16進4桁を読み込んだ後に「\」が来た。 // この後に「u」が来ればUnicode継続、それ以外の文字なら // (1文字戻して)通常のSTRING_ESCAPEに遷移する。 STRING_UNICODE3 } private final HashMap<String, TokenType> keywordTable = new HashMap<String,TokenType>() { { put("true", TokenType.TRUE); put("false", TokenType.FALSE); put("null", TokenType.NULL); } }; private int lookAheadCharacter; private boolean lookingAhead = false; public Lexer(Reader reader) { this.reader = reader; } private int getc() throws IOException { if (this.lookingAhead) { this.lookingAhead = false; return this.lookAheadCharacter; } else { return reader.read(); } } private void ungetc(int ch) { this.lookAheadCharacter = ch; this.lookingAhead = true; } Token getToken() throws IOException, JsonParseException { int ch; Status currentStatus = Status.INITIAL; TokenType tokenType; StringBuilder currentToken = new StringBuilder(); int unicodeCount = 0; StringBuilder unicodeHexStr = new StringBuilder(); ArrayList<Integer> unicodeCodePoints = new ArrayList<>(); for (;;) { ch = getc(); switch (currentStatus) { case INITIAL: if (ch == '[') { return new Token(TokenType.LEFT_BRACKET, "[", this.currentLineNumber); } else if (ch == ']') { return new Token(TokenType.RIGHT_BRACKET, "]", this.currentLineNumber); } else if (ch == '{') { return new Token(TokenType.LEFT_BRACE, "{", this.currentLineNumber); } else if (ch == '}') { return new Token(TokenType.RIGHT_BRACE, "}", this.currentLineNumber); } else if (ch == ':') { return new Token(TokenType.COLON, ":", this.currentLineNumber); } else if (ch == ',') { return new Token(TokenType.COMMA, ",", this.currentLineNumber); } else if (Character.isJavaIdentifierStart(ch)) { currentToken.append((char)ch); currentStatus = Status.ALNUM; } else if (ch == '-') { currentToken.append((char) ch); currentStatus = Status.MINUS; } else if (Character.isDigit(ch)) { currentToken.append((char) ch); currentStatus = Status.INT_PART; } else if (ch == '\"') { currentStatus = Status.STRING; } else if (Character.isWhitespace(ch)) { if (ch == '\n') { this.currentLineNumber++; } } else if (ch == -1) { return new Token(TokenType.END_OF_FILE, null, this.currentLineNumber); } else { throw new JsonParseException("不正な文字(" + (char)ch + ")", this.currentLineNumber); } break; case MINUS: if (Character.isDigit(ch)) { currentToken.append((char)ch); currentStatus = Status.INT_PART; } else { throw new JsonParseException("マイナスの後ろに数字がありません(" + (char)ch + ")", this.currentLineNumber); } break; case INT_PART: if (Character.isDigit(ch)) { currentToken.append((char)ch); } else if (ch == '.') { currentToken.append((char)ch); currentStatus = Status.DECIMAL_POINT; } else if (ch == 'E' || ch == 'e') { currentToken.append('e'); currentStatus = Status.EXP_SIGN; } else { ungetc(ch); int intValue = Integer.parseInt(currentToken.toString()); return new Token(TokenType.INT, currentToken.toString(), intValue, this.currentLineNumber); } break; case DECIMAL_POINT: if (Character.isDigit(ch)) { currentToken.append((char)ch); currentStatus = Status.AFTER_DECIMAL_POINT; } else { throw new JsonParseException("小数点の後ろに数字がありません(" + (char)ch + ")", this.currentLineNumber); } break; case AFTER_DECIMAL_POINT: if (Character.isDigit(ch)) { currentToken.append((char)ch); } else if (ch == 'E' || ch == 'e') { currentToken.append('e'); currentStatus = Status.EXP_SIGN; } else { ungetc(ch); double doubleValue = Double.parseDouble(currentToken.toString()); return new Token(TokenType.REAL, currentToken.toString(), doubleValue, this.currentLineNumber); } break; case EXP_SIGN: if (ch == '+' || ch == '-') { currentToken.append((char)ch); } else { ungetc(ch); } currentStatus = Status.EXP; break; case EXP: if (Character.isDigit(ch)) { currentToken.append((char)ch); } else { ungetc(ch); double doubleValue = Double.parseDouble(currentToken.toString()); return new Token(TokenType.REAL, currentToken.toString(), doubleValue, this.currentLineNumber); } break; case ALNUM: if (Character.isJavaIdentifierStart(ch)) { currentToken.append((char)ch); } else { ungetc(ch); String tokenString = currentToken.toString(); if (this.keywordTable.containsKey(tokenString)) { tokenType = this.keywordTable.get(tokenString); return new Token(tokenType, tokenString, this.currentLineNumber); } else { throw new JsonParseException("不正なキーワード(" + tokenString + ")", this.currentLineNumber); } } break; case STRING: if (ch == '\\') { currentStatus = Status.STRING_ESCAPE; } else if (ch == '\"') { return new Token(TokenType.STRING, currentToken.toString(), this.currentLineNumber); } else { currentToken.append((char)ch); } break; case STRING_ESCAPE: if (ch == '\"' || ch == '\\' || ch == '/') { currentToken.append((char)ch); currentStatus = Status.STRING; } else if (ch == 'b') { currentToken.append("\b"); currentStatus = Status.STRING; } else if (ch == 'f') { currentToken.append("\f"); currentStatus = Status.STRING; } else if (ch == 'n') { currentToken.append("\n"); currentStatus = Status.STRING; } else if (ch == 'r') { currentToken.append("\r"); currentStatus = Status.STRING; } else if (ch == 't') { currentToken.append("\t"); currentStatus = Status.STRING; } else if (ch == 'u') { currentStatus = Status.STRING_UNICODE; } else { throw new JsonParseException("不正なエスケープ文字です(" + (char)ch + ")", this.currentLineNumber); } break; case STRING_UNICODE: if ((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')) { unicodeHexStr.append((char) ch); unicodeCount++; if (unicodeCount == 4) { int codePoint = Integer.parseInt(unicodeHexStr.toString(), 16); unicodeCodePoints.add(codePoint); unicodeHexStr = new StringBuilder(); unicodeCount = 0; currentStatus = Status.STRING_UNICODE2; } } else { throw new JsonParseException("\\uの後ろには16進数4桁が来なければいけません(" + (char)ch + ")", this.currentLineNumber); } break; case STRING_UNICODE2: if (ch == '\\') { currentStatus = Status.STRING_UNICODE3; } else { int[] codePoints = arrayListToIntArray(unicodeCodePoints); String str = new String(codePoints, 0, codePoints.length); currentToken.append(str); ungetc(ch); currentStatus = Status.STRING; } break; case STRING_UNICODE3: if (ch == 'u') { currentStatus = Status.STRING_UNICODE; } else { int[] codePoints = arrayListToIntArray(unicodeCodePoints); String str = new String(codePoints, 0, codePoints.length); currentToken.append(str); ungetc(ch); currentStatus = Status.STRING_ESCAPE; } break; default: assert(false); } } } private int[] arrayListToIntArray(ArrayList<Integer> arrayList) { int[] ret = new int[arrayList.size()]; for (int i = 0; i < arrayList.size(); i++) { ret[i] = arrayList.get(i); } return ret; } }
列挙型Statusで現在の状態を保持しています。説明はコメントに入れておきました。
CSVパーサやNamedParameterPreparedStatementもレキシカルアナライザを含んでいて、基本的な構造は同じですが、このレキシカルアナライザは1文字だけ先読みするようになっています。普通に1文字ずつ読み込むにはgetc()メソッドを使えばよいですが、ungetc()を使うことで1文字だけストリームに戻すことができて、戻した文字は次のgetc()でまた取得できます。これがどのように役に立っているかはソースを読んでください。
レキシカルアナライザが分割したトークンを、JSONの木構造に解釈するのがパーサ(parser)です。
JsonParserImpl.java
package com.kmaebashi.jsonparserimpl; import com.kmaebashi.jsonparser.JsonArray; import com.kmaebashi.jsonparser.JsonElement; import com.kmaebashi.jsonparser.JsonObject; import com.kmaebashi.jsonparser.JsonParseException; import com.kmaebashi.jsonparser.JsonParser; import java.io.IOException; import java.io.Reader; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; import java.util.HashMap; public class JsonParserImpl implements JsonParser { private final Reader reader; private Lexer lexer; private Token lookAheadToken; private boolean lookingAhead = false; public JsonParserImpl(Reader reader) { this.reader = reader; } private Token getToken() throws IOException, JsonParseException { if (this.lookingAhead) { this.lookingAhead = false; return lookAheadToken; } else { return lexer.getToken(); } } private void ungetToken(Token token) { lookAheadToken = token; lookingAhead = true; } public JsonElement parse() throws IOException, JsonParseException { this.lexer = new Lexer(this.reader); return parseJsonElement(); } JsonElement parseJsonElement() throws IOException, JsonParseException { Token token = getToken(); if (token.type == TokenType.INT) { return new JsonValueImpl(token.intValue, token.lineNumber); } else if (token.type == TokenType.REAL) { return new JsonValueImpl(token.realValue, token.lineNumber); } else if (token.type == TokenType.STRING) { return new JsonValueImpl(token.tokenString, token.lineNumber); } else if (token.type == TokenType.TRUE) { return new JsonValueImpl(true, token.lineNumber); } else if (token.type == TokenType.FALSE) { return new JsonValueImpl(false, token.lineNumber); } else if (token.type == TokenType.NULL) { return new JsonValueImpl(token.lineNumber); } else if (token.type == TokenType.LEFT_BRACKET) { return parseArray(token); } else if (token.type == TokenType.LEFT_BRACE) { return parseObject(token); } else { throw new JsonParseException("不正なトークンです(" + token.type + ")", token.lineNumber); } } private JsonArray parseArray(Token leftBracketToken) throws IOException, JsonParseException { ArrayList<JsonElement> arrayList = new ArrayList<>(); Token token; boolean tailComma = false; for (;;) { token = lexer.getToken(); if (token.type == TokenType.RIGHT_BRACKET) { if (tailComma) { throw new JsonParseException("JSONでは配列の末尾に,は付けられません", token.lineNumber); } break; } ungetToken(token); JsonElement elem = parseJsonElement(); arrayList.add(elem); token = lexer.getToken(); if (token.type != TokenType.COMMA) { break; } tailComma = true; } if (token.type != TokenType.RIGHT_BRACKET) { throw new JsonParseException("配列の要素の終わりがカンマでも]でもありません(" + token.type + ")", token.lineNumber); } return new JsonArrayImpl(Collections.unmodifiableList(arrayList), leftBracketToken.lineNumber, token.lineNumber); } private JsonObject parseObject(Token leftBraceToken) throws IOException, JsonParseException { Map<String, JsonElement> map = new LinkedHashMap<>(); Map<String, Integer> keyLineNumberMap = new HashMap<>(); Token token; boolean tailComma = false; for (;;) { token = lexer.getToken(); if (token.type == TokenType.RIGHT_BRACE) { if (tailComma) { throw new JsonParseException("JSONではオブジェクトの末尾に,は付けられません", token.lineNumber); } break; } ungetToken(token); Token keyToken = getToken(); if (keyToken.type != TokenType.STRING) { throw new JsonParseException("オブジェクトのキーが文字列ではありません(" + keyToken.type + ")", token.lineNumber); } keyLineNumberMap.put(keyToken.tokenString, token.lineNumber); Token colonToken = getToken(); if (colonToken.type != TokenType.COLON) { throw new JsonParseException("オブジェクトのキーの後ろがコロンではありません(" + colonToken.type + ")", token.lineNumber); } JsonElement elem = parseJsonElement(); if (map.containsKey(keyToken.tokenString)) { throw new JsonParseException("オブジェクトのキーが重複しています(" + keyToken.tokenString + ")", token.lineNumber); } map.put(keyToken.tokenString, elem); token = lexer.getToken(); if (token.type != TokenType.COMMA) { break; } tailComma = true; } if (token.type != TokenType.RIGHT_BRACE) { throw new JsonParseException("オブジェクトの要素の終わりがカンマでも}でもありません(" + token.type + ")", token.lineNumber); } return new JsonObjectImpl(Collections.unmodifiableMap(map), leftBraceToken.lineNumber, token.lineNumber, Collections.unmodifiableMap(keyLineNumberMap)); } @Override public void close() throws IOException { this.reader.close(); } }
このパーサは典型的な再帰下降パーサです。レキシカルアナライザと同じように、1トークンだけ先読みしていて、ungetToken()でトークンを戻すことができます。再帰下降パーサについてはいろいろ解説もあると思いますが、ソースを読めば理解できるかと思います。「1トークン先読みの再帰下降パーサ」で、たとえばPascalやここにあるsamplan程度の言語であればパースできます。「プログラミング言語を作る」というと難しそうに思えるかもしれませんが、こうして見ると作れそうな気がしてこないでしょうか。
他のソースは、Githubを参照してください。
公開日: 2024/01/27
不具合等ありましたら、掲示板にご連絡願います。