likes
comments
collection
share

划词自动生成正则表达式

作者站长头像
站长
· 阅读数 12

我想,这个标题应该很难吸引到人吧...

需求分析

在一个字符串中选中一段子字符串,然后根据选中的子字符串自动生成一个正则表达式来匹配内容。

这类需求场景比较少,通常在对文本内容进行自定义提取的时候才会用到,而且文本内容都会遵循一定的数据格式。举个例子在日志后台系统中,我需要对如下一段日志文本进行内容提取:

09-28 18:51:03.886878 8543 message:query failed after returning 7 values (BAD_INDEX)

10-02 18:53:03.348955 4082 message:some error.

10-02 18:53:04.4545433 3451 message:葵花宝典

10-02 18:53:06.6545  10234 message:@#¥%&**&¥&@¥%*&……

用户1需要提取每条日志中的日期、时间、以及具体日志内容,而用户2只需要提取时间和具体日志内容。这就产生了差异化需求,我们就需要根据用户的需求进行自定义生成正则表达式对内容进行匹配。

需求分析会后...

sss(某前端):“xxx(某后端),这个你能实现吗?反正我只调接口的。”

xxx(某后端)可能已经在心里开始骂骂咧咧了。

if(你是某前端) return "恭喜你🎉,这篇文章看到这里就结束了。" ;

前后端同学都可以做,身为前端,我只是觉得挺有意思的,就研究了一下。

前置正则知识储备

这里只简单列一下该文章主要用到的核心正则知识,如果正则表达式相关知识有所遗忘的同学建议最好是找官方文档简单的温故一遍。

字符含义
*匹配前一个表达式0次或多次。 例如: /so*/会匹配“the author is soooo handsome”中的“soooo”,不会匹配 “that's a joke”中的任何内容。
[^abc]一个否定类,匹配除了abc之外的任意字符。 例如:/[^abc]/将首先匹配“abcde”中的“d”,如此类推,/[^]*/ 将匹配0个或多个任意字符。
x(?=y)先行断言,xy跟随时匹配x。 例如:/David(?=Nancy)/将会匹配到“DavidNancy“中的David,匹配不到“DavidGrace”中的David。
(?<=y)x后行断言,x跟随y的情况下匹配x。 例如:/(?<=Nancy)David/将会匹配到“NancyDavid“中的David,匹配不到“GraceDavid”中的David。
(?<Name>x)具名匹配,匹配到的x放入返回值groups字段下的Name属性中。 例如:/?<boy>David/将匹配“David,Nancy,Grace”中的"David"放入到 {boy:'David'},groups字段是match方法的返回值中的一个属性。

有一说一,x和y真是两个老六。

实现思路

上岸第一步,先画饼,啊呸,先定目标。

根据需求先定义一个方法,设计它的入参和出参数,我的设想如下:

【入参】

  • templateStr : string

    划词的模版字符串,例如:"09-28 18:51:03.886878 8543 message:query failed after returning 7 values (BAD_INDEX)"。

  • options : Option[]

    划中的词以及提取的字段名称数列,例如:

    [
       {
           name:"date", // 字段名
           value:"09-28" // 划中的词
       },
       {
           name:"time", 
           value:"18:51:03"
       },
       {
           name:"message",
           value:"query failed after returning 7 values (BAD_INDEX)"
       }
    ]
    

【出参】

返回一个正则表达式对象。

代码结构如下:

/**
 * 生成正则表达式
 * @param {*} templateStr 目标字符串
 * @param {*} options 定义的参数名以及提取的内容
 * Option : {
 *   name:"字段名",
 *   value:"设定的字段值",
 * }
 * @returns 返回一个正则表达式对象
 * 例如:
 * generateRegexp(
 *   '09-28 18:51:03.886878 8543 message:query failed after returning 7 values (BAD_INDEX)',
 *   [
 *     {
 *       name:"date", // 字段名
 *       value:"09-28" // 划中的词
 *     },
 *     {
 *       name:"time",
 *       value:"18:51:03"
 *     },
 *     {
 *       name:"message",
 *       value:"query failed after returning 7 values (BAD_INDEX)"
 *     }
 *    ]
 *  )
 */
function generateRegexp(targetStr, options = []) {
  ...
  return new RegExp(regexp)
}

照猫画虎,先生成一个正则表达式在模版字符串(templateStr)中将划中的内容都匹配出来。

模版字符串如下:

09-28 18:51:03.886878 8543 message:query failed after returning 7 values (BAD_INDEX)

正则表达式的大致结构如下:

