How to read or debug rubocop source code

Actually, this is a general trick to understand any code: Find a concrete question -> get source code -> add breakpoints -> mess around -> observe the stacktrace…

Solid example

rubocop codebase is huge, what if I want to know how it works by observing one cop?

follow the steps bellow:

  1. clone the repo and cd into repo
  2. Run bundle install
  3. add breakpoint in source code, e.g.: add require 'pry'; binding.pry to lib/rubocop/cop/lint/shadowed_exception.rb
  4. find the executable of the gem, in this case exe/rubocop
  5. Run exe/rubocop --only Lint/ShadowedException tmp/shawdowed_exception.rb and mess around with that breakpoint
  6. Run caller.grep /rubocop/ to see stacktrace

btw, go ahead to see spec/tests is also a good option.

Have fun reading source code

20220804 update

use exe/rubocop --cache false --only Lint/ShadowedException tmp/shawdowed_exception.rb to avoid odd behaviors while debugging

Rubocop Inspired Learning

背景

第一次知道”rubocop”这个关键字并且开始用它, 是在一次分享”exception handling”后

当时分享的主题是, ruby里的exception类有的继承关系, 最常被捕获的异常应该是StandError及它的子类, 不该捕获Exception类 参考

最后的结论是, 希望大家了解一下 Exception相关类的继承关系, 捕获该被捕获的异常

分享的最后, 有人提到了rubocop, 我了解了一下, 才意识到, 这件事其实可以不用写代码的人主动去检查和注意, 完全可以交给lint程序自己检查

be rubocop --only Lint/RescueException

问题

系统变复杂后, 代码里几乎不可避免的出现了一大团异常处理的代码

尤其是在代码入口处, 一个个的rescue 看得头大, 有一次还因为没注意到一个更具体(specific)的异常被一个更通用(genral)的异常给遮蔽(shadowed 这个描述是后来知道的, 最初没有这个准确的概念)掉了, 好一阵debug才发现原因

这时候我想这种检查是不是也可以自动完成呢?

思路

问题 -> 需求 -> 自己实现? -> 查查已有工具 -> 找到已有工具 -> 验证 -> 有问题 -> 尝试解决/求助 -> ...

第一反应还是自己写个工具检查

但是多了个心眼, 先查查有没有已有的工具已经做了这件事?

事实证明这个问题早有人遇到过, 并且有了解决办法: be rubocop --only Lint/ShadowedException

既然已经有了工具, 那试着用用呗

结果不理想, 没法识别出restclient里被遮蔽的异常, 试了下看rubocop的代码, 一下看懵了, 暂时放弃

于是换了个更直接的方式: 给rubocop提issue: Lint/ShadowedException cop not working as expected for RestClient::RequestFailed exception

作者很快做了回复:

RuboCop is a static analysis tool, it doesn’t know inheritance relationships at runtime. It has false negatives for unknown inheritance relationships.

这里出现了一个不了解的概念: “static analysis tool”

经过 rubocop -> parser -> ast -> wiki: Abstract syntax tree -> wiki: Program analysis

才算意识到 “Program analysis can be performed without executing the program (static program analysis), during runtime (dynamic program analysis) or in a combination of both.”

  • 在 不执行代码的前提下 检查代码, 这种叫做 静态程序分析
  • 在 执行代码的前提下 检查代码, 这种叫做 动态程序分析

而rubocop是一个静态程序分析工具, RestClient::RequestFailed相关的异常只能在运行时确定(需要 require 'rest-client')

这下算是搞明白了作者回复的含义

学习路径

coding -> problem -> analysis: can be automated? -> tool available?

反思

  • 好的问题 + 兴趣 引向好的结果
  • 不要一上来就想着自己实现, 重复造轮子, 并且做出来的轮子不一定圆
  • 眼界要开阔, 多多求助
  • 动手尝试, 实践
  • 读wiki/manual要仔细一点

接下来的TODO

