摘要:写在前面由于我对写解析器只有阅读了几篇文章的知识量,因此水平并不是很高,此文权当一次个人总结,无法保证所涉及的知识点思路完全无误,如有错误,还请各位大佬指正。除此之外的状态都是不合法的,这也就是有时候解析类的包比如会看到的错误的情况。
写在前面
由于我对写解析器只有 阅读了几篇文章 的知识量,因此水平并不是很高,此文权当一次个人总结,无法保证所涉及的知识点、思路完全无误,如有错误,还请各位大佬指正。
从一个正整数表达式开始这篇文章围绕的仅仅是一个 正整数表达式,而且它很简单,不会出现括号嵌套等情况,我们的目标只是把
10 * 5 + 1
解析为一个 Token 序列,如下:
[ { type: NUMBER, value: `10` }, { type: OPERATOR, value: `*` }, { type: NUMBER, value: `5` }, { type: OPERATOR, value: `+` }, { type: NUMBER, value: `1` } ]
我习惯从简单的开始,那么我们先从一个最简单的、只有个位数、没有空格的式子开始:
1+1最简单的思路
其实词法分析器要做的事本质上很简单:对输入的字符串进行遍历,分割成有意义的 Token。
因此,最简单的思路就是一个 for 循环:
String expression = "1+1"; for (char ch : expression.toCharArray()) { // 在这里进行处理 }
所以我们定义一个 Scanner,为了后续方便,顺手实现个简单的单例吧:
public class Scanner { private static volatile Scanner instance; public static Scanner getInstance() { if (Scanner.instance == null) { synchronized ( Scanner.class ) { if (Scanner.instance == null) { Scanner.instance = new Scanner(); } } } return Scanner.instance; } private String expression; public Scanner from(String expression) { this.expression = expression; return this; } public void process() { for (char ch : expression.toCharArray()) { // 在这里进行处理 } } public static void main(String ... args) { Scanner scanner = Scanner.getInstance().from("1+1"); scanner.process(); } }定义 Token 类型
在当前的 1+1 表达式中,涉及到的 Token 不多,只有数字、操作符,因此用一个枚举类即可表述:
public enum Type { INIT, NUMBER, OPERATOR, UNKNOWN; public static Type of(char ch) { if ("0" <= ch && ch <= "9") { return NUMBER; } if ("+-*/".indexOf(ch) != -1) { return OPERATOR; } return UNKNOWN; } }
同时该枚举类承担辨识字符类型的工作:
Type.of("1") // NUMBER Type.of("+") // OPERATOR Type.of("a") // UNKNOWN定义 Token
public class Token { // 一个 Token 的类型一旦确定,就不可能再改变。 private final Type type; // 用以存储 Token 的值。 private final StringBuffer value; public Token(Type type) { this.type = type; this.value = new StringBuffer(); } public void appendValue(char ch) { this.value.append(ch); } public String getValue() { return this.value.toString(); } public Type getType() { return this.type; } @Override public String toString() { return String.format("{type: %s, value: `%s`}", this.getType().name(), this.getValue()); } }处理 1+1
public class Scanner { // 省略... public void process() { for (char ch : expression.toCharArray()) { Type type = Type.of(ch); if (Type.UNKNOWN.equals(type)) { throw new RuntimeException(String.format("`%c` 并不属于期望的字符类型", ch)); } Token token = new Token(type); token.appendValue(ch); System.out.println(token); } } public static void main(String ... args) { Scanner scanner = new Scanner("1+1"); scanner.process(); } } /** 输出 * {type: NUMBER, value: `1`} * {type: OPERATOR, value: `+`} * {type: NUMBER, value: `1`} */现在来加点难度: 10+1
现在一个数字可能不止一位了,那么我们该怎么办呢?
使用状态图:
┌-[ 0-9 ]-┐ ┌-[ +|-|*|/ ]-┐ ┌-[ 0-9 ]-┐ ---( NUMBER )--- ( OPERATOR )---( NUMBER )---
具体的理论这里就不赘述了,有兴趣可以自行查阅相关资料,这里简单说一下怎么用:
现在我们来列个表,看一下对于 10+1,在状态上有什么变化:
字符 | 状态 | Token |
---|---|---|
NULL | INIT | NULL |
1 | NUMBER | {id: 0, type: NUMBER, value: 1} |
0 | NUMBER | {id: 0, type: NUMBER, value: 10} |
+ | OPERATOR | {id: 1, type: OPERATOR, value: +} |
1 | NUMBER | {id: 2, type: NUMBER, value: 1} |
可以看到,在读到字符 1 和 0 时,状态没有发生变化,也就是说它们是一个整体(或是一个整体的一部分)。
如果在 0 后面还有其他数字,那么直到引起状态改变的字符出现之前,这些字符就组成了整个 Token。
同时,我们还发现引入状态图后,有个有意思的事:
从 初始状态 INIT 开始,我们只允许后边是 NUMBER 类型;
NUMBER 后边允许 NUMBER、OPERATOR 类型;
OPERATOR 后边允许 NUMBER 类型。
除此之外的状态都是不合法的,这也就是有时候解析类的包(比如 fast-json)会看到的 Invalid Character 错误的情况。
所以我们需要改改代码,同时为 Scanner 添加一个更新状态的方法:
public class Scanner { // 省略 private Token token; public void setToken(Token token) { this.token = token; } public void process() { // 初始化 this.setToken(new Token(Type.INIT)); for (char ch : expression.toCharArray()) { Type type = Type.of(ch); if (Type.UNKNOWN.equals(type)) { throw new RuntimeException(String.format("`%c` 并不属于期望的字符类型", ch)); } // 根据当前 Token 的类型,选择不同的判断分支 switch (token.getType()) { case INIT: switch (type) { // 当前是初始状态,遇到了数字, 切换状态。 case NUMBER: this.setToken(new Token(Type.NUMBER)); this.token.appendValue(ch); break; default: throw new RuntimeException(String .format("Invalid Character: `%c`", ch)); } break; case NUMBER: switch (type) { // 当前是数字状态,遇到了数字,追加字符。 case NUMBER: this.token.appendValue(ch); break; // 当前是数字状态,遇到了操作符,切换状态。 case OPERATOR: this.setToken(new Token(Type.OPERATOR)); this.token.appendValue(ch); break; default: throw new RuntimeException(String .format("Invalid Character: `%c`", ch)); } break; case OPERATOR: switch (type) { // 当前是操作符状态,遇到了数字,切换状态。 case NUMBER: this.setToken(new Token(Type.NUMBER)); this.token.appendValue(ch); break; default: throw new RuntimeException(String .format("Invalid Character: `%c`", ch)); } break; } System.out.println(token); } } }
/** 输出 * {type: NUMBER, value: `1`} * {type: NUMBER, value: `10`} * {type: OPERATOR, value: `+`} * {type: NUMBER, value: `1`} */试着简化一下
我们刚才用了一个巨大无比的 switch 结构来描述状态图,现在我们由内而外试着简化这个巨无霸。
先从内部开始:switch (type) { // 当前是 ** 状态,遇到了 ** , 执行 ** 操作。 case NUMBER: // ... break; case ... default: throw new RuntimeException(String.format("Invalid Character: `%c`", ch)); }
其实稍微归纳总结一下就能发现, 执行 ** 操作 这部分,总的来说只有两种:
NewToken 对应着 { token = new Token(Type.NUMBER); token.appendValue(ch); } AppendValue 对应着 { token.appendValue(ch); }
现在我们再引入一个工具来帮助我们简化:表驱动。
其实从上面的对应关系不难发现,我们可以用 HashMap 来简单模拟一个表,帮助我们减少工作。
在此之前,我们需要把上述关系中的 {操作} 部分用一个接口来解耦:
public interface Behavior { void apply(Token token, Type type, char ch); }
然后我们来定义一个枚举类 Behaviors 来表示操作类型:
public enum Behaviors { NewToken, AppendValue; private static final Scanner scanner; private static final HashMapbehaviorMap; static { scanner = Scanner.getInstance(); behaviorMap = new HashMap<>(); behaviorMap.put(Behaviors.NewToken, (token, type, ch) -> { token = new Token(type); token.appendValue(ch); scanner.setToken(token); }); behaviorMap.put(Behaviors.AppendValue, (token, type, ch) -> { token.appendValue(ch); }); } public void apply(Token token, Type type, char ch) { behaviorMap.get(this) .apply(token, type, ch); } }
那么现在 执行 操作 这部分,现在可以用 HashMap 来表述了:
// 根据当前 Token 的类型,选择不同的判断分支 switch (token.getType()) { case INIT: HashMapbehaviorsMap = new HashMap<>(); // 当前是初始状态,遇到了数字, 切换状态。 behaviorsMap.put(Type.NUMBER, Behaviors.NewToken); break; case NUMBER: HashMap behaviorsMap = new HashMap<>(); // 当前是数字状态,遇到了数字,追加字符。 behaviorsMap.put(Type.NUMBER, Behaviors.AppendValue); // 当前是数字状态,遇到了操作符,切换状态。 behaviorsMap.put(Type.Operator, Behaviors.NewToken); break; case OPERATOR: HashMap behaviorsMap = new HashMap<>(); // 当前是操作符状态,遇到了数字,切换状态。 behaviorsMap.put(Type.NUMBER, Behaviors.NewToken); break; }
既然是 Java ,那么让我们来让这部分看起来 OO 一些:
public class BehaviorMap { private final HashMapmap; public BehaviorMap() { this.map = new HashMap(); } public BehaviorMap at(Type type, Behaviors behaviors) { this.map.put(type, behaviors); return this; } public BehaviorsTable done() { return BehaviorsTable.getInstance(); } }
现在再来看看:
// 根据当前 Token 的类型,选择不同的判断分支 switch (token.getType()) { case INIT: BehaviorMap map = new BehaviorMap(); map // 当前是初始状态,遇到了数字, 切换状态。 .at(Type.NUMBER, Behaviors.NewToken); break; case NUMBER: BehaviorMap map = new BehaviorMap(); map // 当前是数字状态,遇到了数字,追加字符。 .at(Type.NUMBER, Behaviors.AppendValue); // 当前是数字状态,遇到了操作符,切换状态。 .at(Type.Operator, Behaviors.NewToken); break; case OPERATOR: BehaviorMap map = new BehaviorMap(); map // 当前是操作符状态,遇到了数字,切换状态。 .at(Type.NUMBER, Behaviors.NewToken); break; }简化外部
现在我们可以看到表驱动对于消除判断分支的威力了,那么我们可以用同样的方法将外部 switch 也消除掉:
public class BehaviorsTable { private static volatile BehaviorsTable instance; public static BehaviorsTable getInstance() { if (BehaviorsTable.instance == null) { synchronized ( BehaviorsTable.class ) { if (BehaviorsTable.instance == null) { BehaviorsTable.instance = new BehaviorsTable(); } } } return BehaviorsTable.instance; } private final HashMapmap; public BehaviorsTable () { this.map = new HashMap<>(); } public BehaviorMap register(Type type) { BehaviorMap behaviorMap = new BehaviorMap(); this.map.put(type, behaviorMap); return behaviorMap; } }
现在整个巨大的 switch 结构我们就可以简化为:
BehaviorsTable .getInstance() .register(Type.INIT) .at(Type.NUMBER, Behaviors.NewToken) .done() .register(Type.NUMBER) .at(Type.NUMBER, Behaviors.AppendValue) .at(Type.OPERATOR, Behaviors.NewToken) .done() .register(Type.OPERATOR) .at(Type.NUMBER, Behaviors.NewToken) .done();
现在 process 方法我们就可以简化为:
public void process() { this.setToken(new Token(Type.INIT)); for (char ch : expression.toCharArray()) { Type type = Type.of(ch); if (Type.UNKNOWN.equals(type)) { throw new RuntimeException(String.format("`%c` 并不属于期望的字符类型", ch)); } BehaviorsTable .getInstance() // 根据当前 Token 类型获取对应的处理对策 .get(this.token.getType()) // 获取当前字符所属的处理行为 .is(type) .apply(type, ch); System.out.println(token); } }
我们在源码里做了一些改动,请参考文章底部全部代码。让我们测试一下
public static void main(String ... args) { Scanner scanner = Scanner.getInstance(); scanner.from("10+1").process(); /** * {type: NUMBER, value: `1`} * {type: NUMBER, value: `10`} * {type: NUMBER, value: `+`} * {type: NUMBER, value: `1`} */ scanner.from("10 +1").process(); /** * {type: NUMBER, value: `1`} * {type: NUMBER, value: `10`} * Exception in thread "main" java.lang.RuntimeException: ` ` 并不属于期望的字符类型 */ scanner.from("10++1").process(); /** * {type: NUMBER, value: `1`} * {type: NUMBER, value: `10`} * {type: OPERATOR, value: `+`} * Exception in thread "main" java.lang.RuntimeException: Invalid Character: `+` for Token `OPERATOR` */ }
现在看起来一切正常,但是别忘了 Scanner 的工作是将输入的字符串分割为 Token 序列,因此我们需要让 process 方法返回处理后的 LinkedList
为此我们需要将每次新生成的 Token 保存下来:
public class Scanner { private LinkedListtokens; private Token token; public void addToken(Token token) { this.token = token; this.tokens.add(token); } public LinkedList process() { this.setToken(new Token(Type.INIT)); for (char ch : expression.toCharArray()) { Type type = Type.of(ch); if (Type.UNKNOWN.equals(type)) { throw new RuntimeException(String.format("`%c` 并不属于期望的字符类型", ch)); } BehaviorsTable .getInstance() .get(this.token.getType()) .is(type) .apply(type, ch); } return this.tokens; } public static void main(String ... args) { Scanner scanner = Scanner.getInstance().from("10*5+1"); LinkedList tokens = scanner.process(); for (Token token : tokens) { System.out.println(token); } } }
记得将 Behaviors 初始化部分中 NewToken 里的行为修改一下:
behaviorMap.put(Behaviors.NewToken, (token, type, ch) -> { token = new Token(type); token.appendValue(ch); //scanner.setToken(token); scanner.addToken(token); });
现在再看看结果:
{type: NUMBER, value: `10`} {type: OPERATOR, value: `*`} {type: NUMBER, value: `5`} {type: OPERATOR, value: `+`} {type: NUMBER, value: `1`}
看起来一切都如我们所愿!现在离最初的目标只剩下空格的处理了,得益于我们抽象了行为 Behaviors,我们只需要在 Type 中注册空格,然后为 BehaviorsTable 注册各种类型下对空格的处理就行了:
public enum Type { SPACE; public Tpye of(char ch) { if (" " == ch) return SPACE; } } public enum Behaviors { Continue; static { behaviorMap .put(Behaviors.Continue, (token, type, ch) -> { // 留空就行了 }) } } public class Scanner { static { BehaviorsTable .getInstance() .register(Type.INIT) .at(Type.NUMBER, Behaviors.NewToken) .at(Type.SPACE, Behaviors.Continue) .done() .register(Type.NUMBER) .at(Type.NUMBER, Behaviors.AppendValue) .at(Type.OPERATOR, Behaviors.NewToken) .at(Type.SPACE, Behaviors.Continue) .done() .register(Type.OPERATOR) .at(Type.NUMBER, Behaviors.NewToken) .at(Type.SPACE, Behaviors.Continue) .done(); } }还缺什么呢
我们现在完成的扫描器实际上无法识别出 1 1 + 0 是个错误的表达式,它会解析出如下序列:
{type: NUMBER, value: 11} {type: OPERATOR, value: +} {type: NUMBER, value: 0}
我个人希望这部分工作往上层放,由消化 Token 序列的调用者通过模式匹配的方式去验证,不过这样的话,Type.SPACE 的处理就不能随意 Continue 了,有兴趣的话看官可以自行尝试一下 : P
全部代码一个小尝试,就不传 Github 了,直接放这儿吧 (其实就是懒...
Scanner.javapublic class Scanner { private static volatile Scanner instance; public static Scanner getInstance() { if (Scanner.instance == null) { synchronized ( Scanner.class ) { if (Scanner.instance == null) { Scanner.instance = new Scanner(); } } } return Scanner.instance; } static { // 注册行为表 BehaviorsTable .getInstance() // 注册 INIT 状态的行为表 .register(Type.INIT) .at(Type.NUMBER, Behaviors.NewToken) .at(Type.SPACE, Behaviors.Continue) .done() .register(Type.NUMBER) .at(Type.NUMBER, Behaviors.AppendValue) .at(Type.OPERATOR, Behaviors.NewToken) .at(Type.SPACE, Behaviors.Continue) .done() .register(Type.OPERATOR) .at(Type.NUMBER, Behaviors.NewToken) .at(Type.SPACE, Behaviors.Continue) .done(); } private String expression; private LinkedListType.javatokens; private Token token; public Scanner from(String expression) { this.expression = expression; this.tokens = new LinkedList<>(); return this; } public void setToken(Token token) { this.token = token; } public Token getToken ( ) { return token; } public LinkedList process() { this.setToken(new Token(Type.INIT)); for (char ch : expression.toCharArray()) { Type type = Type.of(ch); if (Type.UNKNOWN.equals(type)) { throw new RuntimeException(String.format("`%c` 并不属于期望的字符类型", ch)); } BehaviorsTable .getInstance() // 获取当前 Token 类型所适用的行为表 .get(this.token.getType()) // 获取当前字符所适用的行为 .is(type) .apply(type, ch); } return this.tokens; } public void addToken(Token token) { // 更新一下当前 Token this.token = token; this.tokens.add(token); } public static void main(String ... args) { Scanner scanner = Scanner.getInstance().from("10 * 5+1"); LinkedList tokens = scanner.process(); for (Token token : tokens) { System.out.println(token); } } }
// Token 类型枚举 public enum Type { INIT, // 初始化时使用 SPACE, // 空格 NUMBER, // 数字 OPERATOR, // 操作符 UNKNOWN; // 未知类型 public static Type of(char ch) { if (" " == ch) { return SPACE; } if ("0" <= ch && ch <= "9") { return NUMBER; } if ("+-*/".indexOf(ch) != -1) { return OPERATOR; } return UNKNOWN; } }Token.java
public class Token { // 一个 Token 的类型一旦确定,就不可能再改变。 private final Type type; // 用以存储 Token 的值。 private final StringBuffer value; public Token(Type type) { this.type = type; this.value = new StringBuffer(); } // 向 value 中追加字符 public void appendValue(char ch) { this.value.append(ch); } public String getValue() { return this.value.toString(); } public Type getType() { return this.type; } @Override public String toString() { return String.format("{type: %s, value: `%s`}", this.getType().name(), this.getValue()); } }Behavior.java
public interface Behavior { /** * 将行为抽象出来 * @param token 当前的 token * @param type 读入字符的类型 * @param ch 读入的字符 */ void apply(Token token, Type type, char ch); }Behaviors.java
// 预设行为 public enum Behaviors { NewToken, // 新建一个指定类型的 Token, 将当前字符保存到新 Token AppendValue, // 将当前字符追加到当前 Token 的值中 Continue, // 跳过当前字符 InvalidCharacter; // 当前 Token 类型所不期望的字符类型,会抛出一个异常 // 持有一个引用就不用老是调用 getInstance()。 private static final Scanner scanner; // 为预设行为指定行为内容 private static final HashMapBehaviorsMap.javabehaviorMap; static { scanner = Scanner.getInstance(); behaviorMap = new HashMap<>(); // 指定 NewToken,行为逻辑参见枚举值说明 behaviorMap.put(Behaviors.NewToken, (token, type, ch) -> { token = new Token(type); token.appendValue(ch); scanner.addToken(token); }); // 指定 AppendValue,行为逻辑参见枚举值说明 behaviorMap.put(Behaviors.AppendValue, (token, type, ch) -> { token.appendValue(ch); }); // 指定 Continue,行为逻辑参见枚举值说明 behaviorMap.put(Behaviors.Continue, (token, type, ch) -> {}); // 指定 InvalidCharacter,行为逻辑参见枚举值说明 behaviorMap.put(Behaviors.InvalidCharacter, (token, type, ch) -> { throw new RuntimeException(String .format("Invalid Character: `%c` for Token `%s`", ch, token.getType().name())); }); } public void apply(Type type, char ch) { // 获取预设行为 behaviorMap.get(this) // 向行为中传递当前 Token, 当前字符类型,当前字符 .apply(scanner.getToken(), type, ch); } }
// 保存某一字符类需要执行何种预设行为的映射关系 public class BehaviorsMap { private final HashMapBehaviorsTable.javamap; public BehaviorsMap() { this.map = new HashMap(); } /** * 注册指定类型所需的预设行为 * @param type 指定类型 * @param behaviors 指定所需的预设行为 */ public BehaviorsMap at(Type type, Behaviors behaviors) { this.map.put(type, behaviors); return this; } // 注册完后回退操作域到 BehaviorsTable public BehaviorsTable done() { return BehaviorsTable.getInstance(); } // 获取指定类型的预设行为 public Behaviors is (Type type) { Behaviors behaviors = this.map.get(type); if (behaviors == null) { // 如果没有注册,那么使用 InvalidCharacter 预设行为,因为出现了非预期的字符类型 behaviors = Behaviors.InvalidCharacter; } return behaviors; } }
// 行为表 public class BehaviorsTable { private static volatile BehaviorsTable instance; public static BehaviorsTable getInstance() { if (BehaviorsTable.instance == null) { synchronized ( BehaviorsTable.class ) { if (BehaviorsTable.instance == null) { BehaviorsTable.instance = new BehaviorsTable(); } } } return BehaviorsTable.instance; } private final HashMapmap; public BehaviorsTable () { this.map = new HashMap<>(); } // 注册指定当前类型,返回一个空的 BehaviorsMap 来注册预设行为 public BehaviorsMap register(Type type) { BehaviorsMap behaviorsMap = new BehaviorsMap(); this.map.put(type, behaviorsMap); return behaviorsMap; } // 获取指定当前类型的 BehaviorsMap public BehaviorsMap get(Type type) { return this.map.get(type); } }
文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。
转载请注明本文地址:https://www.ucloud.cn/yun/70961.html
摘要:验证过程验证过程的目的是为了确保文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。二虚拟机字节码执行引擎虚拟机的执行引擎自行实现,可以自行制定指令集与执行引擎的结构体系。 本篇博客主要针对Java虚拟机的类加载机制,虚拟机字节码执行引擎,早期编译优化进行总结,其余部分总结请点击Java虚拟总结上篇 。 一.虚拟机类加载机制 概述 虚拟机把描述类的数据从Clas...
摘要:深入之继承的多种方式和优缺点深入系列第十五篇,讲解各种继承方式和优缺点。对于解释型语言例如来说,通过词法分析语法分析语法树,就可以开始解释执行了。 JavaScript深入之继承的多种方式和优缺点 JavaScript深入系列第十五篇,讲解JavaScript各种继承方式和优缺点。 写在前面 本文讲解JavaScript各种继承方式和优缺点。 但是注意: 这篇文章更像是笔记,哎,再让我...
摘要:作用域分类作用域共有两种主要的工作模型。换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套。词法作用域词法作用域中,又可分为全局作用域,函数作用域和块级作用域。 一篇巩固基础的文章,也可能是一系列的文章,梳理知识的遗漏点,同时也探究很多理所当然的事情背后的原理。 为什么探究基础?因为你不去面试你就不知道基础有多重要,或者是说当你的工作经历没有亮点的时候,基础就是检验你好坏的一项...
摘要:一言以蔽之,闭包,你就得掌握。当函数记住并访问所在的词法作用域,闭包就产生了。所以闭包才会得以实现。从技术上讲,这就是闭包。执行后,他的内部作用域并不会消失,函数依然保持有作用域的闭包。 网上总结闭包的文章已经烂大街了,不敢说笔者这篇文章多么多么xxx,只是个人理解总结。各位看官瞅瞅就好,大神还希望多多指正。此篇文章总结与《JavaScript忍者秘籍》 《你不知道的JavaScri...
摘要:词法分析对构成源程序的字符流进行扫描然后根据构词规则识别单词也称单词符号或符号。语义分析是编译过程的一个逻辑阶段语义分析的任务是对结构上正确的源程序进行上下文有关性质的审查进行类型审查,审查抽象语法树是否符合该编程语言的规则。 1. 文章的内容和主题 我对编译器的深入了解起源于一条推特中的问题:Angular是如何用Angular预先编译器(AOT)对静态代码进行解析工作的。在进行一些...
阅读 636·2021-11-23 09:51
阅读 3582·2021-11-15 11:38
阅读 903·2021-10-14 09:42
阅读 3114·2021-09-29 09:35
阅读 2073·2021-09-03 10:33
阅读 749·2021-07-30 16:33
阅读 1542·2019-08-30 15:55
阅读 1827·2019-08-30 14:04