Здравствуйте, уважаемые читатели Хабра. В данной статье Вы узнаете, как написать свой JSON-парсер (с жуками и фичами). Созданный парсер будет преобразовывать последовательность текстовых символов в well-formed JSON-объект, который будет представлять JSON-Документ. JSON-документ - это последовательность символов, которая правильно и неукоснительно выполняет правила грамматики языка. Существуют несколько грамматик и RFC, для описания правильной последовательности текста в формате JSON. Для данного парсера воспользуемся спецификацией RFC 8259.

Мотивация

Во-первых, избавиться от некоторых гугловских библиотек, используемых только для разбора JSON текста.

Во-вторых, написать свой, красивый простой API с богатым набором методов, конструкторов и прочих вещей.

В-третьих, изменяемость. Внесение изменений в своё творение.

Элементы JSON.

Итак, что будет вовзращать наш парсер? Очевидно, что одного ответа на вопрос: "Данный текст соответствует JSON-формату?" будет недостаточно. Нам необходимо извлечь содержимое JSON, если таковое имеется. Следовательно, куда сохранить, как хранить, как реализовывать, как потом обращаться с ним? Так много вопросов. Будем действовать последовательно.

Обратимся к RFC. Согласно документу, есть 4 примитивных типов, и 2 структурных (объекты и массивы). Начнём с неструктруных типов. Для каждого типа создадим свой собственный класс, который будет возвращать значение соответствующего типа. Все они будут наследоваться от общего абстрактного класса JsonElement, представляющий элементарное значение в JSON-е.

public abstract class JsonElement<T> { //T - конкретный тип данного в JSON.
    public abstract T getValue(); //Значение указанного типа в тексте.

  	//Возвращаем строковый вариант самого значения
    @Override
    public String toString(){
        return getValue().toString(); 
    }

}

Класс JsonElement абстрактный, поэтому каждый его дочерний подкласс, который будет содержать конкретику, должен реализовать метод getValue.

Начнём с булевого типа (boolean). Напишем класс JsonBoolean производный от JsonElement<Boolean>.

public class JsonBoolean extends JsonElement<Boolean> { //Значение типа - Boolean

    private boolean v;
  
    //По умолчанию = false.
    public JsonBoolean(){
      this.v = false;
    }
  
    //передать выражение логического типа.
  	public JsonBoolean(Boolean b){
     	this.v = b; 
    }
  
    //Мнемонический конструктор.
    //t = true (t stands for true). Как сокращение.
    //other = false. (любой символ, кроме 't').
    public JsonBoolean(Character c){
        this.v = (c == 't');
    }

    @Override
    public Boolean getValue() {
        return v;
    }

}

Далее, напишем такой же класс для числового типа (Number). Заметим, что в JSON можно описывать как целые числа, так и действительные числа с десятичной точкой. Для отделения целых чисел, от чисел с точкой, определим два класса. Первый класс JsonNumber будет обозначать целые числа. Его код абсолютно идентичен, за исключением конструкторов и схемы наследования.

public class JsonNumber extends JsonElement<Number> { // Числовой тип - Number.

    private Number n;

    //Не знаем точно, какой именно из числовых типов, поэтому Number.
    public JsonNumber(Number n){
        this.n = n;
    }
  
    @Override
    public Number getValue() {
        return n;
    }
}

Код же для класса действительных чисел, JsonRealNumber ещё проще:

public class JsonRealNumber extends JsonNumber { // Включает в себя JsonNumber.

    //Здесь можно использовать конкретный тип для чисел с плавающей запятой.
    //Float или Double.
    public JsonRealNumber(Float n){ 
        super(n);
    }
    public JsonRealNumber(Double n) {
        super(n);
    }
}

Остаётся строковый тип JsonString и тип JsonNull для значения null (да, для null - написали аж отдельный класс.). Ниже приведен код данных классов.

public class JsonString extends JsonElement<String> {
    
    private String val;
    public JsonString(String v){
        this.val = v;
    }

    @Override
    public String getValue() {
        return val;
    }

    // Не надо лишний раз вызывать getValue(), у нас уже есть строка.
    @Override
    public String toString(){
        return val;
    }
}

//Определили в отдельном файле JsonNull.java
public class JsonNull extends JsonElement<String> {
    @Override
    public String getValue() {
        return "null";  //Просто возвращаем в виде строки "null"
    }
}

Объясним по поводу getValue() у типа JsonNull. Как Вы уже заметили, значение строки в виде "null" типа JsonString совпадает со значением типа JsonNull. Отличить их можно по конкретному типу JsonNull.

Теперь перейдем к структурным типам. Самый простой - это массив, который содержит в виде списка коллекцию элементов JsonElement. Для простоты, класс JsonArray (для массива), лишь оборачивает работу с ArrayList<JsonElement>. Его код приведён далее.

/* JsonArray - как List of JsonElements. */
public class JsonArray extends JsonElement<List<JsonElement>> {
    private List<JsonElement> elements;
    private int c; // 'c' stands for count (число элементов).
    private String pName; // имя свойства, по которому массив доступен.