n个字符 + 划词 + n个字符 + 划词 + n个字符 + 划词 + n个字符
  1. 将"n个字符"换成[^]*

  2. 将"划词"换成具名匹配(?<${option.name}>(${option.value}))

得到如下正则表达式:

const regexp = /[^]*(?<date>09-28)[^]*(?<time>18:51:03)[^]*(?<message>query failed after returning 7 values \(BAD_INDEX\))[^]*/

注意:【message】划词中有两个括号需要用\进行转译。

测试一下:

const templateStr = "09-28 18:51:03.886878 8543 message:query failed after returning 7 values (BAD_INDEX)"
const options = [{
    name: "date", // 字段名
    value: "09-28" // 划中的词
  },
  {
    name: "time",
    value: "18:51:03"
  },
  {
    name: "message",
    value: "query failed after returning 7 values (BAD_INDEX)"
  }
]

const regexp = /[^]*(?<date>09-28)[^]*(?<time>18:51:03)[^]*(?<message>query failed after returning 7 values \(BAD_INDEX\))[^]*/;

const result = templateStr.match(regexp).groups;
console.log(result);

// result结果如下:

{
  date: '09-28',
  time: '18:51:03',
  message: 'query failed after returning 7 values (BAD_INDEX)'
}

初步实现generateRegexp方法:

function generateRegexp(templateStr, options = []) {
  let regexp = '[^]*';

  for (option of options) {
    regexp += `(?<${option.name}>(${option.value}))`;
    regexp += '[^]*';
  }
  return new RegExp(regexp);
}

目前这个方法生成的正则还是匹配不出来的,因为还没有对划词中的特殊字符进行处理。

接下来将【划词内容转换成子正则】

在实现这个功能的前提下,通常也需要设定一套划词规则。划词内容是由用户决定的,它可能包含任意字符,包括特殊字符,生成的正则表达式要从模版字符串中准确无误的匹配出值来,那就要找到这个划词内容的特殊之处,基础依据就是划词内容前后字符为边界进行判断,这就产生了规则,规则约束用户进行划词,例如:

【规则一】: 规定用户只能划前后有特殊字符的子字符串内容,否则在代码中抛出异常报错。

【例子】举个有特殊边界的划词例子,如:划词【time】前后字符分别是空格(‘ ’)和点(‘.’),这个特征在整个模版字符串中只有‘18:51:03’这个子字符串符合,那么这个划词就是规则允许的(唯一的)。

先按照这个思路实现接着往下实现,找出划词内容的前后边界,并进行判断,如果有特殊边界,就可以在前后加上断言,如果没有,则进行抛错。

【粗糙demo完整代码如下】:

// 特殊字符
const specialCodeArry = [
  '!',
  '@',
  '#',
  '$',
  '%',
  '^',
  '*',
  '(',
  ')',
  '_',
  '-',
  '=',
  '+',
  '\\s',
  ':',
  ';',
  '\'',
  '\"',
  '<',
  '>',
  ',',
  '\\.',
  '/',
  '?'
]

/**
 * 生成正则表达式
 * @param {*} templateStr 目标字符串
 * @param {*} options 定义的参数名以及提取的内容
 * Option : {
 *   name:"字段名",
 *   value:"设定的字段值",
 * }
 * @returns 返回一个正则表达式对象
 * 例如:
 * generateRegexp(
 *   '09-28 18:51:03.886878 8543 message:query failed after returning 7 values (BAD_INDEX)',
 *   [
 *     {
 *       name:"date", // 字段名
 *       value:"09-28" // 划中的词
 *     },
 *     {
 *       name:"time",
 *       value:"18:51:03"
 *     },
 *     {
 *       name:"message",
 *       value:"query failed after returning 7 values (BAD_INDEX)"
 *     }
 *    ]
 *  )
 */
function generateRegexp(templateStr, options = []) {
  let regexp = '[^]*';

  for (option of options) {
    regexp += `(?<${option.name}>(${generatePartRegexp(templateStr,option)}))`;
    regexp += '[^]*';
  }
  return new RegExp(regexp);
}

/**
 * 根据划词内容生成子正则表达式
 * @param {*} templateStr 目标字符串
 * @param {*} option 设定的划词对象
 * @returns 返回一个字符串
 */
