补充SQL。第1部分。解析的复杂性。有关完成ANTLR文件的故事

我在Habr上发表了原始文章,其翻译发布在Codingsight博客上

本文将发生什么?


我在该公司工作了五年多,该公司一直在开发用于处理数据库 IDE产品线。开始撰写本文时,我无法想象我会记得多少有趣的故事,因为完成后我得到了30多页的文字。经过一番思考,我按主题将故事分组,然后将文章分成几部分。

在发布时,我将添加指向以下部分的链接:

第1部分。解析的复杂性。关于用文件完成ANTLR的故事的
第2部分。优化使用字符串和打开文件的
第3部分。Visual Studio扩展的生命周期。使用IO。异常使用SQL
第4部分。处理异常,数据对开发过程的影响。使用ML.NET

工作中发生了很多有趣的事情:我们发现了.NET中的多个错误,多次优化了某些功能,有的仅按百分比进行了优化,第一次做的很酷,而即使经过几次,我们也没有成功尝试。我的团队正在开发和支持IDE语言功能,主要功能是代码完成。因此,该系列文章的名称。在他们的每个部分中,我都会讲几个故事:一些关于成功,一些关于失败。

在这一部分中,我将重点介绍解析SQL的问题,与这些问题的斗争以及在这场斗争中所犯的错误。



对于只对部分内容感兴趣并且只是为了轻松导航的人,本文的内容:

内容




我是谁?


我6月份去工作时,经验很少。和许多人一样,我之所以开始编程是因为我想做玩具。几个甚至相当成功地完成了。我什至在这里写了一个人的发展。顺便说一下,最近,他在另一台服务器上复活了她。在准备阶段的不同阶段,仍然有十几种游戏制作或放弃。除了游戏之外,在从事这项工作之前,我还设法完成了一些自由职业者的项目。其中最大的是社交媒体应用程序,该应用程序是一个足球门户网站,具有锦标赛表,球员数据以及通过SMS通知用户最终得分或进球的功能。这是差不多十年前完成的。那时,并不是每个人都使用智能手机,过去谁经常不使用互联网或使用三诅咒的EDGE来打开文本网站的可能性并不总是很高。所以这个主意对我来说似乎很好。

不知何故,除了游戏之外,我还被吸引去为自己或其他开发人员创建不同的调音。有时候,他接近我上班后要做的事情。例如,我研究Win32 API时所做的一个项目是在Rich Edit Control中突出显示XML代码的程序。此外,还可以将背光代码上传到当时在不同论坛上流行的BMP,HTML或BB代码。

另一个被证实与我在工作中碰巧接近的项目是一个程序,该程序分析C / C ++代码并从中构建框图。流程图完全符合大学一名老师的要求。它在额头上笨拙地制作,对句法分析理论的知识为零,并且专门针对我my脚的性格工作。几年后,在清理旧垃圾计算机时,我偶然发现了它并且无法将其删除,因此出于历史原因,我将其发布在github上。

这些实验,再加上自由职业者,给了很好的经验,使找到工作成为可能。随着时间的流逝,经过数十次的眼泪和眼泪检查,我什至开始使公司和产品受益。回过头来,我很高兴地了解到由于几次事故,我开始完全按照自己的意愿去做。

有什么困难?


需要使用此模块来使读者沉浸在我们实际正在做的事情中。



桌面开发


也许这还不算复杂,因为它已经是一个非常成熟的领域,没有太多的未知,库是稳定的,并且是最佳实践。我们项目的这个功能就在这里,因为我和许多其他程序员一样,很容易出现新颖性,现在新颖性都在网上流传了。有时候,在下雨天,我爬上覆盖有毯子和可可杯的窗台,思考一下Redis,反应,高负载和分布式系统,这些系统现在正在没有我的地方开发。这个问题的另一面是,要向熟悉的啤酒开发人员描述项目的复杂性并不容易,并且当您根据根本不同的法律操作应用程序时,这将变得更加困难。


解析SQL和方言


在这项工作之前,我还为不同的语言编写了小型解析器。有一段时间我教过.NET课程。对于某些组,作为附加任务,作为“字符串”主题的一部分,他们建议编写自己的简单JSON解析器。只有SQL及其变体与XML或JSON相距甚远,而XML或JSON的设计宗旨是使解析器和人们同样理解。而且,SQL在语法上比C / C ++更复杂,它的许多功能已积累了很长的历史。 SQL的结构有所不同,顺便说一句,他们试图使它看起来像自然语言,而且非常成功。该语言有数千个(取决于方言)关键字。通常,为了将一个表达与另一个表达区分开,您需要向前窥视两个或更多的单词(标记)。这种方法称为超前。解析器的分类根据他们可以向前窥视多远:LA(1),LA(2)或LA(*),这意味着解析器可以尽可能向前看以确定正确的分支。有时,一个SQL语句中一个子句的可选结尾与另一个SQL语句的开头重合,这也是可选的:这种情况使解析器更加复杂。 T-SQL倒水了,分号是可选的,某些SQL语句的可能(但不是强制性)结尾可能与另一些开头冲突。这种情况使解析器变得非常复杂。 T-SQL倒水,其中分号是可选的,某些SQL语句的可能(但不是强制性)结尾可能与另一些SQL开头冲突。这种情况使解析器变得非常复杂。 T-SQL倒水,其中分号是可选的,某些SQL语句的可能(但不是强制性)结尾可能与另一些SQL开头冲突。