    public JsonArray(){
        this.elements = new ArrayList<>();
        this.c = 0;
        this.pName = null;
    }

    public JsonArray(List<JsonElement> elements){
        this.elements = elements;
        this.c = elements.size();
        this.pName = null;
    }

    //Меняем ссылку (НЕ addElements)!
    public void setElements(List<JsonElement> elements) {
        this.elements = elements;
        this.c = elements.size();
    }

    public <T> void add(JsonElement<T> e){
        elements.add(e);
        c++;
    }

    public void setPropertyName(String pName) {
        this.pName = pName;
    }

    public String getPropertyName(){
        return pName;
    }

    public ArrayList<JsonElement> getElements() {
        return elements;
    }

    //getCount().
    public int getC() {
        return c;
    }

    @Override
    public List<JsonElement> getValue() {
        return elements;
    }

Остаётся теперь последний тип - объекты JSON. Структура объекта содержит пары «имя-значение», где значение может быть либо структурным типом (объект или массив), либо обычным типом, которые уже перечислялись выше. Имена - суть ключи. Тип ключей - строка. Напишем отдельный класс для хранения последовательности таких пар, и их соответствующего вывода на экран (в консоль, текстовый файл и т.д.).

/* LinkedHashMap - сохраняет порядок вставки элементов. 
	 Нужная вещь, при последовательном проходе и извлечений пар.
   TKey = String. (Тип ключа - строки).
   TValue - V. (Пока оставили произвольный).
*/
public class StrLinkedHashMap<V> extends LinkedHashMap<String, V> {
  
    //Метод, для вывода значений у пар.
    @Override
    public String toString(){
        Set<String> keys = keySet();
      
        //Не рассматриваем параллельные потоки.
        //(Непотокобезопасен! Используйте StringBuffer)
        StringBuilder sb = new StringBuilder();
      
        sb.append("{\n\t"); // имена и значения объекта заключены в фигурные скобки.
        
        Iterator<String> t = keySet().iterator();
        while(t.hasNext()){
            String k = t.next();
            sb.append(k+" : "+get(k).toString());
            if(t.hasNext())
                sb.append(",\n\t");
        }
        sb.append("}");
        return sb.toString();
    }
}

Затем воспользуемся новым типом, для определения типа-объекта JSON. Определим JSON-объект в виде класса JsonObject.

public class JsonObject extends JsonElement<StrLinkedHashMap<JsonElement>> {
    private StrLinkedHashMap<JsonElement> table; // Содержимое

    public JsonObject(){
        this.table = new StrLinkedHashMap<>();
    }

    public void Put(String k, JsonElement id){
        this.table.put(k,id);
    }

    //inherits from LinkedHashMap.
    // Получить значение по ключу.
    // В данном случае по имени свойства.
    // Метод получает значение из текущей коллекции пар, по ключу k.
    // Не работает со вложенными объектами (свойствами).
    public JsonElement Get(String k){//get element on the root level.
        return table.get(k);
    }


    
    //Синоним для метода Get(String k).
    public JsonElement getProperty(String pname){
        return table.get(pname);
    }

    //get Element by path using path dot notation.
    // Работает со вложенными объектами.
    public JsonElement getElement(String path){
        ArrayList<String> p = new ArrayList<>();
      
        //Разделить строку на слова по разделителю точка '.'
        Pattern regex = Pattern.compile("\\w([^.]*)"); 
        Matcher m = regex.matcher(path); 
        while(m.find())
            p.add(m.group());
        StrLinkedHashMap<JsonElement> cnode = table;//ROOT. Корень.
        JsonElement n  = null;
      
        //Если имя свойства без точек -> извлечь непосредственно.
        if(p.size() == 0) 
            return getProperty(path);
      
        // Двигаемся вглубь, последовательно обращаясь к "A.B.C"
        for(String c : p){
            n = cnode.get(c); 
          
            if(n == null) //Ничего вообще нет (даже null в тексте не указали)
                return n; // будет null в коде.
          
            try {//is interior node
                //cnode - текущий объект по вложенности.
                // Переходим к следующему, если не получилось, то извлекаем у текущего.
                cnode = (StrLinkedHashMap<JsonElement>) n.getValue(); // isTable.
            }catch (ClassCastException e){//that was leaf. (не структурный тип).
                return cnode.get(c); //Извлечь значение данного типа у текущего объекта.
            }
        }
        return n;
    }

    //Значение - коллекция пар "имя" : "значение".
    @Override
    public StrLinkedHashMap<JsonElement> getValue() {
        return table;
    }

