【问题标题】:What's an elegant way to parse this data format in Clojure?在 Clojure 中解析这种数据格式的优雅方法是什么?
【发布时间】:2013-12-18 09:58:38
【问题描述】:

A legacy application I work with 有一种时髦的数据格式,称为 SGS。我已经考虑并开始使用一些蛮力解决方案,包括手动生成的有限状态机和自定义递归下降解析器,但我正在努力构建一个应用程序,其中(非库)源代码的数量是足以表达需要做的事情。

所以我一直在研究基于 Clojure 的解析器。我一直在摆弄

这些都没有足够的文档/支持在网络上让我开始运行。因此,我正在寻找对其中一种工具(或不错的替代工具)有经验的人来帮助我。

这是数据语言:


  • 数据由具有标签(从第 1 列开始)和 1 个或多个字段的行表示,由一个或多个空格分隔。

  • 字段由一个或多个子字段组成,以逗号分隔。为了便于阅读,逗号可能后跟空格,但这些并不重要。

  • 标签是由集合 [-$0-9A-Z_*%] 中的字符组成的标识符,不必是唯一的。

  • 子字段要么是标识符,要么是带引号的字符串,要么是缺失的 (nil)。带引号的字符串由 2 前导单引号和 2 尾随单引号分隔。带引号的字符串不包含单引号,因此无需担心引号嵌套或转义。

  • Space-dot-space 开始行后注释。行尾的空格点是空的剩余行注释。行首的点空间使整行成为注释。仅由一个点组成的行也是行注释。

  • 行可以跨越两行或多行。分号作为一行中最后一个非空白、非注释字符意味着该行在下一行继续,就好像分号和换行符都不存在一样。

  • 分号和点(带或不带空格)在 cmets 或带引号的字符串中没有特殊意义。

示例

. Comment
.
LAB1  F1S1                       . Minimal data row, with line comment
LAB1  F1S1,F1S2,F1S3  F2S1  F3S1 . 2nd row with same label
LAB2  , , , F1S4     ''Field #2 (only 1 subfield)''  F3S1,,F3S3
LAB99 F1S1,                      . Field 1 has 2 subfields, 2nd is nil
LAB3  F1S1,F1S2, ;
      F1S3       ;
      F2S1                       . Row continued over 3 lines. 

手动解析我的示例,我想要这样的结果:

[
 ("LAB1" ["F1S1"])
 ("LAB1" ["F1S1" "F1S2" "F1S3"] ["F2S1"] ["F3S1"])
 ("LAB2" [nil nil nil "F1S4"] ["Field #2 (only 1 subfield"] ["F3S1" nil "F3S3"])
 ("LAB99" ["F1S1" nil])
 ("LAB3" ["F1S1" "F1S2" "F1S3"] ["F2S1"])
]

更新:

@edwood 建议展示我自己的实现供人们用作起点。我曾犹豫要不要这样做以避免预先偏向某个特定方向的人,但由于缺乏回应,这可能“聊胜于无”。

那么,这里是我自己的 InstaParse 解决方案,它可以正常工作:

     SGS = (<COMMENT_LINE> / DATA_LINES) *
     COMMENT_LINE = #' *\\.(?: [^\\n]*)?\\n' 
     DATA_LINES = LABEL FIELDS SEPARATOR? (LINE_COMMENT | '\\n')
     LABEL = IDENTIFIER
     FIELDS = '' | (SEPARATOR FIELD)+
     SEPARATOR = CONTINUATION #' +' | #' +' (CONTINUATION #' *')?
     CONTINUATION = #'; *\\n'
     LINE_COMMENT = #' .[^\\n]*\\n'  
     FIELD = SUBFIELD (',' SEPARATOR? SUBFIELD)*
     SUBFIELD = IDENTIFIER | QUOTED_STRING | ''
     IDENTIFIER = #'[-$0-9A-Z_*%]+'
     QUOTED_STRING = #'\\'\\'[^\\']*\\'\\''

在调试时,它设法处理了 249 行,然后才遇到我需要调试的错误。但是一旦我解决了这个问题,它大概可以处理我的全部 431 行数据,并在大约 2 分钟后结束了,

CompilerException java.lang.OutOfMemoryError: Java 堆空间, 编译:(sgs2.clj:40:13)

我将易于处理正则表达式的内容移至正则表达式,这似乎有助于提高性能。例如,注释行现在解析起来很简单,因为它们直接匹配单个正则表达式,或者不匹配。


如果我将输入数据缩减为 228 行,解析器会运行并在 16 秒内产生正确的结果。我认为对于非常少量的数据来说,这是一个很长的时间。我做错了什么?

【问题讨论】:

  • 我经常听到的一个 clojure 解析器是 parsley - github.com/cgrand/parsley。但我以前从未尝试过。
  • instaparse 有一个很好的自述文件/文档。先试试吧。
  • @edbond:我尝试了 Instaparse,很高兴快速创建了一个在小型数据集上运行良好的解析器。然而,令人费解的是,我的解析器在一个只有几百行的文件上爆炸(即停止并产生异常)。
  • 也许你应该添加一个解析器和示例文件,有人帮忙
  • 对于后来到达这里的其他人:Parsley 是一个 LR(0) 解析器生成器,它非常适合解析诸如 S 表达式之类的东西,它具有清晰、明确的开始和结束标记。对于其他任何事情,您都将竭尽全力让 Parsley 解析器工作。

标签: parsing clojure


【解决方案1】:

这是我最终得到的 instaparse 解析器:

"<SGS> = (<COMMENT_ROW> | ROW)+
<NL> = '\\n'
<qq> = \"''\"
space = <#'\\s*'>
COMMENT_ROW = COMMENT NL?
LABEL = 'LAB' #'\\d+'
EMPTY_F = <space>
FFIELD = 'F' #'[0-9A-Z]+'
QFIELD = (<qq> (!qq #'.')+ <qq>)
<F> = FFIELD / QFIELD / EMPTY_F
F_SEP = ((space? | ',')* ';' NL space?) / (<space?> ',' <space?>) / <space>
<NEXT_FIELDS> = F <space?> (<F_SEP> NEXT_FIELDS)? <space?>
FIELDS = F <space?> (<F_SEP> NEXT_FIELDS)? <space?>
COMMENT = '.' #'.*'
ROW = LABEL <space?> FIELDS <space?> <COMMENT?> <NL?>"

我希望有人可以改进或简化它。 解析示例输入的结果:

sgs.core> (sgs example-input)
([:ROW [:LABEL "LAB" "1"] [:FIELDS [:FFIELD "F" "1S1"]]] [:ROW [:LABEL "LAB" "1"] [:FIELDS [:FFIELD "F" "1S1"] [:FFIELD "F" "1S2"] [:FFIELD "F" "1S3"] [:FFIELD "F" "2S1"] [:FFIELD "F" "3S1"]]] [:ROW [:LABEL "LAB" "2"] [:FIELDS [:EMPTY_F] [:EMPTY_F] [:EMPTY_F] [:FFIELD "F" "1S4"] [:QFIELD "F" "i" "e" "l" "d" " " "#" "2" " " "(" "o" "n" "l" "y" " " "1" " " "s" "u" "b" "f" "i" "e" "l" "d" ")"] [:FFIELD "F" "3S1"] [:EMPTY_F] [:FFIELD "F" "3S3"]]] [:ROW [:LABEL "LAB" "99"] [:FIELDS [:FFIELD "F" "1S1"] [:EMPTY_F]]] [:ROW [:LABEL "LAB" "3"] [:FIELDS [:FFIELD "F" "1S1"] [:FFIELD "F" "1S2"] [:FFIELD "F" "1S3"] [:FFIELD "F" "2S1"]]])

在我的机器上大约需要 50 毫秒。 我添加了几个函数来清理结果。

sgs.core> (pprint (parse-and-transform sgs example-input))
[("LAB1" ["F1S1"])
 ("LAB1" ["F1S1" "F1S2" "F1S3"] ["F2S1"] ["F3S1"])
 ("LAB2"
  [nil nil nil "F1S4"]
  ["Field #2 (only 1 subfield)"]
  ["F3S1" nil "F3S3"])
 ("LAB99" ["F1S1" nil])
 ("LAB3" ["F1S1" "F1S2" "F1S3"] ["F2S1"])]

完整源代码在这里:https://gist.github.com/edbond/8052305

关于性能你可以阅读https://github.com/Engelberg/instaparse/blob/master/docs/Performance.md

我会尝试将大输入分成小块。

【讨论】:

  • 嘿@edbond,非常感谢您非常为此所做的工作!由于您使用了参数化,您的代码非常干净易读。我认为您误解了我的规范子字段;在我的简单示例中,它们都以“F”开头,但在现实生活中,它们可以是任何允许的字符。无论如何,我听取了您的建议并构建了自己的 Instaparse 扫描仪,请查看我的更新。
【解决方案2】:

这是使用 Parse-EZ 获得所需内容的一种方法。注意我关闭了默认 Parse-EZ (with-trim-off) 的空白/cmets 功能。入口函数是列表底部的“sgs”。您可以调用解析器:(parse sgs your-input-string)

(ns sgs-parser
  (:use [protoflex.parse]))

(defn line-comments [] (multi* #(regex #"(\r?\n)?\..*\r?\n")))
(defn wsp [] (regex #"[ \t]*(\. .*)?"))
(defn trim [parse-fn] (wsp) (let [r (parse-fn)]  (wsp) r))

(defn label [] (regex #"[-$0-9A-Z_*%]+"))
(defn quoted-str [] (between #(string "''") #(regex #"[^']*") #(string "''")))

(defn sub-field [] (trim #(any label quoted-str)))

(defn- eol? [] (starts-with-re? #"\r?\n"))
(defn field [] 
  (when (not (or (eol?) (at-end?)))
    (when (starts-with? ";") (skip-over "\n"))
    (loop [sfs []]
      (let [sf (opt sub-field)]
        (if (opt #(trim comma))
          (recur (conj sfs sf))
          (conj sfs sf))))))

(defn record [] 
  (line-comments) 
  (let [ret (into [(trim label)] (multi+ field))]
    (any #(regex #"\r?\n") at-end?)
    ret))

(defn sgs [] (with-trim-off (wsp) (multi* record)))

【讨论】:

  • 哇,这非常简洁,谢谢!我在包含标签和注释但没有字段的输入行中收到错误。现在我得弄清楚你的魔法是如何运作的。
  • 上面的代码期望标签后至少有一个字段,因为它使用“multi+”。如果零字段是有效输入,请使用“multi*”而不是“multi+”。
  • 哎呀,我已经忽略了那个项目了。谢谢你的建议!!
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-01-29
  • 2013-04-08
  • 1970-01-01
  • 1970-01-01
  • 2013-07-31
  • 2011-02-08
相关资源
最近更新 更多