关于产品给我硬塞活,让我学会了确定有限状态机这件事

154 阅读14分钟

1624844482_534017.jpg 小贱正摸着鱼,产品突然窜出来:“来活了,小老弟”

小贱内心:“gun!别打扰我摸鱼水群”

实则。。。。。。两个月没发工资的小贱很是害怕:“您说我马上改完”。

产品:“就是啊,之前项目里面有个输入公式的地方客户觉得没有验证随便输入字符都能保存,现在呢。。。”

小贱:“现在呢。。。 就是要加上验证?”

产品:“对对对!很简单吧。这次没有为难你吧,多久能给我。”

小贱:“这还不简单吗。确定只需要验证表达式是否符合规则? 那给我1个小时即可,哦不3个小时(多要点好摸鱼啊)”

产品大哥很满意的走了。

表达式需求

事情是这样的,我是单独负责公司数据中台的前端开发,其中指标模块有个需求是前台输入一段符合条件的公式然后大数据根据这个公式做数据查询(大数据我又不懂,我只关心前端部分)所以对于这种敏捷开发的需求,我直接一个textarea组件没做任何验证处理。这不闲下来了产品开始找事了。具体完整的公式格式如下: [指标1] - [电脑功率] * 10 / 2 + (3 * [显卡最大功率])

看起来就是一个很普通的算数运算符公式,只是多了一个[XXXX]这样的一个类似变量的东西。

小贱:“呵! 3个小时?年轻,不就是算术公式吗,看我20分钟解决它。”

......

写bug中.....

一顿操作猛如虎,20分钟后小贱写出了第一个版本,一眼看去n多if else

小贱:“虽然写的像个屎山但是能跑了,交给老大看看”

老大:“去财务领工资吧,明天。。。”

小贱:“我去拖了两个月的工资终于要发了?”,小贱内心无比激动。

老大:“明天不用来了!哦,不对公司没钱发工资了,那你工资别领了,明天也不用来了,写的什么粑粑代码,单元测试跑了吗?公式前面多个正负号为啥能验证成功?为啥浮点数有三个小数点也能验证成功。再看看你这个代码,全是if else你让后面接手项目的同学怎么活?”

小贱被老大怼的没脸抬头,在座位上左思右想怎么才能有逻辑有简介的写这个验证了。

突然同事来了句:“这么还不简单用有限状态机FSM啊”(虽然小贱也是科班出身,但是大学的快乐时光吗,懂得都懂),于是小贱疯狂的查资料最终:

有限状态自动机(FSM "finite state machine" 或者FSA "finite state automaton" )是为研究有限内存的计算过程和某些语言类而抽象出的一种计算模型。有限状态自动机拥有有限数量的状态,每个状态可以迁移到零个或多个状态,输入字串决定执行哪个状态的迁移。

根据小贱自己的理解就是:具有有限的状态,每个状态能根据当前输入的字符做对应的状态跳转。

然后继续了解知道了什么是确定有限状态机(DFA)非确定有限状态机(NFA)他们都统称FSM,NFA这里不做过多阐述(目前还没有去研究)。一般NFA先要确定化为DFA然后在对DFA做最小化得到一个高效率的状态机(DFA最小化)

  • 这里主要是满足需求没有去做DFA最小化。

确定有限状态机的设计

根据公式示例:[指标名称1] - [指标名称2] * 10.5 / 2 + (3 * [其他指标名称]),我们来分析一下这个公式的字符符号

  1. 10:参与运算的数字
  2. +-*/%:算术运算符
  3. .:小数点
  4. (:左圆括号
  5. ):右圆括号(闭合括号)
  6. [:左中括号
  7. 指标名称1:括号中的字符(规定中英文、数字和下划线组成)
  8. ]:右中括号(闭合中括号)
  9. 空格:\s

闲话: 为什么[、(]、)要分开他们不是一类符号吗?而+-*/%可以放在一起!很简单根据功能区分算术运算符作用就是运算,而括号()[]是一体必须成对出现,但是拆开作用就不一样一个表示开始一个表示结束。如果标识成一起很难区分对应的功能。