    //Нерекурсивно, явно с использованием стэка, выводим построчно всё содержимое,
    //включая вложенные объекты и массивы.
    @Override
    public String toString(){
        StringBuilder sb = new StringBuilder();
        LinkedStack<Triple<String, JsonElement,String>> S = new LinkedStack<>();//entity
        LinkedStack<Integer> T = new LinkedStack<>();//count of tabs (\t symbol)
        int tabs = 0;//current count of tabs
        T.push(0);
        S.push(new Triple<>("", this, ""));
        while(!S.isEmpty()){
            tabs = T.top();
            T.pop();
            _addTabs(sb,tabs);

            String prop = S.top().getV1();
            sb.append(prop);
            JsonElement e = S.top().getV2();
            String c = S.top().getV3();
            S.pop();
            if(e instanceof JsonObject){
                //WRITE BOUND OF OBJECT AND INCREMENT(tabs)
                sb.append('{').append('\n');

                T.push(tabs);
                S.push(new Triple<>("", new JsonLiteral("}"), c));

                //ADD <key, value> pair to the STACK.
                JsonObject ob = (JsonObject)e;
                Iterator<String> ks = ob.table.keySet().iterator();

                //FIRST ITERATION (put to stack entry without comma)
                if(ks.hasNext()) {
                    String p = ks.next();
                    String v1 = "\"" + p + "\"" + " : ";
                    S.push(new Triple<>(v1, ob.getProperty(p), ""));
                    T.push(tabs + 1);
                }

                //put others with comma
                while(ks.hasNext()){
                    String p = ks.next();
                    String v1 = "\"" + p + "\"" + " : ";
                    S.push(new Triple<>(v1, ob.getProperty(p), ","));
                    T.push(tabs + 1);
                }
            }
            else if(e instanceof JsonArray){
                ArrayList<JsonElement> arr = ((JsonArray)e).getValue();
                int s = arr.size();
                sb.append('[').append('\n');

                S.push(new Triple<>("", new JsonLiteral("]"), c));
                T.push(tabs);

                //FIRST ITERATION
                JsonElement el = arr.get(s - 1);
                s--;
                S.push(new Triple<>("", el, ""));
                T.push(tabs + 1);

                //put others with comma.
                while(s != 0){
                    el = arr.get(s - 1);
                    s--;
                    S.push(new Triple<>("", el, ","));
                    T.push(tabs + 1);
                }
            }
            else if(e instanceof JsonLiteral || e instanceof JsonNull || e instanceof JsonNumber || e instanceof JsonBoolean){
                sb.append(e.getValue().toString()).append(c).append('\n');
            }
            else if(e instanceof JsonString){
                sb.append('\"').append(((JsonString) e).getValue()).append('\"').append(c).append('\n');
            }
        }
        return sb.toString();
    }

    //Вспомогательный метод, добавляет табы.
    private void _addTabs(StringBuilder sb, int tabs){
        int k = 0;
        while(k < tabs){
            sb.append('\t');
            k++;
        }
    }
}

Для вывода всего содержимого у объекта, был определён вспомогательный тип - JsonLiteral. Он лишь нужен для уточнения и разделения последовательности содержимого. Его код представлен ниже.

public class JsonLiteral extends JsonString {
    public JsonLiteral(String v) {
        super(v);
    }
}

Состояния парсера.

Парсер принимает на вход последовательность текстовых символов. Он будет возвращать содержимое JSON, в виде объекта-JSON. Т.е. это будет экземпляр класса JsonObject. Поскольку работать придётся со вложенными структурами, то необходимо будет использовать стэки. Распознанную лексему тоже придётся сохранять на время где-нибудь. И надо будет также пробрасывать свои исключения (ошибки), при обнаружении несоответствия к JSON-у. Конечная модель состояний парсера выглядит следующим образом:

Начнём с простого, напишем первоначальный код класса парсера, SimpleJsonParser.

public class SimpleJsonParser {
  
  private JsParserState state; //Текущее состояние парсера.
  private char[] buf; //Буфер для входного потока.
  private int bsize; //Размер буфера
  private int bufp; //Текущая позиция в буфере.
  
   private int line; //line, col - позиция в текстовом файле.
   private int col;
   private JsonElement curVal; //текущий распознанный элемент.

   //Создать с буфером размера bsize.
   //Начальное состояние - START, позиция в буфере = 0,
   //позиция в файле - первая строка, нулевой столбец. (1, 0).
   public SimpleJsonParser(int bsize){
        this.state = JsParserState.START; //START - начальное состояние.
        this.buf = new char[bsize];
        this.bsize = bsize;
        this.bufp = 0;
        this.line = 1;
        this.col = 0;
   }

   //Размер буфера по умолчанию - 255 символов.
   public SimpleJsonParser(){
        this(255);
   }
 //...other code.
}

а для исключений определим новый тип исключений - JsonParseException.

//Расширяемся от RuntimeException -
// - не надо проверять при компиляции.
// - не надо обёртывать try/catch (в Runtime упадёт).
public class JsonParseException extends RuntimeException {
    public JsonParseException(String message, Throwable throwable){
        super(message, throwable);
    }
}

Итак, входом будет последовательность символов, представленная типом InputStream. Определим базовую возможность, ввести имя файла, с которого будем читать. Для этого напишем сразу три метода в классе.

 public class SimpleJsonParser {
   
   //Поля и конструктора....
   
