1. 程式人生 > >LeanCloud SDK不好用,Python手寫一個ORM

LeanCloud SDK不好用,Python手寫一個ORM

Intro

慣例,感覺寫了好用的東西就來寫個部落格吹吹牛逼。

LeanCloud Storage 的資料模型不像是一般的 RDBMS,但有時候又很刻意地貼近那種感覺,所以用起來就很麻煩。

LeanCloud SDK 的缺陷

不管別人認不認可,這些問題在使用中我是體會到不爽了。

資料模型宣告

LeanCloud 提供的 Python SDK ,根據文件描述來看,只有兩種簡單的模型宣告方式。

import leancloud

# 方式1
Todo = leancloud.Object.extend("Todo")
# 方式2
class Todo(leancloud.Object): pass

你說欄位?欄位隨便加啊,根本不檢查。看看例子。

todo = Todo()
todo.set('Helo', 'world') # oops. typo.

忽然就多了一個新欄位,叫做Helo。當然,LeanCloud 提供了後臺設定,允許設定為不自動新增欄位,但是這樣有時候你確實想更新欄位時——行,開後臺,輸入賬號密碼,用那個渲染40行元素就開始輕微卡頓的資料頁面吧。

鬼畜的查詢Api

是有點標題黨了,但講道理的說,我不覺得這個Api設計有多優雅。

來看個查詢例子,如果我們要查詢叫做 Product 的,創建於 2018-8-12018-9-1 ,且 price 大於 10

,小於100的元素。

leancloud.Query(cls_name)\
	.equal_to('name', 'Product')\
    .greater_than_or_equal_to('createdAt', datetime(2018,8,1))\
    .less_than_or_equal_to('createdAt', datetime(2018,9,1))\
    .greater_than_or_equal_to('price', 10)\
    .less_than_or_equal_to('price',100)\
    .find()

第一眼看過去,閱讀全文並背誦?

隱藏於文件中的行為

典型的就是那個查詢結果是有限的,最高1000個結果,預設100個結果。在Api中完全無法察覺——find嘛,查出來的不是全部結果?你至少給個分頁物件吧,說好的程式碼即文件呢。

幸運的是至少在文件裡寫了,雖然也就一句話。

行為和預期不符

以一個簡單的例子來說,如果你查詢一個物件,查詢不到怎麼辦?

返回個空指標,返回個None啊。

LeanCloud SDK 很機智地丟了個異常出來,而且各種不同型別的錯誤都是這個 LeanCloudError 異常,裡面包含了codeerror來描述錯誤資訊。

針對於儲存個人糊出來的解決方案

我就硬廣了,不過這個東西還在施工中,寫下來才一天肯定各種不到位,別在意。

better-leancloud-storage-python

簡單的說,針對於上面提到的痛點做了一些微小的工作。

微小的工作

直接看例子。

class MyModel(Model):
	__lc_cls__ = 'LeanCloudClass'
	field1 = Field()
    field2 = Field()
    field3 = Field('RealFieldName')
    field4 = Field(nullable=False)
MyModel.create(field4='123') # 缺少 field4 會丟擲 KeyError 異常
MyModel.query().filter_by(field1="123").filter(MyModel.field1 < 10)

__lc_cls__是一個用於對映到 LeanCloud 實際儲存的 Class 名字的欄位,當然如果不設定的話,就像 sqlalchemy 一樣,類名 MyModel 就會自動成為這個欄位的值。

create 接受任意數量關鍵字引數,但如果關鍵字引數沒有覆蓋所有的nullable=False的欄位,則會立即丟擲KeyError異常。

filter_by接受任意數量關鍵字引數,如果關鍵字不存在於Model宣告則立即報錯。api 和 sqlalchemy 很像,filter_by(field1='123')比起寫 equal_to('field1', '123')是不是更清晰一些?特別是條件較多的情況下,優勢會越發明顯,至少,不至於背課文了。

實現方式分析

裝逼之後就是揭露背後沒什麼技術含量的技巧的時間。

簡單易懂的元類魔法

python 的元類很好用,特別是你需要對類本身進行處理的時候。

對於資料模型來說,我們需要收集的東西有當前類的所有欄位名,超類(父類)的欄位名,然後整合到一起。

做法簡單易懂。

收集欄位

首先是遍歷嘛,遍歷找出所有的欄位,isinstance就好了。