还是不相信?有一种通过语法描述形式语言的机制。语法是用一种语言描述的另一种语言的代码。根据语法,您通常可以使用工具来生成解析器。最著名的语法描述工具和语言是YACCANTLR。 YACC生成的解析器直接在MySQLMariaDBPostgreSQL引擎中使用。您可以尝试直接从开源中获取它们,并基于基于它们的SQL分析来开发代码完成功能和其他功能。而且,这样的实现将在开发方面获得免费更新,并且解析器的行为将保证与数据库引擎相同。那么为什么要使用ANTLR?它在质量上支持C#/.NET,有很好的工具可以使用它,其语法更易于读写。事实证明,ANTLR语法非常方便,因此Microsoft最近在官方C#文档中使用了它

让我们回到我在解析方面与其他语言相比SQL的复杂性的证明。为此,我想比较公开可用的不同语言的语法大小。在dbForge中,我们使用自己的语法,它们比公开的语法更完整,但是不幸的是,它们被C#代码插入阻塞,无法支持不同的功能,有关更多信息,请参见“错误”部分的“无树解析”部分。

下面是不同语言的语法大小:

JS -475行解析器+ 273 lexer = 748行
Java -615行解析器+ 211 lexer = 826行
C# -1159行解析器+ 433lexer = 1592行
C ++ -1933行

MySQL- 2515线分析器+ 1189 词法 = 3704线
T-SQL - 4035线分析器+ 896 词法 = 4931线
PL SQL - 6719线分析器+ 2366 词法 = 9085线

在某些词法分析器有Unicode字符的语言提供一个枚举的端部,这是无用的在评估语言的复杂性。在开始进行此类传输之前,我已占用行数。

根据语法中的行数来解析语言的复杂性可能存在疑问。与语言的实际语法相比,问题还可能在于开放语法的完整性。尽管如此,由于价差太大,我仍然认为提供这些数字很有用。


这还不是全部,因为我们不必仅将几个文件解析为SQL。我们正在做一个IDE,这意味着我们必须处理不完整或无效的脚本。即使您编写的脚本没有错误,也许编写的脚本不一致,脚本在整个开发过程中也不大可能有效。例如,我首先写“ SELECT FROM”,之后我将很高兴看到可用表的列表。当我选择一个表时,我将把回车重新排列为SELECT,按下空格键,然后等待该特定表中的列列表。这是一个非常简单的示例,但它表明在IDE中提供代码完成功能的解析器不会崩溃,除非遇到无效的脚本。我们必须提出很多技巧,以确保工具提示在许多此类情况下都能正常工作,但是用户仍然会使用不同的方案来处理未完成的脚本,这意味着我们必须提出新的技巧。

谓词战争


解析代码时,有时会出现下一个单词不清楚的情况,请选择两种选择。有时候,足以预先查看严格定义的单词数,您当然可以选择其他选项。解决此类错误的机制称为ANTLR超前。在这种情况下,解析器方法构造为ifs的嵌入式链,每个ifs都进一步看一个单词。下面是产生此类不确定性的语法示例。

rule1:
    'a' rule2 | rule3
    ;

rule2:
    'b' 'c' 'd'
    ;

rule3:
    'b' 'c' 'e'
    ;

在Rule1的中间,已经传递了令牌'a',解析器将不得不向前看2个令牌以选择要遵循的规则。再次,此检查将在规则内进行。可以重写此语法,以使不存在这种先行现象,不幸的是,这种优化经常使结构受损,并且性能增益相对不高。

存在更复杂的机制来解决更复杂的不确定性。其中之一是ANTLR3中的语法谓词机制(synpred)。

在某些情况下,例如当一个子句的可选完成与另一可选子句的开头相交时,他来了。就ANTLR3而言,谓词是生成的方法,它根据一种选择在文本中进行虚拟遍历,如果成功,则返回true,则谓词的完成称为成功。虚拟通行证也称为回溯通行证。如果谓词成功工作,那么将进行一次实际传递。当另一个谓词开始于一个谓词内部,然后一个区段可以遍历一百十千次时,这便成为问题。