   //Получить по абсолютному или относительному пути экземпляр File.
   public JsonObject parse(String fileName) {
        File f = new File(fileName);
        return parse(f);
   }

    
   public JsonObject parse(File fl){
       JsonObject result = null;
       try(FileInputStream f = new FileInputStream(fl.getAbsolutePath());
       ){
           result = parseStream(f);
       } catch (FileNotFoundException e){
           System.out.println(e.getMessage());
           flushBuf();
       } catch (IOException e){
           System.out.println(e.getMessage());
           this.state = JsParserState.START; //Исключение - назад к начальному состоянию.
           flushBuf();
           this.col = 0; this.line = 1;
           return null;
       }
       return result;
   }

   //Можно вызвать напрямую, передав InputStream,
   // или же воспользоваться первым методом, для чтения из файла.
   public JsonObject parseStream(InputStream in){
     //TODO: обработка входа IN.  
     return null;  
   }
   
   //Сбросить содержимое буфера, установив все символы в ноль.
   private void flushBuf(){
        bufp = 0;
        for(int i = 0; i < this.buf.length; i++){
            this.buf[i] = '\u0000';
        }
    }
 }

Поскольку предполагается работа с текстовым файлом, то InputStream не подойдёт, поскольку он работает с бинарниками. Нужен более конкретный тип - InputStreamReader, который может работать с текстом.

Для сохранения текущего контекста вложенности, будем сохранять последовательности вложенных массивов, объектов, имён свойств и текущих вершин (структурных типов) в четыре соответствующих стэка: J_OBJS, J_ARRS, J_ROOTS, props. Обработка текста будет идти до тех пор, пока не сформируется полноценный JSON-объект со всем вложенным в него содержимым (обозначим как состояние CLOSEROOT), либо до первой грамматической ошибки (обозначим как состояние ERR). В итоге, конечный результат будет лежать на вершине стэка J_OBJS, поскольку парсер возвращает JsonObject. Итерацию напишем в отдельном методе iterate.

public JsonObject parseStream(InputStream in){
        LinkedStack<JsonObject> J_OBJS = new LinkedStack<>();
        LinkedStack<JsonArray> J_ARRS = new LinkedStack<>();
        LinkedStack<JsonElement> J_ROOTS = new LinkedStack<>();
        LinkedStack<String> props = new LinkedStack<>();

        //InputStreamReader от InputStream.
        try(InputStreamReader ch = new InputStreamReader(in)){
            this.state = JsParserState.START;// before read set parser to the start state.
            this.col = 0; this.line = 1;
            flushBuf(); //Заранее ставим в начало перед обработкой.
          
            //Основной цикл.
            while(this.state != JsParserState.CLOSEROOT && this.state != JsParserState.ERR)
                iterate(J_OBJS, J_ARRS, J_ROOTS, props, ch);
            if(this.state == JsParserState.ERR)
                return null; //Если ошибка, то null.

        } catch (IOException e){ //Ошибка чтения - null (см. выше).
            System.out.println(e.getMessage());
            System.out.println("At (" + line + ":" + col + ")");
            flushBuf();
            return null;
        }
        return J_OBJS.top();
}

Теперь распишем метод iterate со всеми его частями и вспомогательными методами.


