Reducing Assumptions, Exploding Your Code

Reducing Assumptions, Exploding Your Code

减少假设,让你的代码“爆炸”

Elegant scripts

优雅的脚本

We’ve all written them, they fall together perfectly, they are readable, but they assume the happy paths. And world can be a happy place, but it’s also deeply flawed, imperfect and for your code to function in such world, even add value … it must handle the imperfection. 我们都写过这样的脚本:它们浑然天成、易于阅读,但它们都建立在“一切顺利”的假设之上。世界固然可以很美好,但它同时也充满了缺陷与不完美。为了让你的代码在这样的世界中运行并发挥价值,它必须学会处理这些不完美。

Our nice, worldly example

我们这个贴近现实的例子

Our script will accept an ID as an argument. It will find an API token in setup.json and make a request to download a PDF from a remote server. The name of the downloaded file is determined by the server. It’s simple, but a little realistic-ly messy, but hey - we are programmers, this is what we do, this is what we thrive at, right :) … right :I (thinks of all the vibeco…) 我们的脚本将接收一个 ID 作为参数,从 setup.json 中读取 API 令牌,并向远程服务器发起请求下载 PDF 文件。下载文件的名称由服务器决定。这很简单,但带有一点现实中的混乱。不过嘿,我们是程序员,这就是我们的工作,也是我们擅长的地方,对吧?……对吧?(想起所有那些令人心累的 bug……)

Python Version

Python 版本

Python is the lingua franca of programming. Let’s go! Python 是编程界的通用语言。让我们开始吧!

import sys, json, requests, re
from requests.auth import HTTPBasicAuth

id = int(sys.argv[1])
with open('setup.json') as f:
    setup = json.load(f)

url = f"https://www.example.com/pdf-api?id={id}"
resp = requests.get(url, auth=HTTPBasicAuth(setup['token'], 'x'))

pattern = re.compile(r"filename\*?=[f']?(.*?)[']?(?:;?$)")
content_disp = resp.headers['Content-Disposition']
filename = pattern.search(content_disp).group(1)

with open(filename, 'wb') as f:
    f.write(resp.content)

It’s a perfect little script. Each block of code does one thing, each one is few lines, there is no unneded structure or boilerplate really - I like it. 这是一个完美的小脚本。每个代码块只做一件事,行数精简,没有多余的结构或样板代码——我喜欢它。

Rye version

Rye 版本

Since this is a Rye language blog, we will also write this in Rye :) 既然这是关于 Rye 语言的博客,我们也要用 Rye 来写一遍 :)

rye .Args? .load .first :id
Load %setup.rye |context :setup
re: regexp "filename\*?=[f']?(.*?)[']?(?:;?$)"

format id https://www.example.com/pdf-api?id=%d
|Request 'GET "" |Basic-auth! setup/token "x" |Call :resp
|Header? "Content-Disposition" |Submatch?* re |file .Create |Copy* Reader resp

Ok, a little different, but similar. 好吧,虽然有点不同,但逻辑是相似的。

What are we assuming?

我们做了哪些假设?

  • script always gets one integer argument
  • setup file exists and has correct content
  • HTTP request never fails
  • Content-Disposition header is always present with a filename
  • We can always create a new file

As Eugene Lewis Fordsworthe would say - that’s a lot of …, assumptions :(

  • 脚本总是能接收到一个整数参数
  • setup 文件一定存在且内容正确
  • HTTP 请求永远不会失败
  • Content-Disposition 响应头总是包含文件名
  • 我们总是能创建新文件

正如 Eugene Lewis Fordsworthe 所说——这假设也太多了吧 :(

Adding Basic Validation (Step 2)

添加基础验证(第二步)

I can be like watered down version of Eugene: “User input is the source of many problems”. No user input, no problems - but we need them users. So let’s validate those inputs. 我可以做一个“弱化版”的 Eugene:“用户输入是许多问题的根源”。没有用户输入就没有问题,但我们需要用户。所以,让我们验证这些输入吧。

Python Version

Python 版本

We will now: check the number of arguments, check if ID is integer, check if setup has token value defined. 现在我们将:检查参数数量,检查 ID 是否为整数,检查 setup 中是否定义了 token 值。

import sys, json, requests, re
from requests.auth import HTTPBasicAuth

if len(sys.argv) != 2:
    raise ValueError("script argument id - expected exactly one integer")

try:
    id = int(sys.argv[1])
except ValueError:
    raise ValueError("script argument id - must be an integer")

with open('setup.json') as f:
    setup = json.load(f)

if 'token' not in setup or not isinstance(setup['token'], str):
    raise ValueError("loading setup - token field required as string")

# ... (rest of the logic)

We added those few checks and if you ask me (I am partial to this), the elegant, readable script is already gone. That’s one of the reasons I loathe try/catch approach. It adds structure that disrupts the flow of code. 我们添加了这些检查,如果你问我(我对此有偏见),那个优雅、易读的脚本已经不复存在了。这就是我讨厌 try/catch 方式的原因之一。它增加的结构破坏了代码的流畅性。

Rye Version

Rye 版本

rye .Args? .validate { <one> integer } |check "script argument id" |first :id
Load %setup.rye |context |validate { token: required string } |check "setup file" :setup
re: regexp "filename\*?=[f']?(.*?)[']?(?:;?$)"

format id https://www.example.com/pdf-api?id=%d
|Request 'GET "" |Basic-auth! setup/token "x" |Call :resp
|Header? "Content-Disposition" |Submatch?* re |file .Create .defer\ 'Close |Copy* resp .Reader .defer\ 'Close

We used validation dialect for the arguments and config. And .defer\ 'Close to ensure resources (the file writer and HTTP stream reader - no copying to memory btw) are cleaned up. Script got a little more complex, but structure and flow of it didn’t change. 我们为参数和配置使用了验证方言(validation dialect),并使用 .defer\ 'Close 来确保资源(文件写入器和 HTTP 流读取器——顺便说一下,这里没有内存拷贝)被正确清理。脚本变得稍微复杂了一些,但其结构和流程并没有改变。

Full Error Handling (Step 3)

完整的错误处理(第三步)

Now let’s handle all the failures, and provide helpful feedback to the user in case it fails. Our initially elegant script, exploded into this … :o 现在让我们处理所有的失败情况,并在出错时向用户提供有用的反馈。我们最初优雅的脚本,现在“爆炸”成了这样…… :o

Python Version

Python 版本

We now also check for: does the setup.json exist, can we parse the setup.json’s JSON, did the HTTP request succeed, we provide the default filename if there is no Content-Disposition, can we create a new file, can we write PDF to it. 现在我们还要检查:setup.json 是否存在,能否解析 JSON,HTTP 请求是否成功,在没有 Content-Disposition 时提供默认文件名,能否创建新文件,能否将 PDF 写入文件。

# ... (omitted for brevity, showing the complexity increase)
try:
    with open('setup.json') as f:
        setup = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
    print(f"Error: couldn't open config - {e}")
    sys.exit(1)
# ...