rubocop 里的 ShadowedException cop, 没法检查出需要运行时才能判断出的继承关系

但是他可以检测出 ruby里已有的一些异常(TODO 验证一下)

处理我的这个需求有两种方式

  1. 自己实现一个新的工具, 专门做这件事

    • 有点重新造轮子, 但是只要能问题就是好的(做好了demo)
  2. 把这个能力集成进ShadowedException里面, 这里涉及到比较难的问题是

    • 需要读懂rubocop的代码
    • rubocop是”static analysis tool”, 加入这种 运行时检查的功能, 似乎和他的定义冲突, 怎么处理这种关系?

ruby-script-encoding

ref: https://ruby-doc.org/core-3.0.0/Encoding.html#class-Encoding-label-Script+encoding

终于知道了ruby script里encoding这个magic comment是什么作用了

这个magic comment不是被编辑器解析的, 而是由ruby解释器解析的(not 100% sure)

All Ruby script code has an associated Encoding which any String literal created in the source code will be associated to.

The default script encoding is Encoding::UTF_8 after v2.0, but it can be changed by a magic comment on the first line of the source code file (or second line, if there is a shebang line on the first).
The comment must contain the word coding or encoding, followed by a colon, space and the Encoding name or alias:

The __ENCODING__ keyword returns the script encoding of the file which the keyword is written:

  • file1.rb

    1
    2
    3
    4
    # encoding: ASCII-8BIT

    p "hello".encoding # #<Encoding:ASCII-8BIT>
    p __ENCODING__ # #<Encoding:ASCII-8BIT>
  • file2.rb

    1
    2
    3
    4
    # encoding: UTF-8

    p "hello".encoding # #<Encoding:UTF-8>
    p __ENCODING__ # #<Encoding:UTF-8>

amg error handling

见识真的很重要, 如果我早点意识到”Error Handing”的正确做法是使用”Error Service”, 可以省下不少时间, 减少很多没有意义的成就感, 获得真正的提高

遇到的问题

在做AMG时, 经常在苦恼错误的处理:

  • 捕获了, 会收不到通知
  • 不捕获, 会老是收到提示
  • 打印出backtrace有那么长一串, 找不到关键信息
  • 而且每次都得查日志
  • 定时任务(whenever)失败了, 除了查日志没办法发现吗?

做过的尝试(浪费过的时间)

  • 在每个重要的rescue里, 增加企业微信机器人的通知
  • 增加error_logs表, 在rescue里记录错误信息, 并且专门给它写了个index页和show页面用于定位问题(当时还洋洋自得…)
  • Exception类增加monkey patch, 在每个rescue里, 打印或者记录e.to_hash
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class Exception
    def to_hash(request_id = nil)
    hash = {}
    hash[:request_id] = request_id if request_id
    hash[:class] = self.class.to_s
    hash[:message] = self.message
    hash[:backtrace] = self.app_backtrace

    return hash
    end

    def app_backtrace
    return self.backtrace.select {|trace| trace.include?(Rails.root.to_s)}
    end
    end
  • 由于错误信息太多, 做过几轮收敛, 忽略不重要的错误
  • 为sidekiq的错误信息写了个error_handler, 里面打印错误信息和记录error_log, 这步倒问题不大
  • … 记不清了

正确的做法

https://www.mikeperham.com/2013/08/25/please-use-an-error-service/

error handling这个问题的解决方案是: 使用 bugsnag 之类的Error Service

应用里不该捕获所有的unknown exceptions, rescue StandardError => e 甚至 rescue Exception => e 都是不可取的

错误应该被暴露出去, 然后及时修复; 由于错误被吞掉导致的问题很让人恼火, 而且有时候会很难排查

使用 Error Service(以bugsnag为例) 有什么好处呢?

  • 代码只需要处理已知的异常, 会干净一些
  • 意料外的异常可以报告给bugsnag, 可以触发通知, 查看backtrace, error trending…
  • 可以直接在bugsnag上创建bug, 分配给研发团队处理
  • 不用再在日志里grep error