    private void iterate(LinkedStack<JsonObject> J_OBJS, LinkedStack<JsonArray> J_ARR,
                        LinkedStack<JsonElement> J_ROOTS, LinkedStack<String> props, InputStreamReader r) throws IOException{
        int c = (int)' ';
        JsonObject cur_obj = null;
        while(c == ' ' || c == '\n' || c == '\r' || c == '\t') { //skip spaces
            c = getch(r);
        }

        //JSON_OBJ -> { PROPS } | { }.
        if(this.state == JsParserState.AWAIT_PROPS_OR_END_OF_OBJ && c == '}' && ungetch('}') == 0)
            err('}', "available space for '}' but OutOfMemory!");

        else if(this.state == JsParserState.AWAIT_PROPS_OR_END_OF_OBJ && c == '}'){ // side effect from previous if [ ungetch('}') call]!
            this.state = JsParserState.NEXT_VALUE; // just goto NEXT_VALUE where all checks.
        }
        else if(this.state == JsParserState.AWAIT_PROPS_OR_END_OF_OBJ && ungetch((char) c) == 0) // ungetch call produces side effect for next else if branch
            err(c, "available space for '"+(char) c +"' but OutOfMemory!");

        else if(this.state == JsParserState.AWAIT_PROPS_OR_END_OF_OBJ)
            this.state = JsParserState.AWAIT_PROPS;

        else if(this.state == JsParserState.START && c != '{') //Json object must starts with '{'
            err(c, "{");

        else if(this.state == JsParserState.START){ // state = Start, c == '{'
            J_OBJS.push(new JsonObject());
            J_ROOTS.push(J_OBJS.top());
            this.state = JsParserState.AWAIT_PROPS_OR_END_OF_OBJ;
        }
        // MEMBER -> >" propName " : PROPVALUE | STRINGVAL -> >" symbols ".
        else if((this.state == JsParserState.AWAIT_PROPS || this.state == JsParserState.AWAIT_STRVALUE) && c != '\"')
            err(c, "\"");
        else if(this.state == JsParserState.AWAIT_PROPS){ // state == AWAIT_PROPS, c == '"' [ trans_from(Start, '{') ]

            c = getFilech(r); //read character directly from file.
            int l = 0;
            while(c != '\"' && bufp < bsize) { //read all content until '"' char (end of the string) while buffer available.
                if(c == '\\')
                    c = getEscaped(r);
                l++;
                buf[bufp++] = (char)c;
                c = getFilech(r);
            }
            if(bufp >= bsize && c != '\"') { //too long string (buffer exceeded)
                err(c, "available space for \'"+(char)c +"'\' or EOL (\") symbol");
                return;
            }
            props.push(new String(buf, 0, l));
            flushBuf();
            this.state = JsParserState.READ_PROPNAME;
        }
        else if(this.state == JsParserState.AWAIT_STRVALUE){
            c = getFilech(r); //read character.
            int l = 0;
            while(c != '\"' && bufp < bsize) {
                if(c == '\\')
                    c = getEscaped(r);
                l++;
                buf[bufp++] = (char)c;
                c = getFilech(r);
            }
            if(bufp >= bsize && c != '\"') {
                err(c, "available space for \'"+(char)c +"'\' or EOL (\") symbol");
                return;
            }

            this.state = JsParserState.READ_STRVALUE;
            this.curVal = new JsonString(new String(buf, 0, l));
            flushBuf();
        }
        else if(this.state == JsParserState.READ_PROPNAME && c != ':')
            err(c,":");
        else if(this.state == JsParserState.READ_PROPNAME){ // c == ':' name value separator was read.
            this.state = JsParserState.COLON;
        }


        else if(this.state == JsParserState.EMPTY_OR_NOT_ARR && c == ']' && ungetch(']') == 0){
            err(']', "available space for ']' but OutOfMemory!");
        }
        else if(this.state == JsParserState.EMPTY_OR_NOT_ARR && c == ']'){ // side effect from previous else if [ungetch() call!]
            this.state = JsParserState.NEXT_VALUE;
        }
        else if(this.state == JsParserState.EMPTY_OR_NOT_ARR && ungetch((char) c) == 0){ // c != ']'
            err(c, "available space for '"+(char)c+"' but OutOfMemory!");
        }
        else if(this.state == JsParserState.EMPTY_OR_NOT_ARR){
            this.state = JsParserState.COLON;
        }

        //FIRST SYMBOL OF VALUE (AFTER SEPARATOR)
        else if(this.state == JsParserState.COLON){
            //System.out.println("Property: "+props.top()+ " symbol \'"+(char)c+"\'");
            switch (c){
                case '\"':{
                    this.state = JsParserState.AWAIT_STRVALUE;
                    if( ungetch((char) c) == 0)
                        err(c, "available space but OutOfMemory!");
                    break;
                }
                case '{':{
                    this.state = JsParserState.START;
                    if(ungetch((char) c) == 0)
                        err(c, "available space but OutOfMemory!");
                    break;
                }
                case '[': {
                    J_ARR.push(new JsonArray());
                    J_ROOTS.push(J_ARR.top());
                    this.state = JsParserState.EMPTY_OR_NOT_ARR;
                    break;
                }
                default: {
                    int l = 0;

                    //CHECK that rvalue is not consists of token symbols ( ']' '}' ',' ':' '[' '{', EOF)
                    while(bufp < bsize && (c != ' ' && c != '\n' && c != '\r' && c != '\t')
                        && (c != '}' && c != ']' && c != ',' && c != ':' && c != '{'  && c != '[' && c != 65535)
                    )
                    { //read all content til first space symbol ' '
                        buf[bufp++] = (char)c;
                        c = getFilech(r);
                        l++;
                    }
                    if(bufp >= bsize && (c != ' ' && c != '\n' && c != '\r' && c != '\t')
                            && (c != '}' && c != ']' && c != ',' && c != ':' && c != '{' && c != '[' && c != 65535)
                    )
                    {
                        err(c, "end of the token (space symbol or LF or CR or tab) or another token ('}' ']' etc.) but \"..."+(char)c+"\"");
                        return;
                    }
                    String v = new String(buf, 0, l);
                    flushBuf();
                    if(v.equals("null"))
                        this.curVal = new JsonNull();
                    else if(v.equals("false"))
                        this.curVal = new JsonBoolean('f');
                    else if(v.equals("true"))
                        this.curVal = new JsonBoolean('t');
                    else {
                       double val = ProcessNumber.parseNumber(v);
                       if(Double.isNaN(val)) // NaN (Not a Number) tokens are invalid.
                           err(c, "a number token but found NaN \""+v+"\"");
                       else if(Math.floor(val) == val)
                           this.curVal = new JsonNumber((long) val);
                       else
                           this.curVal = new JsonRealNumber(val);
                    }
                    this.state = JsParserState.READ_STRVALUE;
                    if(ungetch((char) c) == 0)
                        err(c, "available space but OutOfMemory!");
                    break;
                } // END of default.
            } //END of switch
        }// END COLON state.

        //BEGIN READ_STRVALUE state. (READ_NON_EMPTY_VALUE)
        //Проверить контекст перед завершением строки.
        else if(this.state == JsParserState.READ_STRVALUE){
            cur_obj = J_OBJS.top();
            JsonArray cur_arr = null;
            if(c == ',' && J_ROOTS.top() instanceof JsonArray){
                cur_arr = J_ARR.top();
                cur_arr.getElements().add(this.curVal);// add new item to array
                this.state = JsParserState.COLON; //awaiting new value
                this.curVal = null;
            }
            else if(c == ',' && J_ROOTS.top() instanceof JsonObject){
                cur_obj.getValue().put(props.top(), this.curVal);//add new pair prop : value to the object.
                props.pop();
                this.state = JsParserState.AWAIT_PROPS;//awaiting new property
                this.curVal = null;
            }
            else if(c == ']' && J_ROOTS.top() instanceof JsonArray){ //after processed value follows ']'
                cur_arr = J_ARR.top();
                cur_arr.getElements().add(this.curVal);// add processed value to processed array.
                J_ARR.pop(); // remove processed array.
                J_ROOTS.pop();// and update root.
                this.curVal = null;
                if(J_ROOTS.top() instanceof JsonArray){
                    J_ARR.top().getElements().add(cur_arr); //add array as item
                }
                else if(J_ROOTS.top() instanceof JsonObject){
                    J_OBJS.top().getValue().put(props.top(), cur_arr);// add array as property.
                    props.pop();
                }
                this.state = JsParserState.NEXT_VALUE;
            }
            else if(c == '}'){ //after processed value follows '}'
                cur_obj.getValue().put(props.top(), this.curVal);
                props.pop();
                this.curVal = null;
                if(J_OBJS.size() == 1) // root object finished.
                    this.state = JsParserState.CLOSEROOT; // set final state to exit from cycle.
                else{
                    J_OBJS.pop();//remove processed object.
                    J_ROOTS.pop();//and update root.
                    if(J_ROOTS.top() instanceof JsonArray){
                        J_ARR.top().getElements().add(cur_obj);
                    }
                    else if(J_ROOTS.top() instanceof JsonObject){
                        J_OBJS.top().getValue().put(props.top(), cur_obj);
                        props.pop();
                    }
                    this.state = JsParserState.NEXT_VALUE;
                }
            }
            else
                err(c, "one of ',' '}' ']' ");
        } //END READ_STRVALUE

        //BEGIN NEXT_VALUE state
        else if(this.state == JsParserState.NEXT_VALUE){
            cur_obj = J_OBJS.top();
            if(c == ',' && J_ROOTS.top() instanceof JsonArray){
                this.state = JsParserState.COLON;
            }
            else if(c == ',' && J_ROOTS.top() instanceof JsonObject){
                this.state = JsParserState.AWAIT_PROPS;
            }
            else if(c == ']' && J_ROOTS.top() instanceof JsonArray){
                JsonArray cur_arr = J_ARR.top();
                J_ARR.pop(); // remove processed array.
                J_ROOTS.pop();// and update root.
                if(J_ROOTS.top() instanceof JsonArray){
                    J_ARR.top().getElements().add(cur_arr); //add array as item
                }
                else if(J_ROOTS.top() instanceof JsonObject){
                    J_OBJS.top().getValue().put(props.top(), cur_arr);// add array as property.
                    props.pop();
                }
                this.state = JsParserState.NEXT_VALUE;
            }
            else if(c == '}'){
                cur_obj = J_OBJS.top();
                if(J_OBJS.size() == 1) // root object finished.
                    this.state = JsParserState.CLOSEROOT; // set final state to exit from cycle.
                else{
                    J_OBJS.pop();//remove processed object.
                    J_ROOTS.pop();//and update root.
                    if(J_ROOTS.top() instanceof JsonArray){
                        J_ARR.top().getElements().add(cur_obj);
                    }
                    else if(J_ROOTS.top() instanceof JsonObject){
                        J_OBJS.top().getValue().put(props.top(), cur_obj);
                        props.pop();
                    }
                    this.state = JsParserState.NEXT_VALUE;
                }
            }
            else
                err(c, "one of ',' '}' ']' ");

        } // END NEXT_VALUE
    }

