プログラミング
前提条件を減らし、コードを爆発させる
Reducing Assumptions, Exploding Your Code (ryelang.org)
要約
この記事では、エレガントなスクリプトがしばしば「ハッピーパス」を前提としているが、現実世界は不完全であり、コードはその不完全性に対処する必要があると論じています。PythonとRye言語で、引数処理、設定ファイルの読み込み、HTTPリクエスト、ファイル保存といった処理を行うスクリプトを例に、段階的にエラーハンドリングとバリデーションを追加していく過程を示し、Rye言語が構造を維持しながら堅牢なコードを記述できることを強調しています。
全文翻訳
エレガントなスクリプト
誰もが書いたことがあるでしょう。完璧にまとまり、読みやすく、しかし「ハッピーパス」だけを想定しています。世界は幸せな場所かもしれませんが、深く欠陥があり、不完全でもあります。あなたのコードがそのような世界で機能し、さらに価値を加えるためには、その不完全性に対処しなければなりません。
私たちの素敵な、現実的な例
スクリプトはIDを引数として受け取ります。setup.jsonからAPIトークンを見つけ、リモートサーバーからPDFをダウンロードするリクエストを行います。ダウンロードされるファイル名はサーバーによって決定されます。シンプルですが、少し現実的で厄介ですが、私たちはプログラマーであり、これが私たちの仕事であり、私たちが得意とすることですよね? :) ...本当に? :I (すべてのバイブレーションを考える…)
Pythonバージョン
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)
これは完璧な小さなスクリプトです。コードの各ブロックは1つのことを行い、それぞれが数行で、不要な構造やボイラープレートは本当にありません - 私はそれが好きです。
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
少し違いますが、似ています。
何を前提としているのか?
スクリプトは常に1つの整数引数を受け取る
setupファイルが存在し、正しい内容を含んでいる
HTTPリクエストは決して失敗しない
Content-Dispositionヘッダーは常にファイル名とともに存在する
常に新しいファイルを作成できる
ユージン・ルイス・フォードワースが言ったように - それはたくさんの…前提条件です :(
基本的なバリデーションの追加(ステップ2)
私はユージンの薄められたバージョンになれます。「ユーザー入力は多くの問題の原因です」。ユーザー入力がなければ問題はありません - しかし、私たちにはユーザーが必要です。だから、それらの入力を検証しましょう。
Pythonバージョン
今、私たちは以下を行います:
引数の数をチェックする
IDが整数であるかチェックする
setupにトークン値が定義されているかチェックする
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")
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)
これらの数個のチェックを追加しましたが、私に言わせれば(私はこれに偏っていますが)、エレガントで読みやすいスクリプトはすでに失われています。これが私がtry/catchアプローチを嫌う理由の一つです。それはコードの流れを妨げる構造を追加します。
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
引数と設定のためにバリデーション方言を使用しました。.defer\ 'Close は、リソース(ファイルライターとHTTPストリームリーダー - メモリへのコピーなし)がクリーンアップされることを保証します。スクリプトは少し複雑になりましたが、その構造とフローは変わりませんでした。
完全なエラーハンドリング(ステップ3)
それでは、すべての失敗を処理し、失敗した場合にユーザーに役立つフィードバックを提供しましょう。私たちの当初のエレガントなスクリプトは、これに爆発しました… :o
Pythonバージョン
今、私たちは以下もチェックします:
setup.jsonが存在するか
setup.jsonのJSONを解析できるか
HTTPリクエストが成功したか
Content-Dispositionがない場合にデフォルトのファイル名を提供する
新しいファイルを作成できるか
PDFをそれに書き込めるか
import sys, json, requests, re
from requests.auth import HTTPBasicAuth
# Validate arguments
if len(sys.argv) != 2:
print("Error: script argument id - expected exactly one integer")
sys.exit(1)
try:
id = int(sys.argv[1])
except ValueError:
print("Error: script argument id - must be an integer")
sys.exit(1)
# Load and validate config
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)
if 'token' not in setup or not isinstance(setup['token'], str):
print("Error: loading setup - token field required as string")
sys.exit(1)
pattern = re.compile(r"filename\*?=[f']?(.*?)[']?(?:;?$)")
url = f"https://www.example.com/pdf-api?id={id}"
try:
resp = requests.get(url, auth=HTTPBasicAuth(setup['token'], 'x'))
resp.raise_for_status()
except requests.RequestException as e:
print(f"Error: Http request failed - {e}")
sys.exit(1)
# Extract filename with default fallback
content_disp = resp.headers.get('Content-Disposition', '')
match = pattern.search(content_disp)
filename = match.group(1) if match else "default.pdf"
try:
with open(filename, 'wb') as f:
f.write(resp.content)
except IOError as e:
print(f"Error: couldn't create local pdf - {e}")
sys.exit(1)
except Exception as e:
print(f"Error: couldn't save contents - {e}")
sys.exit(1)
当初15行だったスクリプトは、多くの構造を持つ45行のコードになりました。動作するコードはすべての安全コードの中に隠されており、ほとんど見つけることができません。Pythonプログラマーは自然にこれをヘルパー関数でリファクタリングし、argparseやバリデーションライブラリのような追加ライブラリを使用しますが、それでも以前のクリーンなハッピーパスに「構造」を追加します。それはより良く隠されますが、依存関係も追加します。
Ryeバージョン
rye
.Args?
.validate {
<one> integer
}
|^check "script argument id"
|first :id
Load %setup.rye
|check "couldn't open setup file"
|context
|validate {
token: required string
}
|^check "loading setup"
:setup
re: regexp "filename\*?=[f']?(.*?)[']?(?:;?$)"
format id https://www.example.com/pdf-api?id=%d
|Request 'GET ""
|Basic-auth! setup/token "x"
|Call
|^check "Http request failed"
:resp
|Header? "Content-Disposition"
|Submatch?* re
|fix { "default.pdf" }
|file .Create
|^check "couldn't create local pdf"
|defer\ 'Close
|Copy* resp .Reader
.defer\ 'Close
|^check "couldn't save contents"
コードは少し凝縮されましたが、行数はほとんど移動せず、最も重要なことに、プログラムの構造は同じままでした! :O
しかし…魔法
上記のコードに魔法はありません。あなたが見るすべての単語は通常のRye関数です。上記のコードは、Ryeの前任者、私たち、そしていくつかの幸運による多くの慎重な設計決定の結果にすぎません。実際、Ryeの失敗とバリデーション処理機能のほんの一部しか使用しませんでした。より多くを読む前に直接飛び込むのは難しいですが、上記の重要な関数は次のことを行います:check - 値を返します。失敗でない場合は、失敗を上位レベルの失敗にラップして返します。fix - 同様に値を返します。失敗でない場合は、ブロックを評価し、