对于难发现定时任务的报错, 其实也有解决方案: 使用sidekiq的插件 sidekiq-scheduler, 放弃linux的cron服务

有什么好处呢?

  1. sidekiq-scheduler 沿用了 sidekiq的web页面, 增加了一个tab, 显示定时任务的状态等信息, 一目了然
  2. 可以在页面上触发定时任务, 不需要登录服务器
  3. 如果出错了, 可以在页面上看到(和失败的sidekiq任务类似), 并且可以利用sidekiq的重试机制

反思

  • 如果我早点理解这些问题的正确解法, 可以少花不少时间在上面; 可惜不知为什么, 竟然没有人提醒我, 大概是因为我只顾埋头自己干, 太少请教其他人
  • 为什么 在我看到 https://www.mikeperham.com/2013/08/25/please-use-an-error-service/ 这篇文章后仍然没有意识到 error service 是什么意思, 能怎么解决我的问题? 大概是我看到了新的名词, 没有主动去做了解, 没有把大佬的话放在心上导致的
  • 为什么sidekiq-scheduler 这样比较成熟的解决方案, 我竟然很久之后才发现? 大概是搜索技能和好奇心的缺失: 如果在 https://rubygems.org/ 上搜索一下 “sidekiq”, 会发现一堆有趣的插件; 如果我真的遇到了项目中的痛点, 要明确自己想要什么, 然后去搜索解决方案, 我遇到的问题, 大概率其他人也会遇到
  • 陷入了”有一把锤子, 看谁都是钉子”的怪圈: 当时的场景中, 太熟悉通过 企业微信机器人 发消息的机制, 没有细想事情的正确做法应该是什么样的

ruby-prepend

最近在项目代码里看到了 prepend 的用法, 读了文档, 基本明白了它的用法; 但是对于说它是为了解决之前 alias_methods 的问题的这部分讨论还没看明白(可能是我没怎么用过alias_method)

在我的理解中这几点比较关键:

  1. 和ruby其他的hook(inherited included…)类似, prepend 也有自己的hook: prepended
  2. prepend把模块里的方法插入到方法查找链里面, 可以覆盖当前类里的方法定义

示例

  • 定义一个普通的类, run方法会遍历并打印参数, 然后返回结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Service
# perform some real work
def run(args)
args.each do |arg|
puts "arg: #{arg}"
end
{result: "ok"}
end
end

p Service.new.run(["888", "999"])
# arg: 888
# arg: 999
# {:result=>"ok"}
  • 如果给它prepend一个module ServiceDebugger, 里面定义一个同名的方法, 这个模块里的方法定义会覆盖 Service 里的方法定义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
module ServiceDebugger
def run(args)
puts "Service run start: #{args.inspect}"
end
end

class Service
prepend ServiceDebugger

# perform some real work
def run(args)
args.each do |arg|
puts "arg: #{arg}"
end
{result: "ok"}
end
end

p Service.new.run(["888", "999"])
# Service run start: ["888", "999"]
# nil
  • module ServiceDebugger 里面可以调用super方法, 调用原始定义的方法, 类似 around_hook 里的 yield
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
module ServiceDebugger
def run(args)
puts "Service run start: #{args.inspect}"
result = super
puts "Service run finished #{result}"
{new_result: "ok"}
end
end

class Service
prepend ServiceDebugger

# perform some real work
def run(args)
args.each do |arg|
puts "arg: #{arg}"
end
{result: "ok"}
end
end

p Service.new.run(["888", "999"])
# Service run start: ["888", "999"]
# arg: 888
# arg: 999
# Service run finished {:result=>"ok"}
# {:new_result=>"ok"}
  • 可以用这种机制, 在不修改原有代码的情况下, 增强原有的方法, 例如 增加日志; super前的代码类似于 before_hook, 之后的代码类似于after_hook

  • 此外, 可以在被prepend的模块里定义prepended, 里面实现一些自动触发的行为