    //Сигнал об ошибке.
    private void err(int act, String msg){
        state = JsParserState.ERR;
        System.out.println("Founded illegal symbol \'" + (char)act + "\'" +
                " at ("+line+":"+col+"). Expected: "+msg);
    }

    private int ungetch(char c){
        if(bufp >= bsize){
            System.out.println("Error ("+line+":"+col+"). ungetch(): too many characters.");
            return 0;
        }
        else{
            buf[bufp++] = c;
            return 1;
        }
    }

    private int getch(InputStreamReader r) throws IOException {
        if(bufp > 0)
            return buf[--bufp];
        else{
            col++;
            int c = r.read();
            if(c == '\n'){
                line++; col = 0;
            }
            else
                col += 1;
            return c;
        }
    }

    //Проверить управляющую последовательность.
    private int getEscaped(InputStreamReader r) throws IOException {
        col++;
        char x = (char)r.read();
        switch (x){
            case 't':{
                col++;
                return '\t';
            }
            case 'r':{
                col++;
                return '\r';
            }
            case 'n':{
                col++;
                return '\n';
            }
            case 'f':{
                col++;
                return '\f';
            }
            case 'b':{
                col++;
                return '\b';
            }
            case '\'':{
                col++;
                return '\'';
            }
            case '\"':{
                col++;
                return '\"';
            }
            case '\\':{
                col++;
                return '\\';
            }
            case 'u':{
                col++;
                int i = 0;
                char[] hcode = new char[4];
                while(i < 4 && ( ((x = (char) r.read()) >= '0' && x <='9') || (x >= 'A' && x <= 'F') || (x >= 'a' && x <= 'f') )){
                    hcode[i] = x;
                    i++;
                }
                if(i < 4) {
                    err(x, "Unicode token \\uxxxx where x one of [0-9] or [A-Fa-f]");
                    return 0;
                }
                int code = (int)ProcessNumber.parse(new String(hcode),null,'N',16,1, 1); //just parse positive hex number to decimal.
                return code;
            }
            default:{
                ungetch(x);
                return '\\';
            }
        }
    }

