主题区域的语言。 通用语言构造不过载。 同时,它允许您仅用几行就可以实现非常复杂的逻辑。 所有这些都是DSL。
但是,DSL的创建要求开发人员具有资格。 定期使用此方法将成为开发另一种语言的例程。 解决方案可能是创建通用工具-一种适用于完全不同的任务且易于修改的引擎。 在本文中,从实现的角度来看,我们将在C#中进行最简单的开发,但是同时,功能非常强大的语言引擎可用来解决相当多的问题。
引言
开发应用程序项目的方法有两种:将其简化为显而易见的没有缺点的方法,或者使其复杂而没有明显的缺点的方法。 C.E. R. Hoar(CAR Hoare)
在本文中,我想分享一种开发技术,一方面可以帮助我和我的团队解决项目的复杂性。 另一方面-它使您可以快速开发原型应用程序。 乍一看,开发一种编程语言似乎太复杂了。 如果我们在谈论通用工具,那就是如此。 如果目标是涵盖一个狭窄的学科领域,那么开发一种特定的语言通常是合理的。
有一次,我面临着开发一种工业语言(IEC 61131-3)实现以集成到客户软件中的任务。 在这项工作的过程中,我开始对口译员结构这个话题产生了兴趣,从那时起,我就开始以深奥而不是非常语言的口译员为爱好。 将来,人们对如何使用手写的口译员来简化日常生活有了一种理解。
理智的编程语言的主要目标是简化编程和读取程序的过程。 用asm编写比用机器代码编写更容易,用C编写比用asm编写更容易,用C#编写更简单,依此类推。
之所以能够实现这一目标,主要是因为采用了最流行的还原论方法-将复杂的任务分解为简单且可识别的组件-标准化了它们的交互作用和某种语法。
编程语言由一组运算符组成,这些运算符实质上是语言,基本构件和语法的基础,这些语法定义了编写运算符组合的方式以及标准库。 根据语法规则将基本操作的序列分为功能,将功能分为类(如果有OOP),将类组合为库,然后将其组合为包。 这就是典型的主流语言的样子。 原则上,这些技术足以解决大多数日常任务。 但是,这不是限制,因为您可以更进一步-达到更高的抽象水平,并且如果它不支持以宏形式进行元编程,则必须超出所使用语言的限制。
如今,大多数项目都归结为现成组件和微不足道的低级samopisnogo部件的组合。 组件的组合通常通过通用编程语言-C#,Java,Python和其他语言来完成。 尽管这些语言是高级语言,但它们也是通用的,因此必然包含用于低级操作,函数创建,类,通用类型的描述,异步编程等的语法构造。 因此,任务“一次执行,两次执行,三次执行”随着大量语法结构的增长而增长,并且可以膨胀多达数百行代码甚至更多行。
如果您重复使用简化论的技巧,那么您可以简化组件的重用,但是对于这些组件已经如此。 这是通过开发一种特殊的语言来实现的,该语言具有简化的语法并且仅用于描述这些组件的交互。 这种方法称为YaOP(面向语言的编程),而语言称为DSL(特定于域的语言-一种特定于域的语言)。
由于缺乏冗余结构,DSL上只有很少的线路可以实现相当复杂的功能,这会带来积极的后果:开发速度提高,错误数量减少以及系统测试得以简化。
如果成功应用,由于可以编写定义和扩展系统行为的紧凑脚本,这种方法可以显着提高所开发产品的灵活性。 这种方法的普及证明了这种方法的许多应用,因为DSL无处不在。 常见的HTML是文档描述语言,SQL是结构化查询语言,JSON是结构化数据描述语言,XAML,PostScript,Emacs Lisp,nnCron等等。
DSL具有所有优点,但有一个明显的缺点-对系统开发人员有很高的要求。
并非每个开发人员都具备开发原始语言的知识和经验。 甚至更少的专家都可以开发出足够灵活和富有成效的语言。 还有其他问题。 例如,在最初放置的功能开发的某个时刻,可能还不够,因此有必要创建功能或OOP。 并且在有函数的地方,可能需要进行尾递归优化以实现无循环等。 同时,必须考虑向后兼容性,以便先前编写的脚本可以继续与新版本一起使用。
另一个问题是,旨在解决一个问题的语言完全不适合其他问题。 因此,您必须从头开始开发新的DSL,因此新语言的开发已成为日常工作。 这又使维护复杂化,并减少了难以在不同DSL实现和使用它们的项目之间共享的代码的重用。
出路是创建DSL来构建DSL。 在这里,我并不是指RBNF,而是一种可以通过内置方式更改为主题区域语言的语言。 创建灵活且可转换的语言的主要障碍是存在严格定义的语法和类型系统。 在计算机工业的整个发展时期,已经提出了几种没有语法的灵活语言,但是它们一直存在到今天,Forth和Lisp语言也在继续积极发展。 这些语言的主要特征在于,由于其结构和同质性,它们可以由于内置的方式而改变解释器的行为,并在必要时解析最初未规定的句法结构。
Forth有一些解决方案,可将其语法扩展到C或Scheme。 “堡垒”经常因参数和操作的异常后缀序列而受到批评,该序列由使用堆栈传递参数来决定。 但是,“堡垒”可以访问文本解释器,这使您可以在必要时向用户隐藏反向记录。 最后,这是一个习惯问题,并且发展很快。
Lisp语言家族依赖于宏,这些宏允许您在必要时输入DSL。 访问解释器和阅读器有助于实现具有指定解释功能的元循环解释器。 例如,Scheme lisp Racket的实现被定位为用于开发语言的环境,并且具有开箱即用的语言,可用于创建Web服务器,构建GUI界面,推理语言等。
这种灵活性使这些语言成为通用DSL引擎角色的理想选择。
结果,“堡垒”和Lisp主要发展为通用语言,尽管只是小众市场-它们利用了自己的功能,这些功能对于DSL语言可能是多余的。 但是同时它们也很容易实现,这意味着您可以开发有限的版本,并且可以扩展它。 这将使您可以通过对特定任务进行少量修改(理想情况下,无需进行任何修改)来重用这种语言的核心。
我还想指出,这些语言不仅对于编写脚本非常有用,而且对于通过REPL与系统进行交互交互也非常有用。 一方面可以方便调试,另一方面可以作为用户可访问的系统接口。 可以相信,在某些情况下,与系统的文本界面比图形界面更有效,因为它实现起来更简单,更灵活,并允许用户将典型的操作归纳为功能,等等。 文本界面的一个引人注目的示例可能是Bash。 而且,如果该语言是同构符号,则其构造可能相对易于生成和解析,并且只需花费很少的精力就可以在解释器之上实现图形语言-当目标用户远离编程时,这很有用。
如今,XML和JSON数据描述语言已广泛用作DSL进行配置。 当然,这是一个好习惯,但是在某些情况下,仅凭数据是不够的,例如,您需要描述对它们的操作。
在这篇文章中,我建议为Fort语言创建一个简单的解释器,并展示如何使其适应特定的问题。
Fort语言被选为最易于实现和使用的语言,但功能强大到足以将其用作许多任务的DSL。 实际上,该语言的核心是地址解释器,即使在汇编器中也仅占用几行,而实现的大部分内容都落在原语上,而原语则更多,实现应该更加通用,快速和灵活。 语言的另一个重要部分是文本解释器,它使您可以与地址解释器进行交互。
地址翻译
Fort语言的基本元素是一个单词,该单词与其他单词和原子(数字)之间用空格,行尾和制表符分隔。
单词的含义和属性与其他语言(例如C)的功能相同。在实现中连接的单词(即,以与解释器相同的方式实现)类似于其他语言的运算符。 实际上,使用任何编程语言编写的程序无非就是语言和数据运算符的组合。 因此,可以将编程语言的创建视为运算符的定义以及如何将它们组合在一起。 此外,诸如C之类的语言确定了一种不同的写操作符的方式,这决定了该语言的语法。 在大多数语言中,通常无法修改语句-例如,您不能更改if语句的语法或行为。
在Fort语言中,所有运算符及其组合(用户词)都具有相同的编写方法。 堡垒词分为原始词和习俗词。 您可以定义一个单词,该单词会使原语过载,从而改变原语的行为。 虽然实际上重定义的单词将通过最初定义的原语实现。 在我们的实现中,C#中的函数将是原始函数。 用户定义的单词由要执行的单词的地址列表组成。 由于有两种词,解释器必须区分它们。 原语和用户词的分离是通过相同的原语进行的,每个用户词都以DoList操作开始,以Exit操作结束。
可以长时间描述这种分离是如何发生的,但是通过研究解释器程序的执行顺序可以更容易理解。 为此,我们实现了一个最少的解释器,定义了一个简单的程序,并逐步了解了如何执行它。
我们的堡垒机由线性存储器,数据堆栈,返回堆栈,指令指针,字指针组成。 我们还将在一个单独的地方存储基元。
public object[] Mem;
解释的本质是导航到内存中的地址并执行其中指示的指令。 在我们的例子中,整个地址解释器-语言的核心-将在一个函数Next()中定义。
public void Next() { while (true) { if (IP == 0) return; WP = (int)Mem[IP++]; Core[(int)Mem[WP]](); } }
每个用户词都以DoList命令开头,该命令的任务是将当前解释地址保存在堆栈中并设置下一个词的解释地址。
public void DoList() { RS.Push(IP); IP = WP + 1; }
要退出单词,请使用Exit命令,该命令将从返回堆栈中恢复地址。
public void Exit() { IP = RS.Pop(); }
为了直观地解释解释器的原理,我们引入了一个命令,它将模拟有用的工作。 我们称之为Hello()。
public void Hello() { Console.WriteLine("Hello"); }
首先,您需要初始化机器并指定原语,以使解释器正常工作。 您还需要在程序存储器中指定原语的地址。
Mem = new Object[1024]; RS = new Stack<int>(); DS = new Stack<object>(); Core = new List<CoreCall>(); Core.Add(Next); Core.Add(DoList); Core.Add(Exit); Core.Add(Hello); const int opNext = 0; const int opDoList = 1; const int opExit = 2; const int opHello = 3;
现在我们可以制作一个简单的程序,在本例中,用户代码将从地址4开始,并由两个子程序组成。 第一个例程从地址7开始并调用第二个例程,第二个例程从地址4开始并显示单词Hello。
要执行该程序,必须首先将值0保存在返回堆栈上,通过该地址堆栈,地址解释器将中断解释周期,设置入口点,然后启动解释器。
var entryPoint = 7;
如上所述,在该解释器中,原语将存储在单独的内存中。 当然,它可能以不同的方式实现:例如,在程序存储器中,存储了操作员功能的委托。 一方面,这样的解释器不会变得更容易,但是另一方面,它显然会更慢,因为解释的每个步骤都需要类型检查,强制转换和执行,因此可以获得更多的操作。
解释器的每个用户词都以DoList原语开头,该原语的任务是保存解释的当前地址并转到下一个地址。 子例程的退出由Exit操作执行,该操作从返回堆栈中恢复地址,以供进一步解释。 实际上,我们已经描述了整个地址解释器。 要执行任意程序,只需用原语对其进行扩展就足够了。 但是首先,您需要处理文本解释器,该文本解释器提供了与地址解释器的接口。
文字翻译
Fort语言没有语法;用它编写的程序是由空格,制表符或行尾分隔的单词。 因此,文本解释器的任务是将输入流分解为单词(令牌),为它们找到入口点,执行或写入内存。 但并非所有令牌都必须执行。 如果解释者找不到该单词,则尝试将其解释为数字常数。 另外,文本解释器有两种模式:解释模式和编程模式。 在编程模式下,不执行字地址,而是将其写入内存,从而确定新的字。
“堡垒”的规范实现通常将字典(词典条目)和程序存储器结合在一起,以简单连接列表的形式定义单个代码文件。 在我们的实现中,只有可执行代码将在内存中,单词的入口点将存储在单独的结构中-字典。
public Dictionary<string, List<WordHeader>> Entries;
在此词典中,该单词被分配给多个标题,因此您可以定义任意数量的具有相同名称的子程序,然后删除该定义并开始使用旧的定义。 此外,保存的旧地址使您即使在重新定义字典后也可以在字典中查找单词的名称,这对于生成堆栈跟踪或调试学习内存特别有用。 WordHeader是一个存储子例程入口地址和立即解释标志的类。
public class WordHeader { public int Address; public bool Immediate; }
立即标志指示解释器此字应在编程模式下执行,而不是写入存储器。 在示意图上,解释器的逻辑可以表示为:右手为YES,左手为NO。
我们将使用TextReader读取输入流,并使用TextWriter输出它。
public TextReader Input; public TextWriter Output;
根据上述方案的解释器的实现将在一个函数Interpreter()中进行。
void Interpreter() { while (true) { var word = ReadWord(Input); if (string.IsNullOrWhiteSpace(word)) return;
解释在一个循环中执行,其输出在到达输入流的末尾(例如,文件的末尾)时执行,而ReadWord函数将返回一个空字符串。 ReadWord的任务是在每次调用时返回下一个单词。
static string ReadWord(TextReader sr) { var sb = new StringBuilder(); var code = sr.Read(); while (IsWhite((char)code) && code > 0) { code = sr.Read(); } while (!IsWhite((char)code) && code > 0) { sb.Append((char)code); code = sr.Read(); } return sb.ToString(); } static bool IsWhite(char c) { return " \n\r\t".Any(ch => ch == c); }
读取单词后,将尝试在词典中找到它。 如果成功,则返回单词的标题;否则,返回null。
public WordHeader LookUp(string word) { if (Entries.ContainsKey(word)) { return Entries[word].Last(); } return null; }
您可以通过前两个字符检查输入的值是否为数字。 如果第一个字符是数字,则我们假定它是一个数字。 如果第一个字符是“ +”或“-”符号,第二个字符是数字,则很可能也是数字。
static bool IsConstant(string word) { return IsDigit(word[0]) || (word.Length >= 2 && (word[0] == '+' || word[0] == '-') && IsDigit(word[1])); }
要将字符串转换为数字,可以使用标准方法Int32.TryParse和Double.TryParse。 但是由于多种原因,它们的速度没有差异,因此我使用了自定义解决方案。
static object ParseNumber(string str) { var factor = 1.0; var sign = 1; if (str[0] == '-') { sign = -1; str = str.Remove(0, 1); } else if (str[0] == '+') { str = str.Remove(0, 1); } for (var i = str.Length - 1; i >= 0; i--) { if (str[i] == '.') { str = str.Remove(i, 1); return IntParseFast(str) * factor * sign; } factor *= 0.1; } return IntParseFast(str) * sign; } static int IntParseFast(string value) {
ParseNumber方法可以转换整数值和浮点数,例如“ 1.618”。
单词的执行与我们用于运行地址解释器的方式相同。 如果发生异常,将打印地址解释器的堆栈跟踪。
public void Execute(int address) { try { if (address < Core.Count) {
当解释器处于编译模式且未标记该单词以立即执行时,必须将其地址写入内存。
public void AddOp(object op) { Mem[Here++] = op; }
这里的变量存储下一个空闲单元的地址。 由于必须从运行时环境中访问该变量作为Fort语言的变量,因此此处的值以给定的偏移量存储在程序存储器中。
public int _hereShift; public int Here { get => (int)Mem[_hereShift]; set => Mem[_hereShift] = value; }
为了在解释过程中区分数字常量和字地址,在每个常量之前编译单词doLit的汇编,该汇编读取存储器中的下一个值并将其放在数据堆栈中。
public void DoLit() { DS.Push(Mem[IP++]); }
我们已经描述了地址和文本解释器;进一步的发展在于用原子填充原子核。 不同版本的“堡垒”具有一组不同的基本单词,最简约的实现可能是仅包含31个基元的eForth。 因为该原语比复合用户单词运行得快,所以最少的Fort实现通常比详细实现慢。 在
这里可以找到几种口译员的单词集的比较。
在这里描述的解释器中,我还尝试不要不必要地增加基本单词的字典。 但是为了便于与.net平台集成,我决定实现数学,布尔运算,当然还要通过一组原语进行反射。 同时,这里缺少Fort实现中通常很原始的一些词,这意味着通过解释器实现。
为了定义新的用户词,使用了两个内核词:“:”和“;”。 单词“:”从输入流中读取一个新单词的名称,使用此键创建一个头,将基本单词doList的地址添加到程序存储器中,并将解释器置于编译模式。 除标记为立即的单词外,所有后续单词都将被编译。
public void BeginDefWord() { AddHeader(ReadWord(Input)); AddOp(LookUp("doList").Address); IsEvalMode = false; }
编译以单词“;”结束,该单词将单词“ exit”的地址写入程序存储器并使其进入解释模式。 现在,您可以定义自定义单词-例如,循环,条件语句等。
Eval(": ? @ . ;"); Eval(": allot here @ + here ! ;"); Eval(": if immediate doLit [ ' 0branch , ] , here @ 0 , ;"); Eval(": then immediate dup here @ swap - swap ! ;"); Eval(": else immediate [ ' branch , ] , here @ 0 , swap dup here @ swap - swap ! ;"); Eval(": begin immediate here @ ;"); Eval(": until immediate doLit [ ' 0branch , ] , here @ - , ;"); Eval(": again immediate doLit [ ' branch , ] , here @ - , ;"); Eval(": while immediate doLit [ ' 0branch , ] , here @ 0 , ;"); Eval(": repeat immediate doLit [ ' branch , ] , swap here @ - , dup here @ swap - swap ! ;"); Eval(": // immediate [ ' \\ , ] ;");
我不会在这里描述其余的标准词-在网络上有关相应主题资源的信息足够多。 为了与平台交互,我定义了9个字:
- “ Null”-将null压入堆栈;
- “类型”-将类类型推入“单词TrueForth.MyClass类型”堆栈中;
- “ New”-从堆栈中获取类型,创建类的实例并将其放置在堆栈上,构造函数参数(如果有)也必须位于堆栈中“单词TrueForth.MyClass type new”;
- “ M!”-从堆栈中获取对象,字段名称,值的实例,并将值分配给指定的字段;
- “ M @”-从堆栈中选择一个对象的实例,字段名称并将该字段的值返回到堆栈;
- “ Ms!”和“ ms @”-与前面的类似,但是对于静态字段,而不是实例,堆栈上必须有一个类型;
- “加载组件”-从堆栈中取出,放到组件中并加载到内存中;
- “ Invk”-从堆栈中获取委托,参数,并将其称为“ 1133个单词SomeMethod单词TrueForth.MyClass输入new m @ invk”。
我描述了Fort语言实现的要点,该实现并不寻求支持该语言的ANSI标准,因为其任务是实现用于构建DSL的引擎,而不是实现通用语言。 在大多数情况下,成熟的口译员足以构建主题领域的简单语言。
有多种使用上述解释器的方法。 例如,您可以创建解释器的实例,然后将初始化脚本提交给确定必要单词的输入。 后者通过反射与系统交互。
public static bool Init4Th() { Interpreter = new OForth(); if (File.Exists(InitFile)) { Interpreter.Eval(File.ReadAllText(InitFile)); return true; } else { Console.WriteLine($" {InitFile} !"); return false; } }
报表分发系统配置示例
( ***** ***** ) word GetFReporter word ReportProvider.FlexReports.FReporterEntry type new m@ invk constant fr // : word ReportProvider.FlexReports.FDailyReport type new ; // : word AddReport fr m@ invk ; // : [ ' word , ] ; // : [ ' word , ] ; // : [ ' s" , ] ; // , " : ; // : dup [ ' word , ] swap word MailSql swap m! ; : dup [ ' word , ] swap word XlsSql swap m! ; ( ***** ***** ) cr s" " . cr cr " 08:00 mail@tinkoff.ru seizure.sql , " 08:00 mail@tinkoff.ru fixed-errors-top.sql fixed-errors.sql WO" 08:00 mail@tinkoff.ru wo-wait-complect-dates.sql " 07:30 mail@tinkoff.ru top-previous-input-errors.sql previous-input-errors.sql " 10:00 mail@tinkoff.ru collection-report.sql BPM " 08:00 mail@tinkoff.ru bpm-inbox-report.sql ScanDoc3 7 " 07:50 mail@tinkoff.ru new-sd3-complects-prevew.sql new-sd3-complects.sql ( ******************************** ) cr s" " . cr
您可以执行其他操作:通过数据堆栈将现成的对象传递给解释器的输入,然后通过解释器与它们进行交互。 例如,我确实恢复了用于接收文档扫描,扫描仪,网络摄像头或虚拟设备(用于调试或培训)的设备设置。 在这种情况下,不同设备的参数集,设置和初始化顺序非常不同,并且可以通过堡垒解释器轻松解决。
var interpreter = new OForth(); interpreter.DS.Push(this);
该配置是通过编程生成的,结果如下所示:
s" @device:pnp:\\?\usb#vid_2b16&pid_6689&mi_00#6&1ef84f63&0&0000#{65e8773d-8f56-11d0-a3b9-00a0c9223196}\global" s" Doccamera" word Scanning.Devices.PhotoScanner.PhotoScannerDevice type new dup s" 3264x2448, FPS:20, BIT:24" swap word SetSnapshotMode swap m@ invk dup s" 1280x720, FPS:30, BIT:24" swap word SetPreviewMode swap m@ invk word SetActiveDevice arctium m@ invk
顺便说一下,脚本* .ps和* .pdf是用类似的方式生成的,因为PostScript和Pdf本质上都是“堡垒”的子集,但它们仅用于在屏幕或打印机上呈现文档。
不仅为应用程序,还为控制台实现交互模式一样容易。 为此,您必须首先通过准备好的脚本初始化系统,然后通过在标准输入STDIN上设置解释器来开始解释。
var interpreter = new OForth(); const string InitFile = "Init.4th"; if (File.Exists(InitFile)) { interpreter.Eval(File.ReadAllText(InitFile)); } else { Console.WriteLine($" {InitFile} !"); } interpreter.Eval(Console.In);
初始化脚本可以像这样:
( ***** ***** ) word ComplectBuilder.Program type constant main // : mode! [ ' word , ] word Mode main ms! ; // : init word Init main ms@ invk ; // : load [ ' word , ] word LoadFile main ms@ invk ; // : start word StartProcess main ms@ invk ; // : count word Count main ms@ invk ; // : all count ; // ( ***** ***** ) init cr cr s" , help" . cr cr ( ***** ***** ) : help s" :" . cr s" load scandoc_test.csv 0 all start" . cr bl bl s" load scandoc_test.csv -- " . cr bl bl s" 0 all start -- , 0 all " . cr cr s" DEV TEST PROD:" . cr s" mode! DEV init" . cr s" :" . cr s" word Mode main ms@ . cr" . cr ;
作为输入,不仅可以有带有UI的TextBox应用程序中的控制台或文本,而且还可以有网络。 在这种情况下,您可以实现简单的交互式控制,例如服务,以调试,启动和停止组件。 这种使用的可能性受到开发人员的想象力和要解决的任务的限制。 , UI - .
. , , .
, :
public void Callback(string word, MulticastDelegate action) { if (string.IsNullOrWhiteSpace(word) || word.Any(c => " \n\r\t".Any(cw => cw == c))) { throw new Exception("invalid format of word"); } DS.Push(action); Eval($": {word} [ ' doLit , , ] invk ;"); }
DS.Push(action), . , , [ ], , . ' Tick , doLit, , . Comma «,» doLit, .
, . , :
public class WoConfItem { public string ComplectType; public string Route; public string Deal; public bool IsStampQuery; }
— , :
public class WoConfig { private OForth VM; private List<WoConfItem> _conf; public WoConfig(string confFile) { _conf = new List<WoConfItem>(); VM = new OForth();
:
\ WO passport configuration new-conf \ Config rules \ { -- begin config item, } -- end config item, * -- match any values \ Example: \ { complect-type * route offer deal 100500 is-stamp-query true } \ ***** offer ***** { complect-type offer route offer is-stamp-query false deal 5c18e87bfeed2b0b883fd4df } { complect-type KVK route offer is-stamp-query true deal 5d03a8a1edf8af0001876df0 } { complect-type offer-cred route offer is-stamp-query true deal 5d03a8a1edf8af0001876df0 } { complect-type offer-dep route offer is-stamp-query true deal 5d03a8a1edf8af0001876df0 } { complect-type quick-meeting route offer is-stamp-query true deal 5d03a8a1edf8af0001876df0 } { complect-type exica route offer is-stamp-query true deal 5d03a894e2f5850001435492 } { complect-type reissue route offer is-stamp-query true deal 5d03a894e2f5850001435492 } \ ***** offer-flow ***** { complect-type KVK route offer-flow is-stamp-query true deal 5d03a8a1edf8af0001876df0 } { complect-type offer-cred route offer-flow is-stamp-query true deal 5d03a8a1edf8af0001876df0 } { complect-type offer-dep route offer-flow is-stamp-query true deal 5d03a8a1edf8af0001876df0 } { complect-type reissue route offer-flow is-stamp-query true deal 5d03a894e2f5850001435492 }
, , DSL — .
, «». DSL.
, , — , , , , — . , .
— , . — , — !
, .
- .
祝你好运