1
2
3
4
5
6
module ServiceDebugger
def self.prepended(mod)
puts "#{self} prepended to #{mod}"
end
# ...
end

是魔法吗? 不确定; 不知道其他语言有没有这么多hook 和灵活的用法

2022-07-09 09:47:43 update

不是魔法, hook的本质大概是在模块/类的生命周期中 提前定义好”如果存在则会被调用的代码块”, 从外部看来就是hook了

用这种视角来看, 其他语言肯定也可以实现

核心大概是: “生命周期” 和 “封装”

很好例子就是 activerecord 里的 “The Object Life Cycle”

清晰的定义 配合良好的封装, 使得activerecord的hook使用起来很方便

记录 从sublime搜索结果显示为binary引发的思考和行动(WIP)

昨天加今天, 基本搞明白了一个困扰我很久的问题, 有很多收获, 在这里记录一下

最初的问题

  • 有一次在代码库里搜索一个关键字, sublime的结果显示搜索到了, 但是没有preview, 还把那个文件标为了”binary”, 就像这样
  • 当时初步定位到原因是 那个文件里包含了一个 ASCII control characters 里的 BS(backspace), 它在编辑器里渲染的不是正常的字符, 所以能看出它是特殊的, 但是当时对unicode/ascii 和字符编码都了解很少, 不知道是什么意思, 发现把这个字符删掉, 能让搜索结果恢复正常
  • 为什么这种字符让文件的搜索结果变成了binary?
  • 这些个特殊字符是什么?
  • 怎么能快速定位到他们?

学习过程

使用TDD实现这个CharDetector的体会和反思

感觉有价值的的参考资料

PS. 越写越长可不是好习惯!

回答最初问题

WIP

analyse-git-commit-count

INTRO

最近捣鼓出一个小工具, 用来分析某个git仓库里所有人的commit, 看看某个人在这个仓库里做过多少提交

虽然统计结果不可能完全准确, 但是足够满足好奇心了

核心: git shortlog --summary --numbered --email --all

SETUP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
require 'pry'
require 'csv'
require 'tempfile'

def write_output_as_csv(output, output_csv_file_path)
headers = output.first.keys

CSV.open(output_csv_file_path, "w") do |csv|
csv << headers

output.each do |hash|
csv << hash.values
end
end
end

def datasource
`git shortlog --summary --numbered --email --all`.split("\n")
end

def output
records = []

datasource.each do |line|
result = line.match(/(\d{1,})(.*)(<.*>)/)

record = {}
record[:email] = result[3].strip
record[:count] = result[1].strip

records << record
end

deduplicate(records)
end

def deduplicate(arr)
arr.group_by {|r| r[:email]}.each_with_object([]) do |(email, records), mem|
mem << {email: email, count: records.map {|record| record[:count].to_i}.sum}
end.sort_by {|record| record[:count]}.reverse
end

def run
Tempfile.create(["gitinfo.", ".csv"]) do |file|
filepath = file.path

write_output_as_csv(output, filepath)

File.readlines(filepath).drop(1).each {|line| puts line}
end
end

run

HAVE FUN

# cd to any git repo
ruby $ABS_PATH/analyse-git-info.rb | uplot bar -o -d, -t "Git commit count of user"

TODO

  • 这个小工具做成gem
  • 用rspec写测试(练习…)
  • 怎么能使得它接收 stdin 也能正常工作呢?

RestClient Issue

记录一下使用RestClient这个Gem时遇到的一个坑.

版本: rest-client (2.1.0)

TLDR

如果直接使用封装过的 RestClient.get/RestClient.put/RestClient.post 等方法, 当遇到异常的响应时(比如400 bad request), 得不到任何有用的信息

应该使用带块的方式调用, 用块参数接收 response, request 和 result, 不使用块会导致异常时丢失信息