    //Прочитать символ напрямую
    private int getFilech(InputStreamReader r) throws IOException {
        int c = r.read();
        if(c == '\n'){ //увелчичиваем счётчик строк.
            line += 1;
            col = 0;
        }
        else{
            col += 1;
        }
        return c;
    }

Что касается вспомогательных методов. Методы getch и ungetch(c) извлекают и сохраняют символ с в буфер. Метод getFilech читает символ напрямую с файла. А метод getEscaped проверяет, была ли прочитана управляющая последовательность символов (\n, \r, \t) и возвращает соответствующий ей символ. Метод err используется для сигнала парсеру об ошибке. Обработкой чисел занят ProcessNumber, описанный здесь.

В iterate заложена основная работа парсера. Прочитав символ (с буфера или с файла если буфер пуст), начинаем с состояния START. Убедившись, что мы прочитали открывающуюся фигурную скобку, сохраняем новый объект в стэке объектов и ждём либо начало нового имени свойства (т.к. имена строки, то ждем первые двойные кавычки), либо закрытую фигурную скобку, которая закрывает объект. Переходим в состояние AWAIT_PROPS_OR_END_OF_OBJ. Если это скобка, то переходим в NEXT_VALUE, где проверяем контекст вложенности. Иначе переходим в AWAIT_PROPS, читая имя свойства, до тех пор пока не встретим двойные кавычки. Прочитанные символы запоминаем в буфер. Если превысили размер - ошибка - завершаем работу. Прочитав двойные кавычки, извлекаем строку из буфера (очищая его), запоминаем новое имя свойства и переходим в READ_PROPNAME. В состоянии READ_PROPNAME проверяем, что имя и значение отделяется двоеточием ":", если нет то ошибка. Проверив, идём в COLON, где определяем, чем является значение - массивом, объектом, строкой, числом, булевым литералом или null. Если это строка, то идём в AWAIT_STRVALUE и читаем строку, аналогично как в AWAIT_PROPS. Прочитав строку, запоминаем её и переходим в READ_STRVALUE, где проверяем контекст вложенности. Аналогично, распарсив число, булев-литерал, null, переходим туда же (в READ_STRVALUE). В READ_STRVALUE при проверке контекста, проверяем следующий символ. Если это запятая, то проверяем стэк текущих вершин J_ROOTS, и добавляем новый элемент либо в массив из J_ARRS, либо в объект из J_OBJS (на вершине). Если это скобка, которая закрывает массив или объект, то извлекаем из соответствующих стэков запись, проверяем J_ROOTS, и добавляем элемент к соответствующему родителю. Как только мы прочитаем последнюю фигурную скобку ("}") переходим в состояние CLOSEROOT, в результате следующей проверки перед итерацией цикл завершается. При запятой, в зависимости от контекста, мы идём либо в AWAIT_PROPS (объект), либо в COLON (массив). либо в NEXT_VALUE (при закрытии вложенного объекта/массива).

Итоги

Парсер использует явно 4 стэка, вместо рекурсивных вызовов. Кроме этого, он использует буфер для сохранения строк, а также ссылку на текущий распознанный элемент. Парсер игнорирует пробельные символы. Парсер может обрабатывать некоторые управляющие последовательности символов.

Разумеется, API веб-сервисов возвращают данные в виде массивов. Поскольку данный парсер ожидает в качестве корневого элемента объект, а не массив, то для обхода данной проблемы, необходимо либо обернуть массив в объект, либо добавить новые состояния в парсер.

Комментарии (17)


  1. mihmig
    05.04.2022 23:02

    Неплохо было бы добавить в статью сравнительные бенчмарки.


    1. dopusteam
      06.04.2022 07:35

      Без реализации всего функционала тут сравнивать нечего, имхо. Да и написать json парсер с хорошей производительностью - та ещё задача, тут нет смысла сравнивать даже


  1. superconductor
    05.04.2022 23:15
    +3

    return "null";

    Действительно, почему бы вместо чего-то подобного null'у не вернуть строку "null", ведь давно известно, что из-за этого не случится ничего плохого (нет, https://youtu.be/_c1am8NSx_s)