再来分析一下公式的合法条件和非法条件([n]中的内容对应上面拆分符合的编号)

  • 必须有运算符:23 * 30, 并且是完整的公式结构, [指标1] - [电脑功率] * 这种就是非法的
  • 只有中括号中的内容是[2],其他地方出现[2]都是非法的
  • 公式不能以[3, 6, 7]开头。允许+、—号开头但是只能出现一次
  • []中的字符必须满足[7]条件,不允许出现空格。
  • 算数运算符不能连续出现
  • '('和')','['和']'必须成对出现
  • ()中必须是完整公式 或者 单个运算单元不能为空

其实也就正常的加减乘除算术运算多了一个[XXX]的规则。

结合上述DFA知识和公式规则,我们可以简单的画出DFA(真的是第一次用DFA各位大佬轻喷)

20221216173306.png

状态解释

  • 0 初始状态
  • 1 整数:'23' '0'
  • 2 缺少右运算单元:'10 + ' '[指标1] -'
  • 3 由数字结尾的完整公式:'[指标1] + 4' '6 + 44'
  • 4 不完整浮点数:'0.' '3.'
  • 5 浮点数:'0.5' '3.2'
  • 6 由浮点数结尾的完整公式(非完整浮点数):'23 * 4.' '[名称] + 0.'
  • 7 由浮点数结尾的完整公式:'10 + 23.4' '[名称] + 0.234'
  • 8 单个变量表达式开始:[
  • 9 单个变量表达式有内容:[指标
  • 10 单个完整运算单元: '10 ' '[指标名称]' ‘23.5 ’
  • 11 右运算单元为量表达式开始'10 + ['
  • 12 右运算单元为量表达式未结束'10 + [指标名称'
  • z 最终条件 - 完整公式

这边由于判断规则的DFA所以状态要全一点,包括了:单个整数单个浮点数单个完整运算单元非完整表达式等状态,状态划分的比较细(应该还有更优状态划分,小贱这个笨大脑只能到这了,哈哈哈哈)。其中3、7、Z终止条件也就是这些状态满足表达式所有要求。


值得注意的是'('和')'的处理,和程序代码一样他是可以嵌套的如果要把这个带入单一固定状态那么可能一本笔记本都不够你写的,这里说的有点晦涩难懂,可以把他当作if {}中的{}他是有自己的作用域的,里面包裹也是一个完整的公式,括号里面还可以嵌入括号操作。所以这没用特定的状态标识他的状态不然那样可是灾难。处理'('和')'时候只记录出现的个数通过个数来判断当前括号有没有完整闭合。

最后将DFA转换成二元数组

绘制DFA表格

绘制成表格直观的表示各个状态的跳转关系

状态/事件0123456789101112Z
11133557799x1212x
20 (最多一个)2x2x2x2xx27x2
3x4x6xxxxxxxxxx
40x2xxxxxxxxxxx
5x10xZx10xZxxxxxZ
68x11xxxxxxxxxxx
7xxxxxxxx99x1212x
8xxxxxxxxx10xxZx
90102Zx10xZxx10xxZ

废话不多说直接上代码 (Z改用13表示,方便运算)

// 定义一个表达式
const expression = '+[指标1] - [指标2] * 10 - 2 + (3 * [指标3])'

let currentState = 0 // 当前状态

let isStartSignSymbol = false // 正负号开头

const parenthesiStack = [] // 小括号栈

// DFA二维数组
const DFA = [
  [1, 1, 3, 3, 5, 5, 7, 7, 9, 9, 'x', 12, 12, 'x'],
  [0, 2, 'x', 2, 'x', 2, 'x', 2, 'x', 'x', 2, 7, 'x', 2],
  ['x', 4, 'x', 6, 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x'],
  [0, 'x', 2, 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x'],
  ['x', 10, 'x', 13, 'x', 10, 'x', 13, 'x', 'x', 'x', 'x', 'x', 13],
  [8, 'x', 11, 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x'],
  ['x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 9, 9, 'x', 12, 12, 'x'],
  ['x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 'x', 10, 'x', 'x', 13, 'x'],
  [0, 10, 2, 13, 'x', 10, 'x', 13, 'x', 'x', 10, 'x', 'x', 13]
]

定义字符判断正则

const parenthesiStack = [] // 小括号栈

const numericRE = /[0-9]/ // 数值正则

const operatorRE = /[+\-*\/%]/ // 运算符正则

const indicatorNameRe = /[a-zA-Z\u4e00-\u9fa5_]/ // 指标名称正则

const spaceRE = /\s/

定义字符识别器

// 识别器规则
const identifierRules = [
  {
    code: 1,
    ruleType: 'reg',
    reg: numericRE
  }, {
    code: 2,
    ruleType: 'reg',
    reg: operatorRE
  }, {
    code: 3,
    ruleType: 'judgment',
    word: '.'
  }, {
    code: 4,
    ruleType: 'judgment',
    word: '('
  }, {
    code: 5,
    ruleType: 'judgment',
    word: ')'
  }, {
    code: 6,
    ruleType: 'judgment',
    word: '['
  }, {
    code: 7,
    ruleType: 'reg',
    reg: indicatorNameRe
  }, {
    code: 8,
    ruleType: 'judgment',
    word: ']'
  }, {
    code: 9,
    ruleType: 'reg',
    reg: spaceRE
  }
]

/**
 * 字符识别器
 * @param {string} key 
 * @return {number} code
 */
const identifier = (key) => {
  let code = null
  identifierRules.some(rule => {
    let res = false
    if (rule.ruleType === 'reg') {
      res = rule.reg.test(key)
    } else if (rule.ruleType === 'judgment') {
      res = key === rule.word
    }
    code = res ? rule.code : code
    return res
  })
  return code
}

然后遍历表达式字符串

for (let i in expression) {
  const code = identifier(expression[i])
  
  // 如果是“(”则推入栈中
  if (code === 4) {
    isStartSignSymbol = false
    parenthesiStack.push({
      index: i,
      char: '(',
      code
    })
  }
  // 如果是“)”则讲栈中弹出最近一次push的item
  if (code === 5) {parenthesiStack.pop()}
   
  // 如果是表达式开始位置且输入类型为2(+-*/),处理特殊情况开头带正负号的表达式
  if (code === 2 && currentState === 0) {
    // 判断是否为正负号
    if (expression[i] === '+' || expression[i] === '-') {
      // 判断有没有输入过正负号
      if (isStartSignSymbol) currentState = 'x'
      else isStartSignSymbol = true
    } else currentState = 'x'
  }
  currentState = DFA[code - 1][currentState] ?? 'x'
}

最终通过判断currentState的值是否为3、 7、 13parenthesiStack小括号栈的lenth来判断输入的表达式是否满足需求

简单跑了下单元测试

const expression = '[指标1] - [电脑功率] * 10 - 2' // true
const expression = '+[指标1] - 2' // true
const expression = '++[指标1] - [电脑功率]' // false
const expression = '[指标1]' // false
const expression = '([指标1] - [电脑功率]) * 10 - 2' // true
const expression = '[指标1 ] + 3' // false
const expression = '[指标1] + 3()' // false
const expression = '[指标1] + 3)' // false

整完这些已经3个小时了

小贱拿着这份代码找老大codeReview

老大:“整挺好,看来你又行了!”

小贱:“嘿嘿!我给产品看吧”

小贱信心满满找到产品

产品看完效果左思右想:“小贱啊!看来你还是不理解我的意思啊!”

小贱:“嗯?这不是做出来了吗?”

产品:“结果是出来了,但是啊,你得告诉客户哪里出错了对吧,你这人不上道啊!”

88e50770349b949338340ef956755960.png

此时小贱眼睛充满杀气,想了想还有两个月工资没发又别了回去,头也不回的丢了一句“我还会再回来的”

产品满意的点了点头,露出一脸邪笑


显示具体的错误信息

当我们输入一个不满足条件的表达式时,我们要做的可能不仅仅是验证表达式的格式是否正确。为了更好的用户体验,我们需要将表达式中的不满足的错误信息展示出来

当前输入不满足规则时我们,我们只需要记录上一个状态然后更具当前输入类型判断即可,例如:上一个状态是12解析出来的表示是12 + [指标,然后当前输入的是一个空格不满足条件,我们即可抛出错误信息“'[]'运算单元中间不能出现汉字、大小写字母、数字和_以外的字符”。

我们将状态和输入类型组合枚举出来

// 定义错误类型
const errorTypes = {
  illegalCharError: (key) => `"${key}"再当前位置为非法字符`,
  outOfPlaceError: (key) => `"${key}"不能出现再当前位置`,
  operatorError: () => '运算符后面不能再出现运算符',
  floatError: (key) => `当前位置只能是数字,但是接收到的是“${key}”`,
  noContentError: () => '"[]"中不可为空',
  spaceError: () => '空格不可出现再此处',
  
  notAnExpression: () => '这不是一个表达式',
  incompleteExpression: () => '表达式不完整',
  incompleteFloatNumber: () => '表达式末尾浮点数不完整',
  missingParenthesis1: () => '表达式结尾位置缺少“]”',
  missingParenthesis2: (index) => `第${Number(index) + 1}字符位置的"("未闭合`,
}

const errorStateMap = {
  0: {
    2: errorTypes.illegalCharError,
    3: errorTypes.outOfPlaceError,
    7: errorTypes.illegalCharError,
    8: errorTypes.illegalCharError
  },
  1: {
    4: errorTypes.outOfPlaceError,
    6: errorTypes.outOfPlaceError,
    7: errorTypes.illegalCharError,
    8: errorTypes.illegalCharError
  },
  2: {
   2: errorTypes.operatorError,
   3: errorTypes.outOfPlaceError,
   5: errorTypes.outOfPlaceError,
   7: errorTypes.illegalCharError,
   8: errorTypes.outOfPlaceError,
  },
  4: {
   2: errorTypes.floatError,
   3: errorTypes.floatError,
   4: errorTypes.floatError,
   5: errorTypes.floatError,
   6: errorTypes.floatError,
   7: errorTypes.floatError,
   8: errorTypes.outOfPlaceError,
   9: errorTypes.illegalCharError
  },
  5: {
    3: errorTypes.outOfPlaceError,
    4: errorTypes.outOfPlaceError,
    6: errorTypes.outOfPlaceError,
    7: errorTypes.illegalCharError,
    8: errorTypes.illegalCharError
  },
  8: {
    2: errorTypes.illegalCharError,
    3: errorTypes.illegalCharError,
    4: errorTypes.illegalCharError,
    5: errorTypes.illegalCharError,
    6: errorTypes.illegalCharError,
    8: errorTypes.noContentError,
    9: errorTypes.spaceError,
  },
  9: {
    2: errorTypes.illegalCharError,
    3: errorTypes.illegalCharError,
    4: errorTypes.illegalCharError,
    5: errorTypes.illegalCharError,
    6: errorTypes.illegalCharError,
    9: errorTypes.spaceError,
  },
  10: {
    1: errorTypes.illegalCharError,
    3: errorTypes.illegalCharError,
    4: errorTypes.illegalCharError,
    6: errorTypes.illegalCharError,
    8: errorTypes.illegalCharError
  },
  13: {
    1: errorTypes.illegalCharError,
    3: errorTypes.outOfPlaceError,
    4: errorTypes.outOfPlaceError,
    5: errorTypes.outOfPlaceError,
    6: errorTypes.illegalCharError,
    7: errorTypes.illegalCharError,
    8: errorTypes.illegalCharError
  }
}

Object.assign(errorStateMap, {
  3: errorStateMap[1],
  6: errorStateMap[4],
  7: errorStateMap[5],
  11: errorStateMap[8],
  12: errorStateMap[9]
})

上面肯定不是最佳方案,感觉到很啰嗦。但是稍微直观一点

Object.assign(errorStateMap, {....这里是合并一些拥有共同报错特性的状态

修改一下代码


// 新定义两个变量
.......
let prevState = 0 // 上一个状态

let errorMessage = null // 报错信息
.......

定义报错处理方法

function errorHandling(code, index, key, fn) {
  console.log(prevState)
  const errorFn = fn || errorStateMap[prevState]?.[code]
  errorMessage = errorFn ? `第${Number(index) + 1}个字符位置,` + errorFn(key) : '当前表达式无效'
}

修改一下for循环

for (let i in expression) {
  const key = expression[i]
  const code = identifier(key)

  // 如果是“(”则推入栈中
  if (code === 4) {
    isStartSignSymbol = false
    parenthesiStack.push({
      index: i,
      char: '(',
      code
    })
  }
  
  // 如果是“)”则讲栈中弹出最近一次push的item
  if (code === 5) {parenthesiStack.pop()}

  // 如果是表达式开始位置且输入类型为2(+-*/) ,处理特殊情况开头带正负号的表达式
  if (code === 2 && currentState === 0) {

    // 判断是否为正负号
    if (['+', '-'].includes(key)) {
      // 判断有没有输入过正负号
      if (isStartSignSymbol) {
        currentState = 'x'
        errorHandling(code, i, key, errorTypes.operatorError)
        break
      } else isStartSignSymbol = true
    } else currentState = 'x'
  }
  currentState = DFA[code - 1][currentState] ?? 'x'
  if (currentState !== 'x') prevState = currentState
  else {
    errorHandling(code, i, key)
    break
  }
}

for循环中能直接检测解析中的错误信息,但是有些表达式有头没尾列如:[指标1] + 3 + ,在循环中无法对他进行判别。因为循环已经结束了哈哈哈哈,所以我们要在外面单独判断,很简单当循环结束肯定停留在某个状态,如果这个状态不满足结束条件,那么这个表达式是无效的或者不符合规定的。

外层状态判断

const stateValid = [3, 7, 13].includes(currentState)

const parenthesiValid = !parenthesiStack.length

const valid = stateValid && parenthesiValid

if (!stateValid) {
  switch(currentState) {
    case 0: 
      errorMessage = errorTypes.notAnExpression()
      break;
    case 1:
      errorMessage = errorTypes.notAnExpression()
      break;
    case 2:
      errorMessage = errorTypes.incompleteExpression()
      break;
    case 4:
      errorMessage = errorTypes.notAnExpression()
      break;
    case 5:
      errorMessage = errorTypes.notAnExpression()
      break;
    case 6:
      errorMessage = errorTypes.incompleteFloatNumber()
      break;
    case 8:
      errorMessage = errorTypes.notAnExpression()
      break;
    case 9:
      errorMessage = errorTypes.notAnExpression()
      break;
    case 10:
      errorMessage = errorTypes.notAnExpression()
      break;
    case 11:
      errorMessage = errorTypes.notAnExpression()
      break;
    case 12:
      errorMessage = errorTypes.missingParenthesis1()
      break;
  }
} else if (!parenthesiValid) {
  
  errorMessage = errorTypes.missingParenthesis2(parenthesiStack.pop().index)
}

return { valid, error: errorMessage }

验证一下输出

const expression = '[指标1] + 3 + 4.4' // {valid: true, error: null}
const expression = '[指标1] + 3 + ' // {valid: false, error: '表达式不完整'}
const expression = '++[指标1] + 3 + 4.4' // {valid: false, error: '第2个字符位置,运算符后面不能再出现运算符'}
const expression = '[指标1] + (3 + 4.4' // {valid: false, error: '第9字符位置的"("未闭合'}
const expression = '[指标1 ]' // {valid: false, error: '第5个字符位置,空格不可出现再此处'}
......

交作业

感觉写的差不多了,小贱怀着忐忑的心情找到产品。 产品使用后脸上露出了洋溢的笑容说道:“可以,可以!不错小伙子!虽然不算完美但是也达到了我的需求。”

小贱听完开心的笑了,说:“那这个需求是不是可以闭环了”

产品:“其实吧!哥也不是为难你。你也知道思绪是‘亿’点点释放的,就在刚才我又想到一个关于这个优化的需求。听说你最近很闲顺便做了吧。”

小贱:。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。

产品:“你做完我和你透露一下工资情况,算是对你的激励”

小贱:“哥,你说吧!啥需求?”

未完待续。。。。。。。。。

写到这里这是我第一篇技术输出文章,不足的地方还有很多。之前也一直想写但是自己不够自信(菜是真的菜)也比较懒(懒是真的没跑),搁浅了很多次。有人告诉我不在乎结果,想到什么就去做如果不做就等于纸上谈兵放嘴炮。所以有了这篇文章。

总结一下,其实我感觉我写出来的东西自己都没办法去读,不足的地方比比皆是,可能技术相关的内容都占不了40%,其余全部都是情景对话的方式其实主要是为了让读者不枯燥。(这个也是参考其他博主,个人觉得看起来挺有劲的)

代码部分感觉啰嗦的地方很多,而且都不是最优的写法,希望后面能够再接再厉。欢迎各位大佬指正不对的地方