Open pyx opened 7 years ago
臨時整理了一下,python 的放出來了 https://github.com/pyx/arsenal 這個 demo 的 logging 和 i18n 當時沒打算弄,而完整的不方便公開,我有時間另外發個 pull request 吧
8pm 大師~ 不得不承認我的 code 確實不大行,有些地方寫的時候也意識到了,但不知道怎麽改。前輩能蒞臨指導,那真是再好不過啦。 至於設計決定,我明天寫個 README 好了(雖然有所規劃,在貼吧我也沒說很多)。
我覺得首先是目標吧,你應該有腹稿的了,確實有必要寫出來,但不一定在 README,比如
總體原則: KISS,即貼吧 circa 2006-2010
計劃要完成的 features:
可能有用的 features:
deployment & hosting 方案:
coding 策略與 coding style:
這些先大概定下來會好很多,而且基本上都影響 lib 的選擇和 coding 方式
README 主要是簡單說 what, how 就行了 另外可考慮選個 license,沒有等於 all right reserved 的。
下面分開說一下細節,想到哪寫到哪
首先說架構吧
考慮儘可能自動化,比如新開發者怎麼配置好環境等
至少要準備 requirements.txt and/or setup.py,requirements 甚至可以細分,比如 end user 的和開發者的,開發者的應該多了 testing 工具的依賴之類
初始數據庫等也應早考慮寫成腳本
我將那個半成品放出來目的就是覺得應該對你有參考價值,用 make, fabric, flask-script 等自動化這些過程,其中 flask-script 目前有可能被 flask 0.11 開始的功能取代,比如 flask 這個 command 和 click 寫 cli。
目的就是新用戶/開發者應該能:
git clone https://github.com/910JQK/linuxbar/ REPOS
cd REPOS
# mkvirtualenv etc.
pip install -r requirements.txt
$EDITOR some-config-py
./manage.py init_db # etc.
./manage.py run
馬上就能用之類的
然後是一些 code 的問題吧,我一個個文件按順序來看
app.py:
import https://github.com/910JQK/linuxbar/blob/1b92a2390d08bc82d7621beafa8ea2d279611761/app.py#L4-L5
我個人覺得 \ 不好看也容易出錯,可以用括號,這樣怎麼分行都方便,如:
from flask import (Flask, Response, request, session, redirect, url_for,
render_template, send_file, abort)
flask app https://github.com/910JQK/linuxbar/blob/1b92a2390d08bc82d7621beafa8ea2d279611761/app.py#L7-L8
建議使用 app factory,方便以後測試和 deploy 見:http://flask.pocoo.org/docs/0.11/patterns/appfactories/
local config https://github.com/910JQK/linuxbar/blob/1b92a2390d08bc82d7621beafa8ea2d279611761/app.py#L26-L39
config 最好單獨一個 module,比如 config.py 或 dev_config.py 或 default_config.py 之類的,依賴關係和 overwrite 可以用 import 解決,另外還有 class-based 的,用 class 當 namespace 使用,不過我個人看法覺得是 abuse 了,雖然寫起來不錯。 可以參考我那個的 config 處理和這裡: http://flask.pocoo.org/docs/0.11/config/#configuration-best-practices
def _ https://github.com/910JQK/linuxbar/blob/1b92a2390d08bc82d7621beafa8ea2d279611761/app.py#L49-L51
每個文件裡的 _,其實可以放在一個工具 module 裡面,叫 utils.py 或者 common.py 或者 tools.py 什麼的 每個文件裡面就可以改成
from .utils import _
等 i18n 設好以後,將這一行的 .utils 改成 i18n 的就行
另外,配置好 i18n 也是很簡單的,提取/更新 .po 文件也可以用 make 或 fabric 或 manage.py 自動起來
def send_mail https://github.com/910JQK/linuxbar/blob/1b92a2390d08bc82d7621beafa8ea2d279611761/app.py#L54-L76
Flask-Mail 可以簡化這個 https://pypi.python.org/pypi/Flask-Mail 最主要還可以支持測試,即 TESTING 時不寄出,並可以存入一個 outbox 變量方便檢測內容
def check_permission https://github.com/910JQK/linuxbar/blob/1b92a2390d08bc82d7621beafa8ea2d279611761/app.py#L79-L102
首先我認為這個應該屬於 auth 或 user 或 utils 甚至 db (其實我認為 models 可能更合適) 的 module 里,app.py 應該只有關於 app 創建等的內容
然後,如果這個函數都在 view function 里用,可以直接 abort(401),401/404 的界面可以做得很 fancy 的。
def json_response https://github.com/910JQK/linuxbar/blob/1b92a2390d08bc82d7621beafa8ea2d279611761/app.py#L105-L114
http://flask.pocoo.org/docs/0.11/api/#flask.json.jsonify 如果要提供 REST API,還有兩三個 extension 可選
error message handling https://github.com/910JQK/linuxbar/blob/1b92a2390d08bc82d7621beafa8ea2d279611761/app.py#L117-L147 還沒看到怎麼用的,直覺感覺沒必要,one-liner
made config public to templates https://github.com/910JQK/linuxbar/blob/1b92a2390d08bc82d7621beafa8ea2d279611761/app.py#L150-L152 還沒看到其他文件,不過一般其他 module, template 知道的越少越好,不會有意外的依賴,so,先看下去
smart date repr. https://github.com/910JQK/linuxbar/blob/1b92a2390d08bc82d7621beafa8ea2d279611761/app.py#L155-L202
第一反映是這個不需要自己寫的,比如 dev 版本的 babel 有:
flask_babel.format_timedelta
然後是這個應該放在 utils.py 之類的 再然後是,我個人不太喜歡後台 render 這個,主要原因有二, 一 用戶 timezone 不好處理,用戶自設嗎,還是 geoip,只為 eye-candy 都太 heavy 了 二 後台 render 的,存下 html 以後會不準確
所以如果我要使用 smart date 的,一般用 front-end 手段,如 Moment.js,有 extension 方便使用: https://pypi.python.org/pypi/Flask-Moment
view index https://github.com/910JQK/linuxbar/blob/1b92a2390d08bc82d7621beafa8ea2d279611761/app.py#L205-L207
either redirect or:
@app.route('/')
@app.route('/board/<name>')
def board(name=''):
....
view board https://github.com/910JQK/linuxbar/blob/1b92a2390d08bc82d7621beafa8ea2d279611761/app.py#L210-L242
轉下一貼
自己寫 pagination,其實 peewee 有 flask extension,http://pypi.python.org/pypi/Flask-Peewee 提供了 pagination 還有其他功能的,但已經是維護模式了。 這就帶來另外一個問題了,選 peewee 的理由,我個人 prefer SQLAlchemy,雖然配置麻煩點,但上限高,免得以後換,Flask-SQLAlchemy http://pypi.python.org/pypi/Flask-SQLAlchemy 也提供整合和 pagination 等,而且是 Armin 自己維護。
應該盡量避免用 magic number,特別是兩層或以上的 collection 嵌套,解決方法我會這用兩種
OOP Style - namedtuple https://docs.python.org/3/library/collections.html#collections.namedtuple
Result = collections.namedtuple('Result', 'code message')
def f():
return Result(code=42, message='answer')
res = f()
assert res.code == 42
assert res.message == 'answer'
Functional Style - data accessor
def getter(*path):
# omit error handling here for brevity's sake
def get(collection):
for p in path:
collection = collection[p]
return collection
return get
# = lambda c: c[2]['content']
get_content = getter(2, 'content')
# = lambda c: c[42]['foobar']
get_foobar = getter(42, 'foobar')
多利用 python 的 first-class function 這個特點,比如這裡 https://github.com/910JQK/linuxbar/blob/1b92a2390d08bc82d7621beafa8ea2d279611761/forum.py#L34-L35 instead of
def now():
return datetime.datetime.now()
可以這樣
now = datetime.datetime.now
沒必要多一層
if len(lst) == 0:
pass
# 直接用
if not lst:
pass
等等
User handling,用現有的 extension 可以省很多代碼,而且和其他 extension 和 flask 本身整合更好
最基礎的 login/logout 有 Flask-Login http://pypi.python.org/pypi/Flask-Login 用戶權限,有 Flask-Principal https://pypi.python.org/pypi/Flask-Principal 更完整的,包括 activation,重置密碼等,有 Flask-Security https://pypi.python.org/pypi/Flask-Security ,是整合了上面兩個和其他的,大概相當於 django 的 auth app
Sending email 應該異步,不要用 blocking call,否則會是個很容易 DDoS 的點,最簡單的是 multiprocessing 或 threading 的 Pool,或者 third-party 的如 celery
encryption 等 最好不要自己寫,即使 hash + salt,而且為防 timing-attack,應該用專門 hash password 的,那些會迭代很多次,最省事是用 werkzeug (Flask 依賴的 WSGI 實現)的,
from werkzeug.security import check_password_hash, generate_password_hash
Uploading Flask-Uploads http://pypi.python.org/pypi/Flask-Uploads
User input validation, form handling Flask-WTF http://pypi.python.org/pypi/Flask-WTF 支持 CSRF,而且和各個 ORM extension 整合很好
永遠不要信任用戶輸入,存入時應該 sanitize,去掉所有 html tags,特別是 script 和 iframe,escape sequence,處理麻煩的 unicode entity,目前的代碼就沒做好 早期貼吧就因為這些常出漏洞
一個相關的內容是,我強烈建議給用戶輸入 Markdown,並且支持 code highlighting,再 sanitize 掉有害的內容(Markdown 支持直接 html),最後再轉換 @ 的連接,除了最後一步,具體怎麼做可參考我那個 forum。
即使自己寫轉換,也很多地方可以精簡的,比如: https://github.com/910JQK/linuxbar/blob/1b92a2390d08bc82d7621beafa8ea2d279611761/forum.py#L66-L113 這種情況,現在是寫成 imperative 的 FSM 還不如寫成 functional 的 data pipeline 可以隨意搭配 每步處理多的用 generator 也不特別耗費資源 e.g
# in pipeline.py
def normalize_newline(text):
return '\n'.join(text.splitlines())
BEGIN_CODE = '/***#'
END_CODE = '#***/'
def normalize_space(text):
in_code_block = 0
for line in text:
if line.strip().startswith(BEGIN_CODE):
in_code_block += 1
if not in_code_block:
yield ' '.join(line.split())
else:
yield line
if line.strip().endswith(END_CODE):
in_code_block -= 1
def other_transformation(text):
for line in text:
# blah blah blah
yield line
def pipeline(*processors):
def process(text):
for proc = processors:
text = proc(text)
return text
return process
# in forum.py
from .pipeline import (
normalize_newline,
normalize_space,
other_transformation,
pipeline,
)
process_content = pipeline(
normalize_newline,
normalize_space,
other_transformation,
)
...
return process_content(result[0]['content']))
當然,上面說了,這個具體情況下,直接用 Markdown parser 更方便,client side preview 有很多選擇
Rendering 代碼實在太多,我沒看完,但感覺是直接 json,然後 client side rendering,我個人看法是這個應用中不好,原因是
至於 RESTful API,還是可以提供,只要使用 flask extension 的 ORM 和 REST 框架,可以自動生成 API 的,一般並不需要重複寫 views
用 blueprint http://flask.pocoo.org/docs/0.11/blueprints/
模塊化 應盡量分類放,每個 module 盡量保持 200 loc 以下,否則會嚴重影響寫代碼的 productivity 我個人的標準是不超過 100,連注釋,合理分類一般能做到,除了個別模塊,比如想將所有 Model 放在一起。
我寫過一個類似規模的 project,連 100% test coverage 的 tests 代碼,連注釋,連其他 script,總共才 3000 loc python,其中 1000 多是 tests,200 是 script。各個 module 20-80 不等,只有 models 和 views 我為了減少相互 import 故意放在一起,一個 400 多,一個 200 多,但都很好閱讀,因為都是同類的。
最後
目前這個代碼太多了,建議先合理分類好模塊,重構一下,精簡代碼,現在是 3695 行 python,我估計可以精簡至少一半,然後再看看。 我如果還有時間再看具體的地方。
信息量略大…… 從開始寫到現在一直都是走路不看路,結構確實亂。這幾天先精簡一下代碼,搞搞模塊化。其它的慢慢來,一點一點修。 P.S. 除了樓中樓翻頁以外沒什麽 client side rendering 了,寫一坨 API 是真不知道有庫(準確地說是沒有意識去找,各種強寫
試着重構了幾天,感覺腦子要炸了,搞不下去了…… https://github.com/910JQK/linuxbar/compare/dev 事情是這樣的:我去看了看生成 API 的庫,發現難以滿足以下需求:
然後我就想把 forum.py
模塊化,然後自動生成 API, 把重複寫的 view 去掉。結果越寫越玄乎,感覺有些東西難以處理。
比如用 None
代表「不限板塊」(全局 admin / ban),但由於 bool(None) == False
, 會造成版塊不存在的錯誤,所以就 hack 來 hack 去,整個都亂了。有些不斷重複的代碼也不知道怎麽改。對這些具體問題很頭疼。各個表之間邏輯關係太多,腦子繞不過來,庫也沒法用。
感覺是各種姿勢不對。
於是我想,把原來的代碼先拆開,把要緊的問題小修一下,再把剩下的功能完成(基本就是些前端的事),先能用再說,然後再研究怎麽重構。
我沒注意到原來在 dev branch 有改動
把原來的代碼先拆開,把要緊的問題小修一下,再把剩下的功能完成(基本就是些前端的事),先能用再說,然後再研究怎麽重構。
這個會造成 technical debt,你現在代碼這麼新鮮都看/改不下去,六個月後的自己更無法理解了,也就是說,一個根據經驗很合理的推測是,現在這個 code base,開發者以後基本上很難會花比現在少的力氣改進,或者在這基礎上更加一些新功能,或者定位查找 bugs,換言之,無法維護。
因爲我上次留言還沒看到 README,沒法本地測試(這就是項目架構先搞好的重要性,我想測試一下,發些小 pull request,沒門路才開始寫上面一大段的),不知道完成度怎麼樣,如果你覺得基本功能都完成了,那我覺得最好的下一步是重寫,完全重頭來。在沒有 deadline 的情況下,依照之前寫過的經驗,重寫一個往往比全盤重構更省事,而且最終會得到更好的代碼,因爲之前走過的(時間和設計上的)彎路都能避免了。
一步步重頭來過,看似麻煩,其實更快,如果是我,會這樣做
如果之前沒試過,先通讀一遍 Flask 的文檔,包括後面的 cookbook 和 extension 介紹,需時大概一兩天吧,主要瞭解有什麼提供的和有什麼注意事項,best practice 什麼的,效果達到需要時知道大概哪裏找出來看細節就行。(題外話,閱讀也能提高 coding 水平的,我近十年來的提高,大量閱讀是最重要的因素)
雖然目錄架構,各文件等沒有 100% 通用的標準,相信你看過我上面發的那個論壇應該對你怎麼構建項目的結構(文件,模塊等)會有幫助。
由於具體代碼我不方便公開,我貼一些片段,或許可以給你一些靈感
例如測試 user model 的 tests/test_user.py 全文是:
# -*- coding: utf-8 -*-
import pytest
import sqlalchemy.exc
from carucate.models import db, User
def test_create_user(app):
john = User(email='john@example.com', password='1234')
db.session.add(john)
db.session.commit()
user = User.query.filter_by(email='john@example.com').one()
assert user.check_password('1234')
def test_unique_user_email(app):
john = User(email='john@example.com', password='1234')
db.session.add(john)
db.session.commit()
john2 = User(email='john@example.com', password='5678')
db.session.add(john2)
with pytest.raises(sqlalchemy.exc.IntegrityError):
db.session.commit()
def test_user_password_unreadable(app):
john = User(email='john@example.com', password='1234')
with pytest.raises(AttributeError):
'password is: ' + john.password
def test_user_check_password(app):
john = User(email='john@example.com', password='1234')
assert john.check_password('1234')
assert not john.check_password('1234.')
例如 測試 auth 這個 blueprint 的 views 大約是這樣的:
# -*- coding: utf-8 -*-
import re
from flask import url_for
from carucate.mail import mail
from carucate.models import db, User
def parse_url(text):
return re.findall(r'http://localhost/\S+', text)[0]
def test_change_email(app, user, login):
with app.test_client() as client, mail.record_messages() as outbox:
login(client)
response = client.post(
url_for('auth.change_email'),
follow_redirects=True,
data=dict(email='jane@example.com'))
html = response.data.decode()
assert 'confirmation email has been sent to you' in html
response = client.get(url_for('auth.logout'), follow_redirects=True)
html = response.data.decode()
assert 'Logged out.' in html
assert len(outbox) == 1
url = parse_url(outbox[0].body)
response = client.get(url, follow_redirects=True)
html = response.data.decode()
assert response.status_code == 200
assert 'email address confirmed' in html
response = client.post(
url_for('auth.login'),
follow_redirects=True,
data=dict(email='john@example.com', password='1234'))
html = response.data.decode()
assert 'Invalid email or password' in html
response = client.post(
url_for('auth.login'),
follow_redirects=True,
data=dict(email='jane@example.com', password='1234'))
html = response.data.decode()
assert 'Logged in successfully.' in html
def test_change_email_with_get_should_fail(app, user, login):
with app.test_client() as client:
login(client)
response = client.get(
url_for('auth.change_email'),
follow_redirects=True,
query_string=dict(email='jane@example.com'))
assert response.status_code == 200
assert User.query.one().email == 'john@example.com'
def test_change_password(app, user, login):
with app.test_client() as client:
login(client)
response = client.post(
url_for('auth.change_password'),
follow_redirects=True,
data=dict(password='incorrect', newpass='4321', confirm='4321'))
html = response.data.decode()
assert 'Incorrect password' in html
assert user.check_password('1234')
client.post(
url_for('auth.change_password'),
follow_redirects=True,
data=dict(password='1234', newpass='4321', confirm='4321'))
assert user.check_password('4321')
...
def test_invalid_activation_token(app):
with app.test_client() as client:
response = client.get(
url_for('auth.activate_account', token='invalid'),
follow_redirects=True)
assert response.status_code == 404
def test_invalid_login(app):
with app.test_client() as client:
response = client.post(
url_for('auth.login'),
follow_redirects=True,
data=dict(email='john@example.com', password='1234'))
html = response.data.decode()
assert 'Invalid email or password' in html
def test_login_logout(app):
john = User(email='john@example.com', password='1234')
john.is_active = True
john.email_confirmed = True
db.session.add(john)
db.session.commit()
with app.test_client() as client:
response = client.post(
url_for('auth.login'),
follow_redirects=True,
data=dict(email='john@example.com', password='1234'))
html = response.data.decode()
assert 'Logged in successfully.' in html
response = client.get(url_for('auth.logout'), follow_redirects=True)
html = response.data.decode()
assert 'Logged out.' in html
...
比如 auth blueprint 的 views (auth/views.py) 裏部分內容是這樣的
from flask import (
Blueprint,
abort,
current_app,
flash,
redirect,
render_template,
request,
url_for,
)
from flask_login import (
LoginManager,
current_user,
login_required,
login_user,
logout_user,
)
from itsdangerous import URLSafeTimedSerializer
from .forms import (
ChangeEmailForm,
ChangeNameForm,
ChangePasswordForm,
ForgetPasswordForm,
LoginForm,
ResetPasswordForm,
SignupForm,
)
from .. import mail
from ..constants import DEFAULT_TOKEN_MAX_AGE, MAX_USER_NAME_LENGTH
from ..models import User, db
login_manager = LoginManager()
login_manager.login_view = 'auth.login'
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
def init_app(app):
login_manager.init_app(app)
auth = Blueprint('auth', __name__)
...
@auth.route('/activate/<token>/')
def activate_account(token):
salt = current_app.config['CARUCATE_ACTIVATION_SALT']
max_age = current_app.config['CARUCATE_ACTIVATION_TOKEN_MAX_AGE']
email = validate_token_or_404(token, salt, max_age)
user = User.query.filter_by(email=email).first_or_404()
user.email_confirmed = True
user.is_active = True
db.session.add(user)
db.session.commit()
flash('account activated, please login with your email')
return redirect(url_for('.login'))
...
@auth.route('/login/', methods=['GET', 'POST'])
def login():
if not current_user.is_anonymous:
return redirect(url_for('home.index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if check_password(user, form.password.data):
login_user(user, form.remember_me.data)
flash('Logged in successfully.')
next_ = request.args.get('next')
return redirect(next_ or url_for('dashboard.index'))
flash('Invalid email or password')
return render_template('auth/login.html', form=form)
@auth.route('/logout/')
@login_required
def logout():
logout_user()
flash('Logged out.')
return redirect(url_for('.login'))
...
而 app.py 全文裏是大概這樣的
from flask import Flask
from flask_gravatar import Gravatar
from flask_moment import Moment
from flask_pure import Pure
from flask_simplemde import SimpleMDE
from .config import DefaultConfig
from .auth.views import auth, init_app as auth_init_app
...
from .dashboard.views import dashboard
...
from .group.views import group
from .home.views import home
...
from .user.views import user
from .mail import init_app as mail_init_app
from .models import init_app as models_init_app
def create_app(config=None, config_filename=None):
"""application factory"""
app = Flask(__name__, instance_relative_config=True)
if config is None:
config = DefaultConfig
app.config.from_object(config)
if config_filename is not None:
app.config.from_pyfile(config_filename)
app.config.from_envvar('CARUCATE_SETTINGS', silent=True)
mail_init_app(app)
models_init_app(app)
auth_init_app(app)
Gravatar(app, default='identicon')
Moment(app)
Pure(app)
SimpleMDE(app)
app.register_blueprint(auth, url_prefix='/auth')
...
app.register_blueprint(dashboard, url_prefix='/dashboard')
...
app.register_blueprint(group, url_prefix='/groups')
app.register_blueprint(home)
...
app.register_blueprint(user, url_prefix='/user')
return app
部分代碼大小,這是功能完整的了,就是我前面提到的原則,實際上每個 module 超過 200 loc 就變得難以查找和理解(除非是 utils 這類每個 function 之前沒有很緊密結合的工具 module) Python 足夠高級以至於大多數情況下,設計良好的 module 都可以在這個範圍
wc -l `hg manifest | grep '\.py'`
0 carucate/__init__.py
50 carucate/app.py
0 carucate/auth/__init__.py
67 carucate/auth/forms.py
235 carucate/auth/views.py
...
36 carucate/config.py
53 carucate/constants.py
0 carucate/dashboard/__init__.py
13 carucate/dashboard/views.py
52 carucate/decorators.py
...
9 carucate/forms.py
0 carucate/group/__init__.py
52 carucate/group/forms.py
65 carucate/group/views.py
0 carucate/home/__init__.py
28 carucate/home/views.py
...
43 carucate/mail.py
11 carucate/mixins.py
414 carucate/models.py
...
0 carucate/user/__init__.py
25 carucate/user/views.py
34 carucate/utils.py
35 fabfile.py
84 manage.py
30 scripts/create_dev_config.py
30 scripts/create_production_config.py
26 setup.py
...
我覺得帖子的回覆,要麼就傳統論壇的引用,要麼就支持多層嵌套,像貼吧那種只有一層樓中樓的,我認爲是高不成低不就的做法。
多層嵌套就是每個帖子的 model 做成樹的形式,爲了避免多次遞歸造成的訪問數據庫次數過多,有好些方法避免,上面貼的那個片段的項目我用的是其中一種叫 materialized path,簡單的說就是不用自引用的 foreign key 而是用一個專門的 field 來記錄 path,比如 path='123/456/789',其中數字是 id。materialized path 非常適合用在支持多層嵌套的帖子,排序,插入,刪除,查找子樹,都很低 overhead。
那么窝们就先来规划一下, 然后 PR 什么的慢慢来咯? 窝建议就直接用 Flask web dev 那本书的作者的示例代码结构, 风格非常好哇。 窝自己照着学习一遍都很有收获 https://github.com/1dot75cm/flasky
Mark @pyx 大神的建议好实用
待我慢慢研究研究……
強烈建議 @1dot75cm 的建議,通讀一遍 Flask Web Development,再開始寫,如果找不到本書,作者有一系列的 blog post,書的內容就是從那裡提煉出來的。
裡面有不少好的建議,包括怎樣組織項目結構,選用 extensions,best practices,雖然有些建議我不見得認同[1],但 Flask 和其他 full stack (Django,RoR,etc.) 比起來,就是 all about choices,值得一看。
[1]:
窝刚把 Flask 资源汇总了一下,供参考。 @910JQK https://github.com/1dot75cm/awesome-flask-cn/blob/master/README-cn.md
好久不見,決定做輪子啦。 我剛剛看了一下現在的 code base,覺得好多地方可以更好,包括風格,架構,減少重複代碼,更 pythonic 等,有時間我可以一個個 pull request 來。 在此之前,我決定將上次弄的半成品(我發過截圖那個)放出來,或許你可以參考一下。 純 python 和 hy 的 port 都放。 i18n 和 log 可以很簡單寫好 production-ready 的代碼,大概幾十行內就行,但我不確定什麼時候有時間準備 pull request, 另外一些設計決定,如果你有興趣的話我們可以再深入討論一下。