    Это было бы валидно для toString (если toString'ом предлагается этот объект сериализовывать обратно в json, но тогда у всех остальных типов toString вернёт не валидный json), но для getValue это прямо совсем не подходящий вариант.

    Поскольку данный парсер ожидает в качестве корневого элемента объект, а не массив

    Валидный json может быть не только объектом или массивом, но и любым примитивным типом.


    1. OldNileCrocodile Автор
      06.04.2022 09:21

      Да, тут взято подмножество валидного json. В RFC указано, что помимо объекта, валидным может быть любое значение (массив, строка, число, true, false, null). да и в статье написано, что API-возвращают json-массивы и парсер этого не умеет.


  1. ren-san
    06.04.2022 08:39
    +2

    Вообще есть много вопросов, начиная с банальных, например, зачем считать в JsonArray count отдельно, хотя можно взять от листа, причём там судя по всему ошибка в setElements, полагаю, надо бы и счётчик обновить.

    Заканчивая тем, насколько это вообще будет работать. Если цель была в том, чтобы потренироваться в написании статей — тогда понятно, тренируйтесь дальше. А так сыро, трудночитаемо, и не очень полезно


  1. Almazko
    06.04.2022 08:40

    Тоже писал мини-парсер для своих нужд, но только он получился еще компактнее:
    https://github.com/AlmazKo/microjson
    Использую его, когда нужна работа с json без биндинга. В больших проектах, конечно лучше брать что-то типа Jackson'a


    1. OldNileCrocodile Автор
      06.04.2022 12:46
      +2

      Ну, во-первых, у вас в методе экземпляра класса Json parseValue() Вы не проверяете литералы true, false, null, а сразу перематываете на нужное число символов, полагаясь на первую букву, хотя это может быть другим словом, на букву t, например, "topa", что сразу не соответствует JSON. Во-вторых, в методе parseObject не учитывается грамматически-правильная последовательность, в том смысле, что после чтения ключа (имени свойства) в строке может идти '}', что нарушает последовательность (ожидался символ ':'). Т.е. по строке "{ "dummy" }" вернётся пустой JSON-объект, а должно быть сообщение об ошибке "ожидался ':' а получили сразу '}'". (указали в описании репы fail-fast parser). Ссылка


      1. Almazko
        06.04.2022 13:39
        +1

        Спасибо за фидбек, уже забыл про эти моменты, поправлю! Добавлю в тесты.
        Надо соответствовать описанию :)


  1. sYB-Tyumen
    06.04.2022 08:58
    +1

    Под заголовком "Состояния парсера" было бы хорошо разместить схему (конечный автомат?) состояний парсера. Я, например, заглянул за общим принципом реализации, а не за деталями в коде, поэтому слегка разочарован.
    P.S. И вообще, реализация в коде объектов - типов данных просится под кат.


    1. OldNileCrocodile Автор
      07.04.2022 10:02
      +1

      Добавил схему состояний парсера.


  1. yroman
    06.04.2022 16:07

    Сразу видно - человеку нечем заняться. Мне бы столько свободного времени. Надеюсь, этот велосипед не попадёт ни в какой крупный проект, иначе команде не позавидуешь.

    PS: Автотесты-то хоть есть? Или совсем всё плохо?


  1. Felan
    06.04.2022 21:37

    А разве не надо для массивов тоже использовать дженерики в этом случае? А то получается что в конечном итоге, получив из массива объект, нужно будет кастить его тип?

    Т.е.

    public class JsonArray extends JsonElement<List<JsonElement>>

    нужно заменить на что-то типа

    public class JsonArray<TT> extends JsonElement<List<JsonElement<TT>>>

    Или нет?


    1. OldNileCrocodile Автор
      07.04.2022 09:51

      В Вашем случае всё равно придётся кастить в тип, поскольку JsonObject по строковому ключу извлекает значение в виде JsonElement. Да и при обработке тоже. В стэке ведь могут быть массивы содержащие различные подтипы JsonElement.


      1. Felan
        07.04.2022 21:02

        Че то мне лень вникать, но почему объекты из массива по строковому ключу извлекаются?

        А то, что в массиве могут содержаться объекты разных типов это по моему вообще нарушение сути понятия "массив".

        Зачем вообще тогда тут городить огород с дженериками если конечному пользователю все равно кастить? Да еще и так опасно.


        1. OldNileCrocodile Автор
          08.04.2022 09:23

          Да, понятие "массив" неккоректно здесь использовать. Правильное понятие - "список". Но само понятие тянется из того же RFC 8259 (везде указано "array").


        1. OldNileCrocodile Автор
          08.04.2022 09:25

          По строкову ключу извлекается значение из объекта. Значение уже может быть массивом, или тем же объектом по типу.


        1. OldNileCrocodile Автор
          08.04.2022 09:27

          А дженерик здесь для getValue() из JsonElement. Но Java не C#. В рантайме у нас всё стирается, и остаётся Object. Поэтому нельзя писать T a = new T(). А в C# можно, при условии, что параметр тип T имеет конструктор. Указывается через ограничение where T : new()