示例

用rails准备一个简单的接口, 响应 400, 并返回错误的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# config/routes.rb
post 'demo', to: "demo#test"

# app/controllers/demo_controller.rb
class DemoController < ApplicationController
def test
render :json => {msg: 'this msg explains why this is a bad request'}, :status => :bad_request
end
end

# 进入 rails console

# case1: 不用块
[23] pry(main)> RestClient.post("localhost:3000/demo", {}) # => nil
# RestClient::BadRequest: 400 Bad Request
# from /Users/lijunwei/.rvm/gems/ruby-2.6.3@api-provider/gems/rest-client-2.1.0/lib/restclient/abstract_response.rb:249:in `exception_with_response'

# case2: 用块
[24] pry(main)> RestClient.post("localhost:3000/demo", {}) {|response, request, result| puts "response.body: #{response.body}\nrequest.body: #{request.args}\nresult.body: #{result.body}"} # => nil
# response.body: {"msg":"this msg explains why this is a bad request"}
# request.body: {:method=>:post, :url=>"localhost:3000/demo", :payload=>{}, :headers=>{}}
# result.body: {"msg":"this msg explains why this is a bad request"}

可以看到, 用块的这个可以获取到响应里返回的详细信息

这个区别是在调用JIRA7和企业微信的API时发现的, 现象是: 用restclient调用api只返回了400, 用postman调试却能得到错误信息, 使用net/http调试, 也能得到错误信息

经过调试和阅读文档才意识到, 信息是被RestClient给吞了

看源码可以看到, 4XX和5XX的状态吗, 如果响应里有信息, RestClient是不会解析和返回的, 只会包装一个对应的异常

看这注释的意思, 这是个feature, 不是个bug, 但是假如确实有人在 4XX 响应里返回了信息(就像JIRA7和企业微信机器人接口那样), 那使用RestClient, 就得小心了…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# lib/restclient/abstract_response.rb

# Return the default behavior corresponding to the response code:
#
# For 20x status codes: return the response itself
#
# For 30x status codes:
# 301, 302, 307: redirect GET / HEAD if there is a Location header
# 303: redirect, changing method to GET, if there is a Location header
#
# For all other responses, raise a response exception
#
def return!(&block)
case code
when 200..207
self
when 301, 302, 307
case request.method
when 'get', 'head'
check_max_redirects
follow_redirection(&block)
else
raise exception_with_response
end
when 303
check_max_redirects
follow_get_redirection(&block)
else
raise exception_with_response
end
end

def exception_with_response
begin
klass = Exceptions::EXCEPTIONS_MAP.fetch(code)
rescue KeyError
raise RequestFailed.new(self, code)
end

raise klass.new(self, code)
end

结论: 如果使用RestClient, 一定要使用块; 如果用其他lib, 需要注意一下有没有类似的问题

思路.1 使用Request.execute(:method => :get, :url => url, :headers => headers, &block) 封装自己的请求

思路.2 使用RestClient.get等封装后的方法, 并使用块

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def make_qywxrobot_request(url, payload)
RestClient.post(url, payload.to_json, content_type: :json) do |response, request, result|
response_body = JSON.parse(response)
response_code = response.code
request_id = SecureRandom.hex
errcode = response_body["errcode"]

hash = {}
hash[:request_id] = request_id
hash[:request_args] = request.args
hash[:response_code] = response_code
hash[:response_body] = response_body

if response_code == 200 && errcode == 0
return response_body
elsif response_code == 200 && known_qywx_errcode?(errcode)
# exception handler 1
Rails.logger.error("#{__method__} #{hash.to_json}")
else
# exception handler 2
Rails.logger.error("#{__method__} #{hash.to_json}")
raise "调用企业微信接口发送告警消息失败"
end
end
end

# https://developer.work.weixin.qq.com/document/path/95390
def known_qywx_errcode?(errcode)
errcode == 45009 ||
errcode == 45033
end