考虑一个简化的例子。存在3个不确定点(A,B,C)。

  1. 解析器输入A,记住文本中的位置,开始第1级的虚拟通道。
  2. B, , 2- .
  3. C, , 3- .
  4. 3- , 2 .
  5. 2 , 1 B .
  6. , A, B .

因此,C内部的所有检查将执行4次,B-3次,A-2次。但是,如果合适的选择在列表中排第二或第三,该怎么办?然后,在某个谓词的某个阶段它将失败,文本中的位置将回滚,而另一个谓词将开始执行。

反复分析应用程序冻结的原因,我们发现了synpred“ tail”执行了数千次的情况。在递归规则中,句法特别有问题。不幸的是,就其本质而言,SQL是递归的,至少在几乎所有地方都可以使用子查询。有时,在各种技巧的帮助下,事实证明是要制定规则,以便谓词消失。

显然,synpred对性能有负面影响。在某个阶段,有必要严格控制其人口。唯一的问题是,在编写语法代码时,synpred的外观可能并不明显。此外,有时一个规则的更改会导致另一个规则的谓词出现。这不能手动控制。为了控制谓词的数量,我们编写了一个简单的常规,由特殊的MsBuild Task调用。如果谓词的数量与在单独文件中指定的数量不同,则Task中断程序集并报告错误。看到这样的错误,开发人员不得不多次重写规则代码,以消除不必要的谓词,这可能会使其他开发人员陷入困境。如果谓词不可避免,然后开发人员会更新相应文件中的谓词数量。对该文件所做的更改引起了对审阅的更多关注。

在极少数情况下,我们甚至使用C#编写了自己的谓词来避开ANTLR生成的谓词。幸运的是,这种机制也存在。

自行车解决方案




语法继承


在宣布我们支持的每个DBMS中有任何更改之后,我们将开始支持它们的工作。几乎总是这样的出发点是支持语法中的句法构造。对于每个SQL方言,我们都编写自己的语法,这会产生一些代码重复,但最终比在它们之间寻找共同之处要容易。就在几年前,MySQL和MariaDB非常相似,编写单独的语法是不切实际的。由于MariaDB中有少量结构,而MySQL中则没有,因此我们在MySQL语法中提供了支持。这是一个令人不快的时刻:对于MySQL用户,我们可能会建议使用无效的构造。近年来,MariaDB和MySQL在语法和其他方面已变得非常不同。变得明显在同一语法中支持两种不同的语言是错误的。

可能的解决方案可能是语法的完整副本,然后分别对其进行分别支持。经过长时间的讨论,我们做出了一个大胆的决定。我很高兴我们没有复制代码,我体内的每个单元都拒绝了这个决定。决定编写您自己的语法预处理器ANTLR,该处理器实现语法继承机制。前一段时间,我在ANTLR4语法的官方存储库中遇到了ANTLR3语法。我认为这句话需要读几遍才能体会到疯狂的深度。

在讨论继承的想法时,我们很快意识到我们希望拥有一种多态性的机制。继承人在语法上的能力不仅可以重新定义规则,而且可以称为基础。此外,我想控制基本规则的调用位置:开始,中间或结束。我们决定可以重新定义所有规则,因此您无需指定其他任何内容。为了重新定义规则,在后继语法中声明具有相同名称的规则就足够了。之后,父语法中的规则将以其他名称提供。

ANTLR通过调整使开发成为一个令人愉悦的工具-VS的扩展,还有ANTLRWorks。在介绍继承机制时,我不想失去这个机会。但是,如何指示基本语法呢?您可以提出某种命名文件的约定,但这一点并不明显。另一个选择是在一个单独的文件中指示这样的附加信息,但是即使是现在,键入这些行,我仍感觉到此解决方案的气味。输出以ANTLR注释的格式指示了继承人语法中的基本语法。所有工具都只会忽略该文本,因此我们可以轻松提取出我们感兴趣的代码。

需求已经形成,现在应该实施它们。我们编写了MsBuild任务,该任务作为预构建动作内置于常规构建系统中。 Task执行ANTLR语法预处理器的工作,从基础生成结果语法并继承。生成的语法已由ANTLR本身处理。如果在后继文法中找到了与父文法同名的规则,则将基本规则重命名:下划线后将父文法的名称添加到其名称中。以此名字可以在继承人中与他取得联系。

预处理器机制本身并不需要花费很多时间,但是与解析器的生成一起,事实证明,它使项目的每次重新组装速度降低了10-20秒。在某些时候,这不再适合我们。我们决定考虑如何对其进行优化。解决方案是将注释所依赖的所有语法之和的哈希添加到CS解析器文件的标头中。在做任何事情之前,预处理器都会将这些哈希值与磁盘上文件的哈希值进行比较,如果没有区别,则认为解析器文件是相关的。在最初的开发阶段,我们不得不多次处理过时的预处理器版本所收集的无效解析器和语法。结果,带有预处理器的程序集的哈希和出现在标题注释中。