class ModelMeta(type):
    """
    ModelMeta
    metaclass of all lean cloud storage models.
    it fill field property, collect model information and make more function work.
    """
    _fields_key = '__fields__'
    _lc_cls_key = '__lc_cls__'

    @classmethod
    def merge_parent_fields(mcs, bases):
        fields = {}

        for bcs in bases:
            fields.update(deepcopy(getattr(bcs, mcs._fields_key, {})))

        return fields
    
    def __new__(mcs, name, bases, attr):
        # merge super classes fields into __fields__ dictionary.
        fields = attr.get(mcs._fields_key, {})
        fields.update(mcs.merge_parent_fields(bases))

        # Insert fields into __fields__ dictionary.
        # It will replace super classes same named fields.
        for key, val in attr.items():
            if isinstance(val, Field):
                fields[key] = val

        attr[mcs._fields_key] = fields

思路就是一條直線,什麼架構、最佳實踐都滾一邊,用粗大的腦神經和頭鐵撞過去就是了。

第一步拿出所有基類,找出裡面已經建立好的__fields__,然後合併起來。

第二步遍歷一下本類的成員(這裡可以直接用{... for ... in filter(...)}不過我沒想起來),找出所有的欄位成員。

第三步?合併起來,一個update就完事兒了,賦值回去,大功告成。

欄位名的預設值

還沒完事兒,欄位名怎麼對映到 LeanCloud 儲存的 欄位上?

直接看程式碼。

    @classmethod
    def tag_all_fields(mcs, model, fields):
        for key, val in fields.items():
            val._cls_name = model.__lc_cls__
            val._model = model

            # if field unnamed, set default name as python class declared member name.
            if val.field_name is None:
                val._field_name = key
	
    def __new__(mcs, name, bases, attr):
    	# 前略
		# Tag fields with created model class and its __lc_cls__.
        created = type.__new__(mcs, name, bases, attr)
        mcs.tag_all_fields(created, created.__fields__)
        return created

就在那個tag_all_fields裡面,val._field_name賦值完事兒。不要在乎那個field_name_field_name,一個是包了一層的只讀getter,一個是原始值,僅此而已。為了統一也許後面也改掉。

苦力活

有了元資料,接下來的就是苦力活了。

create怎麼檢查是不是滿足所有非空?引數的鍵和非空的鍵做個集合,非空鍵如果不是引數鍵的子集也不等同則不滿足。

filter_by同理。

構建查詢也不困難,大家都知道a<b可以過載__lt__來返回個比較器之類的東西。

慢著,怎麼讓一個例項,用instance.a訪問到的內容和model.a訪問到的內容不一樣?是在init、new方法裡做個魔術嗎?

例項訪問欄位值

說穿了也沒什麼特別的,在例項裡面用實際欄位值覆蓋重名元素很簡單,self.field = self.delegated_object.get('field')也就一句話的事情,多少不過是 setattrgetattr的混合使用罷了。

不過我用的是過載 __getattribute____setattr__的方法,同樣不是什麼難理解的東西。

__getattribute__會在所有的例項成員訪問之前呼叫,用這個方法可以攔截掉所有instance.field形式的對field的訪問。所以說python是個基於字典的語言一點也不玩笑(開玩笑的)。

看程式碼。

    def __getattribute__(self, item):
        ret = super(Model, self).__getattribute__(item)
        if isinstance(ret, Field):
            field_name = self._get_real_field_name(item)

            if field_name is None:
                raise AttributeError('Internal Error, Field not register correctly.')

            return self._lc_obj.get(field_name)

        return ret

需要特別注意的點是,因為在__getattribute__裡訪問成員也會呼叫到自身,所以注意樹立明確的呼叫分界線:在分界線外,所有成員值訪問都會造成無限遞迴爆棧,分界線內則不會。

對於我寫的這段來說,分界線是那個 if isinstance(...)。在if之外必須使用super(...).__getattribute__(...)來訪問其他成員。

至於 __setattr__更沒什麼好說的了。看看是不是模型的欄位,然後轉移一下賦值的目標就是了。

看程式碼。

    def __setattr__(self, key, value):
        field_name = self._get_real_field_name(key)
        if field_name is None:
            return super(Model, self).__setattr__(key, value)

        self._lc_obj.set(field_name, value)

so simple!