核心:self.env原理、方法與踩坑實(shí)戰(zhàn)指南)
入行Odoo開發(fā)這些年我經(jīng)常被剛接觸框架的同事問一個問題“self.env到底是什么”很多人能照葫蘆畫瓢寫出self.env[res.partner].browse(id)但一旦遇到權(quán)限異常、上下文錯亂、性能下降就卡住了。今天干脆把self.env的屬性和方法攤開講一遍每個點(diǎn)都帶上實(shí)際項目中的使用場景和踩坑記錄保證你讀完能少走很多彎路。1. 先搞懂一個概念self 不總是同一個東西1.1 模型、記錄集和環(huán)境三者各管什么在Odoo里有三樣?xùn)|西經(jīng)常被混在一起模型Model、記錄集Recordset和環(huán)境Environment。模型是類的定義比如res.partner、sale.order它決定了數(shù)據(jù)有哪些字段、哪些方法。記錄集是一批具體記錄的集合可以是空集、單條記錄也可以是一堆記錄。環(huán)境則是這段代碼執(zhí)行時所在的“運(yùn)行場景”——它包含當(dāng)前是哪個用戶、連接哪個數(shù)據(jù)庫、上下文里有什么參數(shù)、公司在哪。self.env就是當(dāng)前記錄集所處的那個環(huán)境對象。在普通模型中你寫self.env就是拿到當(dāng)前執(zhí)行的用戶、語言、數(shù)據(jù)庫連接狀態(tài)然后在它上面調(diào)用各種方法去查數(shù)據(jù)、寫數(shù)據(jù)。可以把它理解成一個“帶權(quán)限和上下文的數(shù)據(jù)庫會話助手”它知道自己是以什么身份在操作也清楚操作時應(yīng)該遵守哪些規(guī)則。1.2 不同方法里self是什么樣的這里有個新手必踩的認(rèn)知盲區(qū)不是所有方法里的self都是同一個東西。在create、write這類ORM內(nèi)置方法中self往往是具體操作對應(yīng)的當(dāng)前記錄集。在compute計算字段中self是所有需要計算的那批記錄。在onchange中self是一份內(nèi)存模擬的記錄它只包含視圖上加載了的字段值并不完整。而default默認(rèn)值方法中self通常是空記錄集。不管self是哪種形態(tài)self.env始終存在并且指向同一個運(yùn)行環(huán)境。所以當(dāng)你在一個方法里不確定self到底有沒有記錄時優(yōu)先用self.env去拿模型和環(huán)境可讀性和安全性都會好很多。1.3 為什么Odoo把環(huán)境單獨(dú)拿出來設(shè)計Odoo沒有把環(huán)境信息做成全局變量而是每個記錄集都攜帶一個env引用。這種設(shè)計的核心原因是Odoo在同一個請求里可能會切換不同的用戶身份、不同的公司、不同的語言和上下文如果做成全局變量切換時容易出現(xiàn)數(shù)據(jù)串線。比如一個操作需要臨時以管理員身份讀取一條伙伴數(shù)據(jù)只需要self.env[res.partner].sudo().browse(id)這個sudo()并不會影響別處的代碼因?yàn)閟udo()返回了一個新的環(huán)境原環(huán)境仍然是原樣。這種隔離機(jī)制讓權(quán)限切換、上下文變化都可以精確控制在極小的代碼范圍內(nèi)不用全局考慮狀態(tài)恢復(fù)問題。2. 環(huán)境自帶的核心屬性日常開發(fā)中最值錢的信息2.1 env.cr數(shù)據(jù)庫游標(biāo)雙刃劍env.cr是當(dāng)前環(huán)境的數(shù)據(jù)庫游標(biāo)它可以讓你直接執(zhí)行原生SQL。最常見的用法是self.env.cr.execute(SELECT id, name FROM res_partner WHERE name ILIKE %odoo%) rows self.env.cr.dictfetchall()我推薦優(yōu)先用dictfetchall()而不是fetchall()因?yàn)榍罢叻祷刈值淞斜碜侄蚊荒苛巳淮a可讀性高很多。但cr是一把雙刃劍。直接用SQL繞過了ORM的權(quán)限機(jī)制、緩存機(jī)制和記錄規(guī)則稍不注意就容易出現(xiàn)數(shù)據(jù)越權(quán)問題。另外在Odoo的請求生命周期里事務(wù)是由框架統(tǒng)一管理的你不需要也不應(yīng)該自己加commit()否則會出現(xiàn)事務(wù)邊界混亂。一個折中方案是能用ORM查的盡量用ORM只有遇到ORM寫起來特別費(fèi)力、而SQL又明顯更合適的場景才用cr。比如排名統(tǒng)計、跨多表的復(fù)雜聚合報表這種場景直接寫SQL反而清晰。2.2 env.uid和env.user到底該用哪個env.uid是一個整數(shù)比如2env.user是res.users的一條記錄集。兩者都能拿到當(dāng)前用戶區(qū)別在于使用場景。如果你只需要判斷“當(dāng)前用戶是不是管理員”可以用env.uid 1這種數(shù)值比較性能好、不用查庫。如果你需要讀用戶的某些字段比如郵箱、所屬組、語言偏好那就應(yīng)該用env.useruser_email self.env.user.email這里有個細(xì)節(jié)要提醒你如果一個用戶被刪除了env.user訪問時可能會拋MissingError但env.uid仍然只是個數(shù)字不會報錯。所以做底層工具類代碼時優(yōu)先保留uid的判斷邏輯可以省去很多異常處理。2.3 env.company和env.companies多公司數(shù)據(jù)隔離的關(guān)鍵在Odoo的多公司場景下env.company返回當(dāng)前公司env.companies返回當(dāng)前用戶有權(quán)限訪問的公司集合。這個差異如果沒注意很容易造成數(shù)據(jù)錯配。比如你要創(chuàng)建一條公司相關(guān)的記錄但某些字段值是從另一家公司復(fù)制過來的此時如果用env.company容易忽略用戶實(shí)際可以訪問的范圍。在記錄規(guī)則依賴公司列表時env.companies尤其重要visible_companies self.env.companies再比如你需要在代碼里判斷當(dāng)前記錄是否屬于用戶可見公司if record.company_id in self.env.companies: # 可以顯示或操作很多權(quán)限規(guī)則里搜索不到的記錄往往不是沒權(quán)看而是company_id不在env.companies范圍內(nèi)。排查權(quán)限問題時優(yōu)先看一下這兩者的值。2.4 env.context和env.lang上下文里藏著的坑env.context是一個字典包含請求的上下文信息比如語言lang、時區(qū)tz、用戶ID等。另外還有大量以default_開頭的默認(rèn)值鍵比如default_partner_id。我在項目里遇到過這樣一個案例前端某個按鈕跳轉(zhuǎn)并傳了{(lán)default_type: customer}到上下文后端在向?qū)ы撁鎰?chuàng)建記錄時一切正常但只要在哪里一次search把這段上下文帶過去了后續(xù)代碼再create一條記錄這條記錄的類型也會莫名其妙被默認(rèn)成customer排查了很久才發(fā)現(xiàn)是上下文泄漏。所以記住一個原則永遠(yuǎn)不要直接修改env.context。需要調(diào)整上下文時用with_context()生成新環(huán)境。讀上下文字段時也要用self.env.context.get(key)而不是直接訪問避免KeyError。env.lang則是上下文中語言部分的快捷方式大部分場景下等價于self.env.context.get(lang)。做多語言數(shù)據(jù)匹配或需要按語言取值時這個屬性很實(shí)用。2.5 env.ref()用XML ID定位記錄的底層邏輯你常在模塊數(shù)據(jù)文件里看到XML ID但代碼里要用XML ID找記錄時靠的就是env.ref()admin_user self.env.ref(base.user_root) delivery_product self.env.ref(product.product_delivery_01)env.ref()內(nèi)部會去ir_model_data表里查外部ID對應(yīng)的模型和記錄然后返回該記錄。它的一個隱藏特性是如果找不到會拋出ValueError所以在外界輸入可能不存在的情況下建議加個保護(hù)record self.env.ref(my_module.my_xml_id, raise_if_not_foundFalse)另外要注意env.ref()返回的已經(jīng)是帶環(huán)境權(quán)限的記錄集如果當(dāng)前用戶沒有權(quán)限讀同樣會報權(quán)限錯誤。需要繞過權(quán)限時記得用self.env.ref(xxx).sudo()。3. 主要方法的內(nèi)在邏輯查詢、寫入與環(huán)境切換3.1 env[model.name]拿到模型的空記錄集env[res.partner]返回res.partner的一個空記錄集這是后續(xù)一切操作的入口。用空記錄集調(diào)search、browse、create是Odoo最標(biāo)準(zhǔn)的寫法。Partner self.env[res.partner] partners Partner.search([(is_company, , True)]) new_partner Partner.create({name: 測試公司})有個習(xí)慣值得養(yǎng)成當(dāng)同一個模型被反復(fù)使用時先用一個變量接收空記錄集比如上面的Partner不僅減少代碼重復(fù)也能避免每行都去env[res.partner]查找模型定義帶來的小幅性能開銷。需要說明的是在Odoo中env[model_name]和env[model_name]本質(zhì)上都會走到Environment.__getitem__區(qū)別只在于如果你傳入的是一個變量省去了字符串引號比如從方法參數(shù)拿到的模型名時直接寫self.env[model_name]即可。此時如果模型不存在Odoo會拋出KeyError定位問題很容易。3.2 search和browse兩種讀取方式的分工在日常數(shù)據(jù)讀取中search負(fù)責(zé)按域條件篩選記錄browse負(fù)責(zé)按ID獲取記錄。# 搜索所有類型為customer的伙伴且只取前10條 partners self.env[res.partner].search( [(is_company, , True)], limit10, ordername asc ) # 通過ID列表獲取記錄 partner self.env[res.partner].browse(partner_id)這里有幾個使用技巧第一如果只需要統(tǒng)計數(shù)量用search(..., countTrue)它不會加載記錄集速度會快很多count self.env[res.partner].search_count([(is_company, , True)])第二browse接收的可以是單個整數(shù)、整數(shù)列表、元組也可以是已存在的記錄集。它是惰性加載的——browse本身不會立刻查庫只有當(dāng)你訪問字段時才真正查詢。這就意味著在循環(huán)里用browse并不一定比批量search慢但要注意不要在循環(huán)里反復(fù)browse同一條記錄那樣會產(chǎn)生重復(fù)查詢。第三search_read是一個高效的組合操作直接返回字典列表data_list self.env[res.partner].search_read( domain[(is_company, , True)], fields[id, name, email], limit50 )這種寫法在寫接口和報表服務(wù)時非常好用可以避免構(gòu)造大量臨時記錄集而且只讀取你需要的字段對性能更友好。3.3 create、write和unlink寫操作的正確姿勢create在單個模型上接收一個字典批量創(chuàng)建也可以接收字典列表批量創(chuàng)建partners self.env[res.partner].create([ {name: 公司A, is_company: True}, {name: 公司B, is_company: True}, ])write是批量更新方法名本身已經(jīng)說明它是按多個ID生效的partners.write({phone: 12345678})unlink刪除記錄同樣支持批量self.env[res.partner].browse(ids).unlink()寫操作有幾個容易忽略的細(xì)節(jié)。其一create和write會觸發(fā)計算字段、約束、自動化動作這在某些場景下會拖慢性能如果只是寫底層數(shù)據(jù)且確定不動業(yè)務(wù)邏輯可以考慮直接SQL但不建議新手這么做。其二create返回一個包含新記錄的空記錄集隨后操作新記錄時直接基于返回值繼續(xù)做不要重新browse。其三刪除記錄要小心關(guān)聯(lián)數(shù)據(jù)優(yōu)先考慮歸檔字段或狀態(tài)字段而不是物理刪除。3.4 sudo、with_user、with_context、with_company環(huán)境切換四件套這四個方法是我認(rèn)為env里最需要吃透的方法它們分別解決權(quán)限、用戶、上下文和公司四個維度的問題。# sudo: 生成一個跳過權(quán)限檢查的新環(huán)境 partner self.env[res.partner].sudo().browse(pid) # with_user: 模擬另一個用戶執(zhí)行代碼 demo_env self.env.with_user(self.env.ref(base.user_demo)) products demo_env[product.product].search([]) # with_context: 在指定上下文中執(zhí)行 lang_product self.env[product.product].with_context(langen_US).browse(pid) # with_company: 指定公司環(huán)境 company_a self.env.ref(base.main_company) product_with_company self.env[product.product].with_company(company_a).browse(pid)這些方法返回的都是新環(huán)境對象不會影響原環(huán)境。在寫代碼時最好把環(huán)境切換控制到最小范圍。比如下面兩種寫法# 不推薦的寫法整個模型都給了sudo權(quán)限 all_products self.env[product.product].sudo().search([]) # 推薦的寫法只對讀取這條記錄用sudo product self.env[product.product].sudo().browse(pid)范圍控制得越小越不容易把權(quán)限漏洞帶到整個業(yè)務(wù)流程里。3.5 在非模型代碼里手動構(gòu)造envself.env并不是永遠(yuǎn)都能直接拿到的。在定時任務(wù)入口、外部腳本、自定義命令行工具里你需要手動構(gòu)造環(huán)境。最常見的寫法是通過odoo.registry拿數(shù)據(jù)庫連接from odoo import api, SUPERUSER_ID db_name your_database registry odoo.registry(db_name) with registry.cursor() as cr: env api.Environment(cr, SUPERUSER_ID, {}) partners env[res.partner].search([])在舊版《Odoo開發(fā)入門與精通》相關(guān)的資料里還經(jīng)??吹給penerp.api.Environment這種寫法在新版本中都已經(jīng)統(tǒng)一到odoo.api.Environment了。采用with registry.cursor()的好處是事務(wù)結(jié)束自動提交或回滾不用手動干預(yù)。如果你在模型方法內(nèi)部需要拿到一個全新的環(huán)境比如完全脫離當(dāng)前上下文做一次干凈查詢也可以用new_env api.Environment(self.env.cr, self.env.uid, {})不過這種寫法要看清楚cr還是同一個游標(biāo)還是會在當(dāng)前事務(wù)內(nèi)執(zhí)行并不會開啟新事務(wù)。4. 我在實(shí)際項目中踩過的坑4.1 N1查詢循環(huán)里search的危害一開始寫Odoo模塊時我特別容易寫出這種代碼for order in orders: partner self.env[res.partner].search([(id, , order.partner_id.id)]) partner_name partner.name看起來邏輯沒毛病但每條search都會發(fā)起一次數(shù)據(jù)庫查詢循環(huán)100條訂單就查100次伙伴表。此時更合理的做法是一次性把伙伴ID收集起來然后一次性查詢partner_ids orders.mapped(partner_id.id) partners self.env[res.partner].browse(partner_ids)或者干脆直接通過關(guān)聯(lián)字段讀取比如order.partner_id.name本身已經(jīng)懶加載并緩存了不需要專門去search。在問題定位階段Odoo自帶的日志模式非常好用。啟動服務(wù)時加上--log-leveldebug_sql就能看到每一條SQL查詢。如果發(fā)現(xiàn)某個頁面請求后SQL數(shù)量異常多先別急著看代碼邏輯直接從SQL數(shù)量往回找循環(huán)。4.2 上下文泄漏default_開頭的key引發(fā)的問題前面提到過default_開頭的上下文鍵這里再展開講一個真實(shí)案例。有一次我處理一個銷售訂單用戶從某個對賬單頁面跳轉(zhuǎn)到新建訂單URL里帶了default_partner_id123。我在這段業(yè)務(wù)邏輯里有一段代碼讀取了self.env.context并把它塞進(jìn)了后續(xù)的create調(diào)用ctx self.env.context order self.env[sale.order].with_context(ctx).create(vals)結(jié)果新訂單里partner_id被自動帶上了123而用戶本想在界面上自己重新選擇客戶。因?yàn)橐晥D字段的default實(shí)現(xiàn)機(jī)制就是讀取上下文中的default_鍵當(dāng)你把整個上下文原封不動傳給create時默認(rèn)值會重新應(yīng)用一遍。后來我改成只把必要參數(shù)放進(jìn)去不再全程攜帶default_鍵問題才徹底消失。所以經(jīng)驗(yàn)是上下文不是越大越全越好傳遞時要有意識過濾掉default_開頭的鍵。如果你只是想把語言、時區(qū)之類的信息帶過去直接保留少量鍵或新建一個小字典即可。4.3 sudo()的范圍控制權(quán)限放太寬會出事sudo()非常強(qiáng)大但也非常危險。它本質(zhì)上是讓代碼繞過權(quán)限規(guī)則去操作數(shù)據(jù)適合用來處理系統(tǒng)底層的、用戶不應(yīng)干預(yù)的數(shù)據(jù)。有一次我寫了一個自動同步合同狀態(tài)的定時任務(wù)因?yàn)樯婕岸鄠€模型我圖省事在方法開頭直接加了def _sync_contracts(self): self.sudo() # 這種寫法是無效且危險的等等很多資料告訴你self.sudo()是讓整個記錄集切換環(huán)境但實(shí)際它返回一個新記錄集原self并不會變。要想對當(dāng)前記錄集生效需要寫成self self.sudo()。而往往有人誤以為調(diào)用過一次就全局生效導(dǎo)致后續(xù)校驗(yàn)權(quán)限的代碼失效普通用戶能通過某些路徑觸發(fā)管理員權(quán)限的邏輯。正確的做法是把sudo()收斂到最小范圍比如只對某個模型查詢用sudocontracts self.env[contract.contract].sudo().search([...])4.4 onchange里的env操作讓表單卡了半天onchange方法在表單視圖里觸發(fā)非常頻繁用戶每改一個字段就可能觸發(fā)一次。我在一個客戶項目里在某個onchange里順手用self.env[sale.order].search([])統(tǒng)計歷史訂單每次切換客戶都觸發(fā)全表搜索頁面卡得讓人懷疑人生。排查后發(fā)現(xiàn)onchange建議只做內(nèi)存級的字段聯(lián)動計算不要做重量級的搜索和創(chuàng)建。如果確實(shí)需要展示統(tǒng)計信息可以考慮用計算字段并通過api.depends控制重算時機(jī)或者把統(tǒng)計邏輯放到服務(wù)端方法中由按鈕觸發(fā)而不是每次字段變化都跑。另外onchange中的self是一份模擬記錄集訪問它上面未在視圖里加載的字段可能取到空值或不可預(yù)期的值所以盡量只依賴當(dāng)前已經(jīng)改變的字段和視圖上已有字段。5. 一些實(shí)用習(xí)慣和項目建議5.1 代碼規(guī)范env相關(guān)寫法的一些約定我在帶團(tuán)隊開發(fā)Odoo項目時會特別強(qiáng)調(diào)幾條關(guān)于env的代碼約定這些約定看起來瑣碎但非常有效。第一所有模型類名和記錄集的變量命名要清晰。比如self.env[res.partner]不要到處寫封裝成局部變量Partner self.env[res.partner]后續(xù)統(tǒng)一用Partner。第二凡是調(diào)用sudo()、with_user()、with_company()切換環(huán)境的地方一定要加注釋說明為什么需要切換否則半個月后自己再看都不一定記得。第三不要在compute方法里頻繁創(chuàng)建記錄計算字段是冪等優(yōu)先的里面寫create會引發(fā)幽靈數(shù)據(jù)問題。5.2 怎么快速排查env相關(guān)的問題當(dāng)你懷疑env相關(guān)的問題時我有幾個快速定位手段。先看self.env.context里是否有奇怪的鍵值特別是default_和active_id等。再確認(rèn)sudo()是否出現(xiàn)在不該出現(xiàn)的位置with_user切換的用戶是不是預(yù)期用戶。接著用日志模式跑一遍請求看SQL數(shù)量和耗時集中在哪個模型上。如果你在排查權(quán)限問題時建議臨時寫一個方法打印當(dāng)前環(huán)境信息api.model def debug_env(self): print(uid:, self.env.uid) print(user:, self.env.user.login) print(company:, self.env.company.name) print(companies:, self.env.companies.mapped(name)) print(context:, self.env.context)這種方式比反復(fù)打斷點(diǎn)來得更快尤其在服務(wù)端日志環(huán)境下一眼就能看清當(dāng)前代碼到底以什么身份在跑。5.3 為什么國內(nèi)Odoo社區(qū)里env的問題反復(fù)被問作為一個經(jīng)常在社區(qū)里翻帖子的人我發(fā)現(xiàn)關(guān)于self.env的問題在國內(nèi)社區(qū)出現(xiàn)頻率非常高。究其原因一方面是Odoo國內(nèi)資料本來就參差不齊很多講解只給代碼不給原理新手對著教程抄完能跑一旦環(huán)境發(fā)生變化就懵了另一方面是Odoo本身學(xué)習(xí)曲線比較陡模型、記錄集、環(huán)境這三層概念和傳統(tǒng)MVC框架里的“模型層”不是一個東西很多人拿Django、Rails的思維套Odoo自然覺得別扭。另外國內(nèi)很多項目并沒有成熟的中文Odoo人才梯隊很多開發(fā)者是半路轉(zhuǎn)過來的基礎(chǔ)概念沒有系統(tǒng)梳理過。所以遇到環(huán)境、上下文、權(quán)限這類偏底層問題時只能靠一次次試錯積累經(jīng)驗(yàn)。這篇關(guān)于self.env的文章也算是我把這幾年的積累做一個系統(tǒng)性的梳理希望后來者能少踩一些我當(dāng)年踩過的坑。環(huán)境這套機(jī)制一旦理解了它在程序中的角色再回頭看那些sudo、with_context、search代碼你會覺得一切都順理成章。建議你下次寫模塊前先把自己代碼里所有self.env的用法拉出來看一遍該封裝的封裝、該清理的清理、該控制范圍的控制好范圍代碼質(zhì)量會直接上一個臺階。