另一个后处理ANTLR


在许多编程语言中,如果单词是键,则不能再将其用作对象的名称。在SQL中,根据方言,从800到3000个关键字。它们中的大多数与主题领域密切相关,此外,并不是全部都被立即引入,因此禁止将它们全部用作对象名称将引起一连串的愤慨。 SQL引入了保留关键字和非保留关键字的概念。您不能在不引用对象的情况下使用与保留关键字(SELECT,FROM等)相同的方式命名对象,因为它不是多余的(CONVERSATION,AVAILIBILITY等),您可以。此行为使解析器的开发复杂化。在进行词法分析时,上下文是未知的,但是解析器要求标识符和关键字使用不同的数字。为了解决此问题,我们向ANTLR解析器添加了另一个后处理。后处理通过调用特殊方法,将所有显式检查替换为标识符。此方法实现了棘手的检查。如果输入了标识符并且还期望有标识符,那么一切都很好,但是如果将未保留的关键字提供给输入,则需要对其进行额外检查。另一项检查是在搜索分支的当前上下文中检查了该方法,该未保留的关键字可以完全用作关键字,如果没有这样的分支,则可以将其用作标识符。但是如果输入了未保留的关键字,则需要对其进行额外检查。另一项检查是在搜索分支的当前上下文中检查了该方法,该未保留的关键字可以完全用作关键字,如果没有这样的分支,则可以将其用作标识符。但是如果输入了未保留的关键字,则需要对其进行额外检查。另一个检查是,在搜索分支的当前上下文中检查了该方法,该未保留的关键字可以完全用作关键字,如果没有这样的分支,则可以用作标识符。

严格来说,只能通过ANTLR来解决此问题,但是这样的解决方案并不是最佳的。解决此问题的经典方法是创建一个列出所有未保留的关键字和标识符令牌的规则。此外,在任何允许使用标识符的地方,不再是所使用的标识符令牌,而是此特殊规则。这样的解决方案不仅使您记住在输入关键字时添加关键字,不仅在使用位置添加关键字,而且在此特殊规则中,它的运行速度也要慢得多。

失误



无树解析




通常,解析器的结果是语法树。语法树(抽象的具体的)是通过形式语法的棱镜反映程序文本的数据结构。在研究了该问题之后,如果您想为最近想出的语言实现具有自动补全功能的代码编辑器,则可能会实现以下算法:

  1. 在编辑器中解析文本。获取语​​法树。
  2. 找到马车下方的结。将其与语法匹配。找出此时可以使用哪些关键字和对象类型。

在这种情况下,语法可以方便地表示为图或有限状态机。

不幸的是,在开发之初,IDE ANTLR存在于其第三版本中。第四版从头开始进行了重写,与第三版根本不同;通过传递代码,解析器将自动生成解析树,而无需一行额外的代码。在第三个版本中,有一种机制可以告诉ANTLR如何构建一棵树,但是使用它并不是很愉快。此外,关于此主题的许多示例和文章建议在解析器通过规则时使用动作机制执行代码。该机制非常方便,可让您快速获得结果。不幸的是,该解决方案导致产品开发中的主要体系结构问题,并增加了支持新功能的复杂性。事实是,在一个文件中,在一个语法文件中,与大量不同功能相关联的操作开始累积,将其很好地传播到不同的程序集。将来,我们能够将动作的处理程序本身分配给不同的程序集,实现订户-通知程序模式的相当棘手的版本,但是调用本身通过传递必要的信息仍然使我们的语法混乱,使新功能的支持变得复杂,并对体系结构施加了严重且令人不愉快的限制。对新功能的支持变得复杂,并对架构施加了严重且令人不愉快的限制。对新功能的支持变得复杂,并对架构施加了严重且令人不愉快的限制。

但是,一切似乎都不像看起来那样明显。事实是ANTLR3比ANTLR4快得多。根据我们的测量,差异约为6倍。此外,大型脚本的语法树可能会占用大量RAM空间,并且只要我们必须在Visual Studio和SqlServer Management Studio的32位地址空间中生存,这可能就很关键。

结论


小计可能如下:

  • ANTLR一个强大的构建解析器的工具
  • 与其他工具相比,它的优势是工具,语法方便,支持多种语言
  • ANTLR4是从头开始重写的,它意味着使用与第三版不同的解析器
  • 总有一种方法可以从ThirdParty库中获得比他们提供的更多的东西
  • SQL特定语言,为其构建解析器不是一件容易的事
  • 解析与构建IDE有关的任务的代码具有其自身的特点:您需要考虑处理未加密或无效的脚本

下一部分见!

All Articles