function generatePartRegexp(templateStr, option) {
  const partStr = option.value;
  // 先获取当前划词的前后边界下标
  const index = templateStr.indexOf(partStr);
  const end = index + partStr.length;
  // 获取划词前后的字符。
  const headAndTailArray = [];
  // 前边界
  if (index === 0) {
    // 字符串开头,初始化为null
    headAndTailArray.push(null)
  } else {
    headAndTailArray.push(convertStrToRegStr(templateStr.substring(index - 1, index)))
  }

  // 后边界
  if((end + 1) === templateStr.length){
    headAndTailArray.push(null)
  }else{
    headAndTailArray.push(convertStrToRegStr(templateStr.substring(end, end + 1)));
  }
  
  const hasSpecialBoundary = headAndTailArray.some(item => specialCodeArry.includes(item));
  // 规则一: 选中的值前后要存在边界(目前对边界的定义为特殊字符串)
  if (!hasSpecialBoundary) {
    throw new Error(`${option.name} 设定的值有误,无法定义边界,请重新设定或设置长度限制`);
  }

  let regexp = headAndTailArray[0] ? `(?<=${headAndTailArray[0]})` : '^';

  if(headAndTailArray[1]){
    // 非贪婪匹配,匹配到了边界字符即停止(不匹配包含边界的特殊字符)。
    regexp += `[^${headAndTailArray[1]}]`
  }else{
    regexp += '[^]'
  }
  regexp += '*';
  regexp += `(?=${headAndTailArray[1]})`;
  return regexp;
}

/**
 * 将特殊字符转换成正则写法的字符串
 * @param {*} code 特殊字符
 * @returns 返回一个正则写法的字符
 */
function convertStrToRegStr(code) {
  let resultCode = '';
  switch (code) {
    case ' ':
      resultCode = '\\s'
      break;
    case '.':
      resultCode = '\\.'
      break;
    case '^':
      resultCode = '\\^'
      break;
    default:
      resultCode = code;
      break;
  }
  return resultCode;
}

进行测试一下:

const templateStr = "09-28 18:51:03.886878 8543 message:query failed after returning 7 values (BAD_INDEX)"
const options = [{
    name: "date", // 字段名
    value: "09-28" // 划中的词
  },
  {
    name: "time",
    value: "18:51:03"
  },
  {
    name: "message",
    value:"query failed after returning 7 values (BAD_INDEX)"
  }
]

const regexp = generateRegexp(
  templateStr,
  options
)

console.log('regexp', regexp)

const testStr01 = '10-02 18:53:03.348955 4082 message:some error.'
const testStr02 = '10-02 18:53:04.4545433 3451 message:葵花宝典'
const testStr03 = '10-02 18:53:06.6545  10234 message:@#¥%&**&¥&@¥%*&……'

const result01 = testStr01.match(regexp).groups;
const result02 = testStr02.match(regexp).groups;
const result03 = testStr03.match(regexp).groups;

console.log(result01);
console.log(result02);
console.log(result03);

// -----------------结果如下------------------

regexp /[^]*(?<date>(^[^\s]*(?=\s)))[^]*(?<time>((?<=\s)[^\.]*(?=\.)))[^]*(?<message>((?<=:)[^]*(?=)))[^]*/</message>

{
  date: '10-02',
  time: '18:53:03',
  message: 'some error.'
}

{
  date: '10-02',
  time: '18:53:04',
  message: '葵花宝典'
}

{
  date: '10-02',
  time: '18:53:06',
  message: '@#¥%&**&¥&@¥%*&……'
}

结尾

看到这里大致思路就分享完了。

这个版本比较粗糙,里面有很多的漏洞,可以在这个基础上一步步完善更多的逻辑来实现更灵活匹配,最后给出一些优化思路,判断前后特殊字符是判断特殊边界的一种,还可以加上以下条件进行辅助判断边界:

  1. 划出的子字符串的前后边界在整个模板中存在多个同样边界的子字符串时,可以按照划词出现的顺序位置进行匹配。

提示:存在多个同样边界的异常报错可以在生成子正则表达式后对模板字符串进行全局匹配,如果匹配出多个结果,则说明不唯一,则可抛出异常报错,让用户重新划词。

  1. 判断划词内容中是否有特殊字符组成的特殊格式。

    例如:划词【date】内容中有个特殊字符(‘-’),这个特征在整个模版字符串中只有‘09-28’这个子字符串符合。

  2. 可以给Option配置项新增长度限制字段limit来进行长度匹配。

    例如:划词【date】"09-28"在模板"09-28 23 3 "中就可能匹配"09-28 23 3", 这时可以用长度限制截取。

总之,这是个需要长期维护并逐步完善的功能,驱动来源于不同的日志格式。内部判定边界越详细,划词功能灵活度越高。

谢谢观看。