思路.3 用ruby自带的Net::HTTP自己封装吧

目前没看出RestClient有什么优势…(可能是没遇到很复杂的http请求的场景)

反思

  1. 又仔细读一下文档, 发现文档里还是提供了些头绪的, 只是遇到问题时没读懂

  2. 这个”信息被吞”的问题, 和Error Handling有点像, 很多时候异常被吞掉是很恼人的事情, 一定要想清楚再决定是否 rescue

  • 这里有几点体会:
    • 代码里应该尽量少写begin, rescue, 尤其是rescue所有Exception更要少些, 代码的可读性会有提升, 可维护性也会变好一些, 因为出错时会崩, 崩了能找到源头; 如果满篇rescue, 那么排查起来就会很费劲了
    • 一定要理解rescue Exception => erescue => e的区别, 多数情况下前者是万万不可的
    • 必要的地方要做容错处理, 不能崩; 但是这种地方如果崩了, 要能及时发出告警, 记录好现场数据以备排查和修复, 绝对不能简单吞了完事
    • 捕获的异常越具体, 或者说处理异常的代码越少, 说明写代码时考虑的越周到(前提是这种异常确实会发送), 代码会干净很多, 这样的代码无论是使用、阅读还是维护, 都会很舒服
  1. 没必要时, 可以不考虑使用lib(例入写gem时, 要尽量少的引入依赖)

  2. 使用开源lib时, 最好能先了解它, 不要拿来就用, 不然遇到了奇怪的问题时会很头疼; 如果有安全问题也会很麻烦的, 甚至会有巨大的损失

migrate_to_frozen_string_literal

[20220613 update] better use robocop auto-correct to handle this issue.
[20220728 update] bundle exec rubocop -A **/*.rb --only Style/FrozenStringLiteralComment

I’ve read a blog post written by Mike Perham introducing the Magic Comment, and I tried it out in my project.

The # frozen_string_literal: true

STEP-1: add this “magic comment”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Find all ruby files.
# Iterate through them.
# Add two lines if the file doesn't have those magic comment.

basedir = Rails.root.to_s
filenames = Dir["#{basedir}/**/*.rb"]
target = "# frozen_string_literal: true"

filenames.each do |filename|
lines = File.foreach(filename).first(2)

if lines != ["#{target}\n", "\n"]
`gsed -i "1i#{target}" "#{filename}"` # Use gsed on macos.
`gsed -i "1G" "#{filename}"` # Use sed on linux.
end
end

STEP-2: do automated/manual tests

This is important, since your project code may have a situation for manipulating Mutable String.

STEP-3: deploy and pay extra attention to production state

Be ready to rollback your deployment. You know, shit happens.

Exception occurred: FrozenError

Yes, it happened…

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"class": "ScanAlertingAndPendingWorker",
"args": [],
"retry": 1,
"queue": "default",
"jid": "0a48de6e85b383432760b013",
"created_at": 1646124005.491867,
"enqueued_at": 1646124034.8366895,
"error_message": "can't modify frozen String",
"error_class": "FrozenError",
"failed_at": 1646124035.9765105,
"retry_count": 0,
}

Occurrence No.1

1
2
3
4
5
# frozen_string_literal: true

content = ""
content << 'world'
content << 'hello'

solution

1
2
3
4
5
# frozen_string_literal: true

content = String.new
content << 'world'
content << 'hello'

Occurrence No.2

1
2
3
4
# frozen_string_literal: true

body = 'Roses are red Mud is fun'
body.force_encoding('utf-8')

solution

1
2
3
4
5
6
# frozen_string_literal: true

body = 'Roses are red Mud is fun'
body.dup.force_encoding('utf-8')
# or
String.new(body).force_encoding('utf-8')

At Last

But to my disappointment, I didn’t see significant memory reduction.

It might be related to the size of the system.(Is it?)