標籤: 包裝設計

  • 從7萬到25萬,這些車為了衝量竟然最高降3.7萬!

    從7萬到25萬,這些車為了衝量竟然最高降3.7萬!

    6L自動擋版本綜合油耗在5毛錢以內。別克君威指導價:17。89-27。99萬降價幅度:3。7萬推薦車型:2015款1。6T領先技術型君威作為別克的一款中高檔轎車,定位介於英朗和君越之間,屬於一款20萬級別的中型車,在乘坐空間,配置,做工用料上,都比代步車上了一個層次,而這款車的優惠幅度也是同級別車型中最大的,最高達到了3。

    許多經銷商們為了年底衝量,在7萬-25萬這個區間的車型,許多都放出了最大降價的優惠,從小型車,緊湊型車再到中級車,SUV,羅列了幾款降價幅度很大的好品質車型,供大家參考參考。

    雪鐵龍愛麗舍

    指導價:8.38-12.08萬元

    降價幅度:1.5萬元

    車型:2016款1.6L自動豪華型

    愛麗舍是法國雪鐵龍推出的一款家用轎車,自2002年誕生以來,用戶好口碑一直不斷積累着,為了針對中國人的審美觀,新款愛麗舍擁有更加動感的車身線條和造型設計,廠家似乎正通過這款車和消費者說:其實我們要造好造型還是不差於別人的。

    愛麗舍內飾的整體風格簡潔,清新,高雅,且空調出風口設計得很大,製造出風最好的效率,每個區域分隔清晰,操作非常方便,車型上推薦買自動豪華版,因為配置豐富帶ESp,真皮座椅,倒車影像,安全側氣囊等,優惠完車身價大概在10萬元左右。動力則全系搭載1.6L自然吸氣發動機,匹配5擋手動和6擋手自一體變速器。

    車主評價:動力充足,配置夠用,外形沉穩時尚,底盤舒服有韌性,剎車感覺非常線性。

    大眾高爾夫

    指導價:12.19-23.99萬

    降價幅度:2.8萬

    推薦車型:2016款 1.6L自動時尚型

    從1974年開始,大眾高爾夫就誕生了,直至今年,已經在全球市場推出七代車型,也是大眾最暢銷的車型之一,在中國,兩廂的高爾夫非常受人們歡迎,比起六代,七代高爾夫在外觀造型上更加犀利運動。

    內飾風格同樣也是大眾風格,簡約實用,對於家用代步來說,車型版本選擇1.6L自然吸氣排量就夠了,如果不需要天窗的可以選自動時尚型,匹配6擋手自一體,配置安全側氣囊,ESp車身穩定控制,上坡輔助,自動駐車等等,而如果是喜歡追尋動力的年輕人,可以考慮1.4T渦輪增壓發動機版本,匹配7擋雙離合變速器,更有操控和駕駛的樂趣。

    車主評價:外觀好看,舒適度滿意,轉向精準,隔音做得蠻好,油耗低,1.6L自動擋版本綜合油耗在5毛錢以內。

    別克君威

    指導價:17.89-27.99萬

    降價幅度:3.7萬

    推薦車型:2015款1.6T領先技術型

    君威作為別克的一款中高檔轎車,定位介於英朗和君越之間,屬於一款20萬級別的中型車,在乘坐空間,配置,做工用料上,都比代步車上了一個層次,而這款車的優惠幅度也是同級別車型中最大的,最高達到了3.7萬元。

    推薦的這款1.6T領先技術型,匹配6速手自一體變速箱,算是各方面相對均衡的一個版本,動力夠用,配置齊全,帶有胎壓監測,ESp,上坡輔助,電動天窗,自動空調等等,再加上美系車蠻好的隔音效果,優質的底盤響應,用料十分厚道,總有一種讓人們覺得這個價格買得值的感覺。

    車主口碑:隔音好,外觀公認好看,車身穩重,安全感強烈。

    現代ix35

    指導價:14.98-22.28萬

    降價幅度:2.8萬元

    推薦車型:2015款 2.0L自動兩驅智能型國IV

    現代ix35的外形設計由德國法蘭克福研發中心完成,帶有明顯歐式風格,時尚前衛,動感強韌,韓系車都喜歡將錢花在大家都看得到的地方,同樣的價格,假如這台車外形更好看的話,中國老百姓就喜歡,就覺得值,同樣車內空間的拓展上做得非常好,後備箱也一樣寬大。

    內飾方面,營造了一種溫馨家用的風格,做工細膩手感順滑,並採用大面積的銀色裝飾,方向盤採用真皮縫製,配置非常貼心,配備有安全側氣囊,無鑰匙進入與啟動,ESp,上坡輔助,電動天窗等等,同等價位里這個配置挺高分的了,搭載有2.0L和2.4L自然吸氣發動機,匹配6速手動和6速手自一體變速器。

    車主評價:同等級對比后,內部空間大,配置高,性價比強。

    本站聲明:網站內容來源於http://www.auto6s.com/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※別再煩惱如何寫文案,掌握八大原則!

    網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

    ※超省錢租車方案

    ※教你寫出一流的銷售文案?

    網頁設計最專業,超強功能平台可客製化

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

  • 汽車的頭枕為什麼怎麼調都不舒服?

    汽車的頭枕為什麼怎麼調都不舒服?

    這一后一前兩個猛烈彎折動作,足以導致頸椎折斷。這是重傷或者致死的主要原因。正因為被追尾的主要死/傷原因是揮鞭效應,所以汽車製造商就推出了相關的硬件安全設計。最常見的現行設計就是主動頭枕,防止頸部受傷。主動安全頭枕就是在碰撞的瞬間讓頭部得到支撐,也就是把頭枕往前移動。

    很多朋友買來新車以後會裝很多配件,比如座椅套,方向盤套。

    而細心的朋友會發現,不管頭枕怎麼調,手動調節還是電動調節都不能調到一個很舒服的位置,總覺得頭靠上去脖子部分空空的,所以很多朋友一般都會額外加裝一個托住脖子的軟墊。難道汽車廠商從來都沒有發現這個問題嗎?

    其實汽車頭枕的全稱是汽車座椅安全頭枕,在早些年的時候汽車都沒有頭枕這個東西。

    他的出現主要是為提高汽車安全性而設置的一種輔助裝置。

    因為在被追尾時, 安全帶是不會發揮作用的.追尾帶來的最大傷害的原因是揮鞭效應, 撞擊一瞬, 被撞者頭部猛烈後仰, 頸椎在巨大的頭部加速作用下,嚴重的頸椎會當場折斷,頭部後仰,在巨大慣性作用下,然後頭部猛烈想前運動,頸椎前折. 這一后一前兩個猛烈彎折動作,足以導致頸椎折斷。

    這是重傷或者致死的主要原因.正因為被追尾的主要死/傷原因是揮鞭效應,所以汽車製造商就推出了相關的硬件安全設計。最常見的現行設計就是主動頭枕,防止頸部受傷。

    主動安全頭枕就是在碰撞的瞬間讓頭部得到支撐,也就是把頭枕往前移動。其机械結構也不複雜,只要在椅背內設計了一個連桿,發生來自後部的撞擊時,身體重量擠壓在這個連桿上,在聯動機構的作用下,頭枕向前移動,最大限度地防止頭部猛烈後仰,從而保護人員頭部和頸部的安全。

    雖然現在也有些廠商將頭枕設計得很舒服,但是汽車的頭枕在絕大多數情況下都只是一項安全配置。

    最後祝所有TV的粉絲元旦快樂,在新的一年能開超跑。

    本站聲明:網站內容來源於http://www.auto6s.com/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※教你寫出一流的銷售文案?

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※回頭車貨運收費標準

    ※別再煩惱如何寫文案,掌握八大原則!

    ※超省錢租車方案

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

  • 我買中國品牌的車不只是因為便宜!

    我買中國品牌的車不只是因為便宜!

    觀致耗資1600萬與超跑製造商柯尼塞克共同研發的無凸輪軸發動機(代號QamFree),同等排量下,要比傳統的發動機的功率大幅提升45%,最大扭矩大幅提高47%。假如測試通過,進行量產,將成為未來發動機技術的主要發展方向,彷彿為全世界指明了一條比混合動力更為通暢的道路。

    自主品牌亮瞎眼睛的地方還多着!

    當然了,自主品牌能夠亮瞎眼的地方遠不止這兩點。力壓奔馳、寶馬,在歐洲新車安全評鑒協會Euro-NCAp所進行的2013年最佳碰撞車型評選(小型家用車類)一舉奪得第一名的觀致3,讓一眾歪果仁瞠目結舌。觀致耗資1600萬與超跑製造商柯尼塞克共同研發的無凸輪軸發動機(代號QamFree),同等排量下,要比傳統的發動機的功率大幅提升45%,最大扭矩大幅提高47%。假如測試通過,進行量產,將成為未來發動機技術的主要發展方向,彷彿為全世界指明了一條比混合動力更為通暢的道路。吧唧這麼多,我想說的其實很簡單:自主品牌比你想象中牛逼多了!趕緊去4S店下訂吧!

    本站聲明:網站內容來源於http://www.auto6s.com/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※超省錢租車方案

    ※別再煩惱如何寫文案,掌握八大原則!

    ※回頭車貨運收費標準

    ※教你寫出一流的銷售文案?

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

  • Logstash下字段以及嵌套Json字段類型轉換

    Logstash下字段以及嵌套Json字段類型轉換

     

    前言

    從filebeat傳輸到Logstash的數據,某個字段需要由string類型裝換成float類型。但是不管怎麼改logstash的配置文件都不生效,其實官方文檔都有,但是具體細節方面的東西就得自己不斷的實踐驗證最後達到自己想要的目標了。整整一天,都在弄這一個,中間實在想放棄了。但是就如張靚穎的“終於等到你,還好沒放棄”,最後在某一篇博文得到了啟發,才解決。

     

    這裏類型轉換分兩個類型:

    1)字段是單純的字段,也就是直接在_source下的

    2)字段是在json里的,在_source下還有嵌套一層json里的字段

     

    一、單一字段

    可以從下面的圖中看出,字段就在頂層機構_source下,這種情況下的Logstash配置文件設置如下:

    filter {
         mutate {
         convert => { "request_time" => "float" }
         convert => { "upstream_response_time" => "float" }
         }
    }

     

     

     

     

    二、嵌套Json下的字段

    如果需要轉換的字段是在非頂級結構下,是在一個JSON里,因為在filebeat做decode的時候指定了,如我需要轉換的字段是在jsonn的json字段里:

    processors:
     - decode_json_fields:
        fields: ["message"]    #要進行解析的字段
        process_array: false   #數組是否解碼,默認值:false
        max_depth: 3           #解碼深度,默認值:1
        target: "jsonn"          #json內容解析到指定的字段,如果為空(“”),則解析到頂級結構下
        overwrite_keys: false  #如果解析出的json結構中某個字段在原始的event(在filebeat中傳輸的一條數據為一個event)中也存在,是否覆蓋

     

     

     

    這種情況下的Logstash配置文件設置如下:

    filter {
        mutate {
          convert => { "[jsonn][request_time]" => "float" }
          convert => { "[jsonn][upstream_response_time]" => "float" }
       }
    }
    

     

    注意:

    [jsonn][request_time] 不是[jsonn].[request_time],也不是jsonn.request_time沒有點.

     

    受啟發的鏈接:

    https://stackoverflow.com/questions/30369148/logstash-remove-deep-field-from-json-file

     

    本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※自行創業缺乏曝光? 網頁設計幫您第一時間規劃公司的形象門面

    網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

    ※想知道最厲害的網頁設計公司"嚨底家"!

    ※別再煩惱如何寫文案,掌握八大原則!

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

  • CSS中的float和margin的混合使用

    CSS中的float和margin的混合使用

    在最近的學習中,在GitHub上找了一些布局練習,我發現了我自己對布局超級不熟悉(很難受)。

    在以前的學習CSS過程中,感覺使用CSS就記住各個屬性的屬性值以及作用就OK了,但是實際上呢?呵呵一笑。不說了,太傷心了,進入正題吧!

    最近我使用float和margin布局,加深了我對這兩個一起使用的理解。(新生可以看一下,大神請忽略

    float屬性

    float: left | right | none | inherit
    

    當然最常用的還是前面兩個:向左浮動向右浮動

    浮動最主要的特點:脫標

    脫離標準流,處於更加高級的層面,影響父元素和後面元素的布局,這裏就不具體介紹了。

    margin屬性

    這裏主要講margin-leftmargin-right

    margin-left: 設置元素的左外邊距。
    margin-right: 設置元素的右外邊距。
    

    總的來說,這個兩個屬性的字面理解還是很容易的,但是越簡單的東西越不要小看。

    重點

    以下代碼:

    html:

    <div class="box">
        <div class="zi_box1">1</div>
        <div class="zi_box2">2</div>
        <div class="zi_box3">3</div>
        <div class="clear"></div>
    </div>
    

    CSS:

    .box {
                background-color: #555555;
                width: 600px;
                height: 200px;
            }
            .zi_box1 {
                float: left;
                background-color: #c23232;
                width: 200px;
                height: 100px;
            }
            .zi_box2 {
                float: left;
                background-color: chartreuse;
                width: 200px;
                height: 100px;
            }
    
            .zi_box3 {
                float: left;
                background-color: blue;
                width: 200px;
                height: 100px;
            }
            .clear {
                clear: both;
            }
    

    最後實現的效果圖:

    三個子盒子充滿父盒子,因為但他們寬度可以在父盒子裏面撐開。

    如果父盒子撐不開呢?

    加大一個子盒子的寬度,序號為3的盒子

    zi_box3 {
        width: 300px;
    }
    

    效果圖如下:

    那麼第三個盒子則會另外起一行。

    結合margin使用時

    在第一代碼的基礎上,增加一個margin值

    zi_box1 {
        margin-left: 20px;
    }
    

    這時候,由於三個盒子的寬度加上margin值大於父盒子的寬度,所以盒子3就會另起一行

    反之,給盒子3設置一個外邊距的值,盒子1和盒子2不設置外邊距,是不是盒子3也會另外起一行呢?答案是肯定的,因為他們的寬度已經超過父盒子的值了。

    實現三列布局

    在不改變DOM的順序的情況下,使盒子3盒子1盒子2的順序呢?是不是就可以充分使用margin這個屬性了。最開始白痴的我(很少練習布局吧,大神就不要噴我了,我只是個菜鳥)

    白痴代碼

    .zi_box1 {
         margin-left: 200px;       
    }
    .zi_box2 {
         margin-left: 200px;       
    }
    
    .zi_box3 {
         margin0left: -400px;
    }
    //這裏很天真的想法,以為每個元素是單獨行動
    

    這樣寫的效果圖:

    我當時就傻了,這是什麼玩意。

    但是在最後的摸索中,我知道原因了,最最最最重要的就是DOM的執行順序

    造成這樣的原因就是:盒子1先解析,margin-left: 200px,那麼這樣盒子3也就去了第二行; 再盒子2解析,margin-left:200px,那麼盒子2也去了第二行,因為第一行已經有600px這麼寬的長度了。最後解析盒子3,margin-left:-400px,盒子向前移動400px,不就造成了這樣的效果圖嘛。

    這樣想的,就是指考慮片面的,而不是全局的

    實現三列布局的最終代碼

    .zi_box1 {
         margin-left: 200px;       
    }
    .zi_box2 {
         margin-left: 0px;      
    }
    
    .zi_box3 {
         margin0left: -600px;
    }
    

    效果圖

    可以簡單的這樣理解

    盒子1向右移動200px,那麼盒子2和盒子3也會向右移動200px,具體的效果圖如下

    那麼盒子3移動到前面去,是不是需要600px的距離啊(是不是很容易懂,嘻嘻),當然這隻是我的片面理解,也不完全是對的。
    在這種思維模式下,還要注意一點:當超出的部分盒子還是會遵守float的規則的。

    那麼float: right和margin-right是一樣的道理。

    這是我的第一篇博客,寫的太菜,不要笑我喲。

    喜歡我的話,點個關注吧!

    本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※別再煩惱如何寫文案,掌握八大原則!

    ※教你寫出一流的銷售文案?

    ※超省錢租車方案

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

  • 學習ASP.NET Core(10)-全局日誌與xUnit系統測試

    學習ASP.NET Core(10)-全局日誌與xUnit系統測試

    上一篇我們介紹了數據塑形,HATEOAS和內容協商,並在制器方法中完成了對應功能的添加;本章我們將介紹日誌和測試相關的概念,並添加對應的功能

    一、全局日誌

    在第一章介紹項目結構時,有提到.NET Core啟動時默認加載了日誌服務,且在appsetting.json文件配置了一些日誌的設置,根據設置的日誌等級的不同可以進行不同級別的信息的显示,但它無法做到輸出固定格式的log信息至本地磁盤或是數據庫,所以需要我們自己手動實現,而我們可以藉助日誌框架實現。

    ps:在第7章節中我們記錄的是數據處理層方法調用的日誌信息,這裏記錄的則是ASP.NET Core WebAPI層級的日誌信息,兩者有所差異

    1、引入日誌框架

    .NET程序中常用的日誌框架有log4net,serilog 和Nlog,這裏我們使用Serilog來實現相關功能,在BlogSystem.Core層使用NuGet安裝Serilog.AspNetCore,同時還需要搜索Serilog.Skins安裝希望支持的功能,這裏我們希望添加對文件和控制台的輸出,所以選擇安裝的是Serilog.Skins.File和Serilog.Skins.Console

    需要注意的是Serilog是不受appsetting.json的日誌設置影響的,且它可以根據命名空間重寫記錄級別。還有一點需要注意的是需要手動對Serilog對象進行資源的釋放,否則在系統運行期間,無法打開日誌文件。

    2、系統添加

    在BlogSystem.Core項目中添加一個Logs文件夾,並在Program類中進行Serilog對象的添加和使用,如下:

    3、全局添加

    1、這個時候其實系統已經使用Serilog替換了系統自帶的log對象,如下圖,Serilog會根據相關信息進行高亮显示:

    2、這個時候問題就來了,我們怎麼才能進行全局的添加呢,總不能一個方法一個方法的添加吧?還記得之前我們介紹AOP時提到的過濾器Filter嗎?ASP.NET Core中一共有五類過濾器,分別是:

    • 授權過濾器Authorization Filter:優先級最高,用於確定用戶是否獲得授權。如果請求未被授權,則授權過濾器會使管道短路;
    • 資源過濾器Resource Filter:授權后運行,會在Authorization之後,Model Binding之前執行,可以實現類似緩存的功能;
    • 方法過濾器Action Filter:在控制器的Action方法執行之前和之後被調用,可以更改傳遞給操作的參數或更改從操作返回的結果;
    • 異常過濾器Exception Filter:當Action方法執行過程中出現了未處理的異常,將會進入這個過濾器進行統一處理;
    • 結果過濾器Result Filter:執行操作結果之前和之後運行,僅在action方法成功執行后才運行;

    過濾器的具體執行順序如下:

    3、這裏我們可以藉助異常過濾器實現全局日誌功能的添加;在在BlogSystem.Core項目添加一個Filters文件夾,添加一個名為ExceptionFilter的類,繼承IExceptionFilter接口,這裡是參考老張的哲學的簡化版本,實現如下:

    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.AspNetCore.Mvc.Filters;
    using Microsoft.Extensions.Logging;
    using Serilog;
    using System;
    
    namespace BlogSystem.Core.Filters
    {
        public class ExceptionsFilter : IExceptionFilter
        {
            private readonly ILogger<ExceptionsFilter> _logger;
    
            public ExceptionsFilter(ILogger<ExceptionsFilter> logger)
            {
                _logger = logger;
            }
    
            public void OnException(ExceptionContext context)
            {
                try
                {
                    //錯誤信息
                    var msg = context.Exception.Message;
                    //錯誤堆棧信息
                    var stackTraceMsg = context.Exception.StackTrace;
                    //返回信息
                    context.Result = new InternalServerErrorObjectResult(new { msg, stackTraceMsg });
                    //記錄錯誤日誌
                    _logger.LogError(WriteLog(context.Exception));
                }
                catch (Exception e)
                {
                    Console.WriteLine(e);
                    throw;
                }
                finally
                {
                    //記得釋放,否則運行時無法打開日誌文件
                    Log.CloseAndFlush();
                }
    
            }
    
            //返回500錯誤
            public class InternalServerErrorObjectResult : ObjectResult
            {
                public InternalServerErrorObjectResult(object value) : base(value)
                {
                    StatusCode = StatusCodes.Status500InternalServerError;
                }
            }
    
            //自定義格式內容
            public string WriteLog(Exception ex)
            {
                return $"【異常信息】:{ex.Message} \r\n 【異常類型】:{ex.GetType().Name} \r\n【堆棧調用】:{ex.StackTrace}";
            }
        }
    }
    

    4、在Startup類的ConfigureServices方法中進行異常處理過濾器的註冊,如下:

    5、我們在控制器方法中拋出一個異常,分別查看效果如下,如果覺得信息太多,可調整日誌記錄級別:

    二、系統測試

    這裏我們從測試的類別出發,了解下測試相關的內容,並添加相關的測試(介紹內容大部分來自微軟官方文檔,為了更易理解,從個人習慣的角度進行了修改,如有形容不當之處,可在評論區指出)

    1、測試說明及分類

    1、自動測試是確保軟件應用程序按照作者期望執行操作的一種絕佳方式。軟件應用有多種類型的測試,包括單元測試、集成測試、Web測試、負載測試和其他測試。單元測試用於測試個人軟件的組件或方法,並不包括如數據庫、文件系統和網絡資源類的基礎結構測試。

    當然我們可以使用編寫測試的最佳方法,如測試驅動開發(TDD)所指的先編寫單元測試,再編寫該單元測試要檢查的代碼,就好比先編寫書籍的大綱,再編寫書籍。其主要目的是為了幫助開發人員編寫更簡單,更具可讀性的高效代碼。兩者區別如下(來自Edison Zhou)

    2、以深度(測試的細緻程度)和廣度(測試的覆蓋程度)區分, 測試分類如下(此處內容來自solenovex):

    Unit Test 單元測試:它可以測試一個類或者一個類的某個功能,但其覆蓋程度較低;

    Integration Test 集成測試:它的細緻程度沒有單元測試高,但是有較好的覆蓋程度,它可以測試功能的組合,以及像數據庫或文件系統這樣的外部資源;

    Subcutaneous Test 皮下測試 :其作用區域為UI層的下一層,有較好的覆蓋程度,但是深度欠佳;

    UI測試:直接從UI層進行測試,覆蓋程度很高,但是深度欠佳

    3、在編寫單元測試時,盡量不要引入基礎結構依賴項,這些依賴項會降低測試速度,使測試更加脆弱,我們應當將其保留供集成測試使用。可以通過遵循显示依賴項原則和使用依賴項注入避免應用程序中的這些依賴項,還可以將單元測試保留在單獨的項目中與集成測試相分離,以確保單元測試項目沒有引用或依賴於基礎結構包。

    總結下常用的單元測試和集成測試,單元測試會與外部資源隔離,以保證結果的一致性;而集成測試會依賴外部資源,且覆蓋面更廣。

    2、測試的目的及特徵

    1、為什麼需要測試?我們從以單元測試為例從4個方面進行說明:

    • 時間人力成本:進行功能測試時,通常涉及打開應用程序,執行一系列需要遵循的步驟來驗證預期的行為,這意味着測試人員需要了解這些步驟或聯繫熟悉該步驟的人來獲取結果。對於細微的更改或者是較大的更改,都需要重複上述過程,而單元測試只需要按一下按鈕即可運行,無需測試人員了解整個系統,測試結果也取決於測試運行程序而非測試人員。
    • 防止錯誤回歸:程序更改後有時會出現舊功能異常的問題,所以測試時不僅要測試新功能還要確保舊功能的正常運行。而單元測試可以確保在更改一行代碼后重新運行整套測試,確保新代碼不會破壞現有的功能。
    • 可執行性:在給定某個輸入的情況下,特定方法的作用或行為可能不會很明顯。比如,輸入或傳遞空白字符串、null后,該方法會有怎樣的行為?而當我們使用一套命名正確的單元測試,並清楚的解釋給定的輸入和預期輸出,那麼它將可以驗證其有效性。
    • 減少代碼耦合:當代碼緊密耦合時,會難以進行單元測試,所以以創建單元測試為目的時,會在一定程度上要求我們注意代碼的解耦

    2、優質的測試需要符合哪些特徵,同樣以單元測試為例:

    • 快速:成熟的項目會進行數千次的單元測試,所以應當花費非常少的時間來運行單元測試,一般來說在幾毫秒
    • 獨立:單元測試應當是獨立的,可以單獨運行,不依賴文件系統或數據庫等外部因素
    • 可重複:單元測試的結果應當保持一致,即運行期間不進行更改,返回的結果應該相同
    • 自檢查:測試應當在沒有人工交互的情況下,自動檢測是否通過
    • 及時:編寫單元測試不應該花費過多的時間,如果花費時間較長,應當考慮另外一種更易測試的設計

    在具體的執行時,我們應當遵循一些最佳實踐規則,具體請參考微軟官方文檔單元測試最佳做法

    3、xUnit框架介紹

    常用的單元測試框架有MSTestxUnitNUnit,這裏我們以xUnit為例進行相關的說明

    3.1、測試操作

    首先我們要明確如何編寫測試代碼,一般來說,測試分為三個主要操作:

    • Arrange:意為安排或準備,這裏可以根據需求進行對象的創建或相關的設置;
    • Act:意為操作,這裏可以執行獲取生產代碼返回的結果或者是設置屬性;
    • Assert:意為斷言,這裏可以用來判斷某些項是否按預期進行,即測試通過還是失敗

    3.2、Assert類型

    Assert時通常會對不同類型的返回值進行判斷,而在xUnit中是支持多種返回值類型的,常用的類型如下:

    boolean:針對方法返回值為bool的結果,可以判斷結果是true或false

    string:針對方法返回值為string的結果,可以判斷結果是否相等,是否以某字符串開頭或結尾,是否包含某些字符,並支持正則表達式

    數值型:針對方法返回值為數值的結果,可以判斷數值是否相等,數值是否在某個區間內,數值是否為null或非null

    Collection:針對方法返回值為集合的結果,可以針對集合內所有元素或至少一個元素判斷其是否包含某某字符,兩個集合是否相等

    ObjectType:針對方法返回值為某種類型的情況,可以判斷是否為預期的類型,一個類是否繼承於另一個類,兩個類是否為同一實例

    Raised event:針對事件是否執行的情況,可以判斷方法內部是否執行了預期的事件

    3.3、常用特性

    在xUnit中還有一些常用的特性,可作用於方法或類,如下:

    [Fact]:用來標註該方法為測試方法

    [Trait(“Name”,”Value”)]:用來對測試方法進行分組,支持標註多個不同的組名

    [Fact(Skip=”忽略說明…”)]:用來修飾需要忽略測試的方法

    3.4 、性能相關

    在測試時我們應當注意性能上的問題,針對一個對象供多個方法使用的情況,我們可以使用共享上下文

    • 針對一個對象供同一類中的多個方法使用時,可以將該對象提取出來,使用IClassFixture 對象將其注入到構造函數中
    • 針對一個對象供多個測試類使用的情況,可以使用ICollectionFixture 對象和[CollectionDefinition(“…”)]定義該對象

    需要注意在使用IClassFixtureICollectionFixture對象時應當避免多個測試方法之間相互影響的情況

    3.5、數據驅動測試

    在進行測試方法時,通常我們會指定輸入值和輸出值,如希望多測試幾種情況,我們可以定義多個測試方法,但這顯然不是一個最佳的實現;在合理的情況下,我們可以將參數和數據分離,如何實現?

    • 方法一:使用[Theory]替換[Fact],將輸入輸出參數提取為方法參數,並使用多個[InlineData(“輸入參數”,”輸出參數)]來標註方法
    • 方法二:使用[Theory]替換[Fact],針對測試方法新增一個測試數據類,該類包含一個靜態屬性IEumerable<object[]>,將數據封裝為一個list后賦值給該屬性,並使用[MemberData(nameof(數據類的屬性),MemberType=typeof(數據類))]標註測試方法即可;
    • 方法三:使用外部數據如數據庫數據/Excel數據/txt數據等,其實現原理與方法二相同,只是多了一個數據獲取封裝為list的步驟;
    • 方法四:自定義一個Attribute,繼承自DataAttribute,實現其對應的方法,使用yield返回object類型的數組;使用時只需要在測試方法上方添加[Theory][自定義Attribute]即可

    4、測試項目添加

    4.1、添加測試項目

    首先我們右鍵項目解決方案選擇添加一個項目,輸入選擇xUnit後進行添加,項目命名為BlogSystem.Core.Test,如下:

    項目添加完成后我們需要添加對測試項目的引用,在解決方案中右擊依賴項選擇添加BlogSystem.Core;這裏我們預期對Controller進行測試,但後續有可能會添加其他項目的測試,所以我們建立一個Controller_Test文件夾保證項目結構相對清晰。

    4.2、添加測試方法

    在BlogSystem.Core.Test項目的Controller_Test文件夾下新建一個命名為UserController_Should的方法;在微軟的《單元測試的最佳做法》文檔中有提到,測試命名應該包括三個部分:①被測試方法的名稱②測試的方案③方案預期行為;實際使用時也可以對照測試的方法進行命名,這裏我們先不考慮最佳命名原則,僅對照測試方法進行命名,如下:

    using Xunit;
    
    namespace BlogSystem.Core.Test.Controller_Test
    {
        public class UserController_Should
        {
            [Fact]
            public void Register_Test()
            {
                
            }
        }
    }
    

    4.3、方案選擇

    1、在進行測試時,我們可以根據實際情況使用以下方案來進行測試:

    • 方案一:直接new一個Controller對象,調用其Action方法直接進行測試;適用於Controller沒有其他依賴項的情況;
    • 方案二:當有多個依賴項時,可以藉助工具來模擬實例化時的依賴項,如Moq就是一個很好的工具;當然這需要一定的學習成本;
    • 方案三:模擬Http請求的方式來調用API進行測試;NuGet中的Microsoft.AspNetCore.TestHost就支持這類情況;
    • 方案四:自定義方法實例化所有依賴項;將測試過程種需要用到的對象放到容器中並加載,其實現較為複雜;

    這裏我們以測試UserController為例,其構造函數包含了接口服務實例和HttpContext對象實例,Action方法內部又有數據庫連接操作,從嚴格意義上來講測試這類方法已經脫離了單元測試的範疇,屬於集成測試,但這類測試一定程度上可以節省我們大量的重複勞動。這裏我們選擇方案三進行相關的測試。

    2、如何使用TestHost對象?先來看看它的工作流程,首先它會創建一個IHostBuilder對象,並用它創建一個TestServer對象,TestServer對象可以創建HttpClient對象,該對象支持發送及響應請求,如下圖所示(來自solenovex):

    在嘗試使用該對象的過程中我們會發現一個問題,創建IHostBuilder對象時需要指明類似Startup的配置項,因為這裡是測試環境,所以實際上會與BlogSystem.Core中的配置類StartUp存在一定的差異,因而這裏我們需要為測試新建立一個Startup配置類。

    4.4、方法實現

    1、我們在測試項目中添加名為TestServerFixture 的類和名為TestStartup的類,TestServerFixture 用來創建HttpClient對象並做一些準備工作,TestStartup類為配置類。然後使用Nuget安裝Microsoft.AspNetCore.TestHost;TestServerFixture 和TestStartup實現如下:

    using Autofac.Extensions.DependencyInjection;
    using BlogSystem.Core.Helpers;
    using BlogSystem.Model;
    using Microsoft.AspNetCore.Hosting;
    using Microsoft.AspNetCore.TestHost;
    using Microsoft.Extensions.Hosting;
    using System;
    using System.Net.Http;
    
    namespace BlogSystem.Core.Test
    {
        public static class TestServerFixture
        {
            public static IHostBuilder GetTestHost()
            {
                return Host.CreateDefaultBuilder()
               .UseServiceProviderFactory(new AutofacServiceProviderFactory())//使用autofac作為DI容器
               .ConfigureWebHostDefaults(webBuilder =>
               {
                   webBuilder.UseTestServer()//建立TestServer——測試的關鍵
                   .UseEnvironment("Development")
                   .UseStartup<TestStartup>();
               });
            }
    
            //生成帶token的httpclient
            public static HttpClient GetTestClientWithToken(this IHost host)
            {
                var client = host.GetTestClient();
                client.DefaultRequestHeaders.Add("Authorization", $"Bearer {GenerateJwtToken()}");//把token加到Header中
                return client;
            }
    
            //生成JwtToken
            public static string GenerateJwtToken()
            {
                TokenModelJwt tokenModel = new TokenModelJwt { UserId = userData.Id, Level = userData.Level.ToString() };
                var token = JwtHelper.JwtEncrypt(tokenModel);
                return token;
            }
    
            //測試用戶的數據
            private static readonly User userData = new User
            {
                Account = "jordan",
                Id = new Guid("9CF2DAB5-B9DC-4910-98D8-CBB9D54E3D7B"),
                Level = Level.普通用戶
            };
    
        }
    }
    
    using Autofac;
    using Autofac.Extras.DynamicProxy;
    using BlogSystem.Common.Helpers;
    using BlogSystem.Common.Helpers.SortHelper;
    using BlogSystem.Core.AOP;
    using BlogSystem.Core.Filters;
    using BlogSystem.Core.Helpers;
    using Microsoft.AspNetCore.Authentication.JwtBearer;
    using Microsoft.AspNetCore.Builder;
    using Microsoft.AspNetCore.Hosting;
    using Microsoft.AspNetCore.Http;
    using Microsoft.AspNetCore.Mvc.Formatters;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Hosting;
    using Microsoft.IdentityModel.Tokens;
    using System;
    using System.IO;
    using System.Linq;
    using System.Reflection;
    using System.Text;
    
    namespace BlogSystem.Core.Test
    {
        public class TestStartup
        {
            private readonly IConfiguration _configuration;
    
            public TestStartup(IConfiguration configuration)
            {
                _configuration = GetConfig(null);
                //傳遞Configuration對象
                JwtHelper.GetConfiguration(_configuration);
            }
    
            public void ConfigureServices(IServiceCollection services)
            {
                //控制器服務註冊
                services.AddControllers(setup =>
                {
                    setup.ReturnHttpNotAcceptable = true;//開啟不存在請求格式則返回406狀態碼的選項
                    var jsonOutputFormatter = setup.OutputFormatters.OfType<SystemTextJsonOutputFormatter>()?.FirstOrDefault();//不為空則繼續執行
                    jsonOutputFormatter?.SupportedMediaTypes.Add("application/vnd.company.hateoas+json");
                    setup.Filters.Add(typeof(ExceptionsFilter));//添加異常過濾器
                }).AddXmlDataContractSerializerFormatters()//開啟輸出輸入支持XML格式
    
                //jwt授權服務註冊
                services.AddAuthentication(options =>
                {
                    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
                    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
                }).AddJwtBearer(x =>
                {
                    x.TokenValidationParameters = new TokenValidationParameters
                    {
                        ValidateIssuerSigningKey = true, //驗證密鑰
                        IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_configuration["JwtTokenManagement:secret"])),
    
                        ValidateIssuer = true, //驗證發行人
                        ValidIssuer = _configuration["JwtTokenManagement:issuer"],
    
                        ValidateAudience = true, //驗證訂閱人
                        ValidAudience = _configuration["JwtTokenManagement:audience"],
    
                        RequireExpirationTime = true, //驗證過期時間
                        ValidateLifetime = true, //驗證生命周期
                        ClockSkew = TimeSpan.Zero, //緩衝過期時間,即使配置了過期時間,也要考慮過期時間+緩衝時間
                    };
                });
    
                //註冊HttpContext存取器服務
                services.AddHttpContextAccessor();
    
                //自定義判斷屬性隱射關係
                services.AddTransient<IPropertyMappingService, PropertyMappingService>();
    
                services.AddTransient<IPropertyCheckService, PropertyCheckService>();
            }
    
            //configureContainer訪問AutoFac容器生成器
            public void ConfigureContainer(ContainerBuilder builder)
            {
                //獲取程序集並註冊,採用每次請求都創建一個新的對象的模式
                var assemblyBll = Assembly.LoadFrom(Path.Combine(AppContext.BaseDirectory, "BlogSystem.BLL.dll"));
                var assemblyDal = Assembly.LoadFrom(Path.Combine(AppContext.BaseDirectory, "BlogSystem.DAL.dll"));
    
                builder.RegisterAssemblyTypes(assemblyDal).AsImplementedInterfaces().InstancePerDependency();
    
                //註冊攔截器
                builder.RegisterType<LogAop>();
                //對目標類型啟用動態代理,並注入自定義攔截器攔截BLL
                builder.RegisterAssemblyTypes(assemblyBll).AsImplementedInterfaces().InstancePerDependency()
               .EnableInterfaceInterceptors().InterceptedBy(typeof(LogAop));
            }
    
            public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
            {
                if (env.IsDevelopment())
                {
                    app.UseDeveloperExceptionPage();
                }
                else
                {
                    app.UseExceptionHandler(builder =>
                    {
                        builder.Run(async context =>
                        {
                            context.Response.StatusCode = 500;
                            await context.Response.WriteAsync("Unexpected Error!");
                        });
                    });
                }
    
                app.UseRouting();
    
               //添加認證中間件
                app.UseAuthentication();
    
                //添加授權中間件
                app.UseAuthorization();
    
                app.UseEndpoints(endpoints =>
                {
                    endpoints.MapControllers();
                });
            }
    
            private IConfiguration GetConfig(string environmentName)
            {
                var path = Microsoft.DotNet.PlatformAbstractions.ApplicationEnvironment.ApplicationBasePath;
    
                IConfigurationBuilder builder = new ConfigurationBuilder().SetBasePath(path)
                   .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
    
                if (!string.IsNullOrWhiteSpace(environmentName))
                {
                    builder = builder.AddJsonFile($"appsettings.{environmentName}.json", optional: true);
                }
    
                builder = builder.AddEnvironmentVariables();
    
                return builder.Build();
            }
        }
    }
    

    2、這裏對UserController中的註冊、登錄、獲取用戶信息方法進行測試,實際上這裏的斷言並不嚴謹,會產生什麼後果?請繼續往下看

    using BlogSystem.Model.ViewModels;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.AspNetCore.TestHost;
    using Microsoft.Extensions.Hosting;
    using Newtonsoft.Json;
    using System.Net;
    using System.Net.Http;
    using System.Text;
    using System.Threading.Tasks;
    using Xunit;
    
    namespace BlogSystem.Core.Test.Controller_Test
    {
        public class UserController_Should
        {
            const string _mediaType = "application/json";
            readonly Encoding _encoding = Encoding.UTF8;
    
    
            /// <summary>
            /// 用戶註冊
            /// </summary>
            [Fact]
            public async Task Register_Test()
            {
                // 1、Arrange
                var data = new RegisterViewModel { Account = "test", Password = "123456", RequirePassword = "123456" };
    
                StringContent content = new StringContent(JsonConvert.SerializeObject(data), _encoding, _mediaType);
    
                using var host = await TestServerFixture.GetTestHost().StartAsync();//啟動TestServer
    
                // 2、Act
                var response = await host.GetTestClient().PostAsync($"http://localhost:5000/api/user/register", content);
    
                var result = await response.Content.ReadAsStringAsync();
    
                // 3、Assert
                Assert.DoesNotContain("用戶已存在", result);
            }
    
            /// <summary>
            /// 用戶登錄
            /// </summary>
            [Fact]
            public async Task Login_Test()
            {
                var data = new LoginViewModel { Account = "jordan", Password = "123456" };
    
                StringContent content = new StringContent(JsonConvert.SerializeObject(data), _encoding, _mediaType);
    
                var host = await TestServerFixture.GetTestHost().StartAsync();//啟動TestServer
    
                var response = await host.GetTestClientWithToken().PostAsync($"http://localhost:5000/api/user/Login", content);
    
                var result = await response.Content.ReadAsStringAsync();
    
                Assert.DoesNotContain("賬號或密碼錯誤!", result);
            }
    
            /// <summary>
            /// 獲取用戶信息
            /// </summary>
            [Fact]
            public async Task UserInfo_Test()
            {
                string id = "jordan";
    
                using var host = await TestServerFixture.GetTestHost().StartAsync();//啟動TestServer
    
                var client = host.GetTestClient();
    
                var response = await client.GetAsync($"http://localhost:5000/api/user/{id}");
    
                var result = response.StatusCode;
    
                Assert.True(Equals(HttpStatusCode.OK, result)|| Equals(HttpStatusCode.NotFound, result));
            }
        }
    }
    

    4.5、異常及解決

    1、添加完上述的測試方法后,我們使用打開Visual Studio自帶的測試資源管理器,點擊運行所有測試,發現提示錯誤無法加載BLL?在原先的BlogSystem.Core的StartUp類中我們是加載BLL和DAL項目的dll來達到解耦的目的,所以做了一個將dll輸出到Core項目bin文件夾的動作,但是在測試項目的TestStarup類中,我們是無法加載到BLL和DAL的。我嘗試將BLL和DAL同時輸出到兩個路徑下,但未找到對應的方法,所以這裏我採用了最簡單的解決方法,測試項目添加了對DAL和BLL的引用。再次運行,如下圖,似乎成功了??

    2、我們在測試方法內部打上斷點,右擊測試方法,選擇調試測試,結果發現response參數為空,只應Assert不嚴謹導致看上去沒有問題;在各種查找后,我終於找到了解決辦法,在TestStarup類的ConfigureServices方法內部service.AddControllers方法最後加上這麼一句話即可解決 .AddApplicationPart(Assembly.LoadFrom(Path.Combine(AppContext.BaseDirectory, "BlogSystem.Core.dll")))

    3、再次運行測試方法,成功!但是又發現了另外一個問題,這裏我們只是測試,但是數據庫中卻出現了我們測試添加的test賬號,如何解決?我們可以使用Microsoft.EntityFrameworkCore.InMemory庫 ,它支持使用內存數據庫進行測試,這裏暫未添加,有興趣的朋友可以自行研究。

    本章完~

    本人知識點有限,若文中有錯誤的地方請及時指正,方便大家更好的學習和交流。

    本文部分內容參考了網絡上的視頻內容和文章,僅為學習和交流,視頻地址如下:

    老張的哲學,系列教程一目錄:.netcore+vue 前後端分離

    我想吃晚飯,ASP.NET Core搭建多層網站架構【12-xUnit單元測試之集成測試】

    solenovex,使用 xUnit.NET 對 .NET Core 項目進行單元測試

    solenovex,ASP.NET Core Web API 集成測試

    微軟官方文檔,.NET Core 和 .NET Standard 中的單元測試

    Edison Zhou,.NET單元測試的藝術

    聲明

    本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※別再煩惱如何寫文案,掌握八大原則!

    網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

    ※超省錢租車方案

    ※教你寫出一流的銷售文案?

    網頁設計最專業,超強功能平台可客製化

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

  • 前端面試手寫篇

    手寫篇

    1. 手寫 instenceof

    原生的instanceof

    console.log([] instanceof Array) // true
    
    console.log('' instanceof Array) // false
    

    手寫myInstanceof

    function myInstanceof(left,right){
        
        let proto = left.__proto__
        
        let prototype = right.prototype
        
        while(true){
            
            if(proto === null)return false
            
            if(proto === prototype)return true
            
            proto = proto.__proto__
            
        }
    }
    
    console.log(myInstanceof([],Array))// true
    
    console.log(myInstanceof('',Array))// false
    
    

    實現原理:

    通過不斷的沿着原型鏈查找,如果找到頂端了即:proto === null,那麼就說明沒有找到,返回false,說明 left 不是 right 構造函數的實例

    如果找到隱式原型 proto等於構造函數的原型prototype,那麼說明 leftright 構造函數的實例,返回true

    其它情況就是不斷的改變proto,以便可以不斷的往上查找

    2. 手寫 flat

    原生示例:

    const arr1 = [1, 2, [3, 4]];
    arr1.flat(); 
    // [1, 2, 3, 4]
    
    const arr2 = [1, 2, [3, 4, [5, 6]]];
    arr2.flat();
    // [1, 2, 3, 4, [5, 6]]
    
    const arr3 = [1, 2, [3, 4, [5, 6]]];
    arr3.flat(2);
    // [1, 2, 3, 4, 5, 6]
    
    const arr4 = [1, 2, [3, 4, [5, 6, [7, 8, [9, 10]]]]];
    arr4.flat(Infinity);
    // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    
    

    手寫flatDeep:

    function flatDeep( arr, dep=1 ){
        let ret = []
        
        for(let i=0;i<arr.length;i++){
            
            if(Array.isArray(arr[i])){
                
                dep>0 ? (ret = ret.concat(flatter(arr[i],dep-1))):(ret.push(arr[i]))
                
            }else{
                
                ret.push(arr[i]) 
            }
        }
        
        return ret
    }
    

    實現原理:

    第一個參數是數組,第二個是降維層級,

    用for循環遍歷這個數組,檢測每一項

    如果這項是不是數組則直接添加到ret結果數組裡面

    否則根據降維層級判斷,默認是降一維層級,當遞歸降維不滿足ret>0,說明已經達到dep降維層數了,其它情況即ret.push(arr[i])

    3. 手寫 call

    Function.prototype.myCall = function(context){
    
        context =(context === null || context === undefined) ? window : context
        
        context.fn = this// 其實就等價於 obj.fn = function say(){} 當指向 context.fn 時,say裏面的this 指向obj [關鍵]
        //obj 此時變成 var obj = {name:'innerName',fn:function say(){console.log(this.name)}}
    
        let args = [...arguments].slice(1) //截取第二個開始的所有參數
        let result= context.fn(...args)//把執行的結果賦予result變量
    
        delete context.fn //刪除執行上下文上的屬性 (還原)由var obj = {name:'innerName',fn:function say(){console.log(this.name)}}刪除fn
        return result
    }
    var name = 'outerName'
    var obj = {
        name:'innerName'
    }
    function say(){
        console.log(this.name)
    }
    say()//outerName 等價於 window.say this指向window
    say.myCall(obj)//innerName
    

    實現原理:

    函數的原型方法call 第一個參數是傳入的執行上下文,後面傳入的都是參數,以逗號隔開

    當傳入的是null或undefined是執行上下文是指向window,否使為傳入的對象,然後再傳入的對象身上添加fn屬性並把函數實例say函數賦值給fn,此時變成

    var obj = {name:'innerName',fn:function say(){console.log(this.name)}}此時context就是obj對象啦,所有你執行context.fn(...args)

    其實就是obj.fn(...args)fn 其值是 function say(){ console.log(this.name) },所以這個this就變成obj對象了

    然後就是結果賦值,對象還原

    返回結果

    4. 手寫 apply

    Function.prototype.myApply = function(context){
        
        context =(context === null || context === undefined) ? window : context
        
        let result
        
        context.fn = this
        
        result = arguments[1] ? context.fn(...arguments[1]) : context.fn()
        
        delete context.fn
        
        return result
    }
    

    myCall實現原理大致相同,不同的是由於callapply的傳參方式不一樣,

    我們需要額外的對第二個參數做判斷,apply受參形式是數組,且再第二個參數位置,

    一:如果第二個參數存在,執行的時候就把第二個參數(數組形式)用擴展運算符打散後傳入執行

    二:如果第二個參數不存在,執行執行

    其它就於call的實現一樣

    5. 手寫 bind

    Function.prototype.myBind = function(context){
        
        context =(context === null || context === undefined) ? window : context
        
        let o = Object.create(context)
        
        o.fn = this
        
        let args = [...arguments].slice(1)
        
        let fn= function(){
            
            o.fn(...args)
        }
        
        return fn
    }
    

    bind 的手寫實現,與其它兩個區別是返回一個函數,並沒返回函數執行的結果,並且受參形式不受限制

    實現原理:

    通過 Object.create方法創建一個新對象,使用現有的對象來提供新創建的對象的__proto__,通過 中介對象o來實現,來達到不影響傳入的對象

    6. 手寫 new

    new 一個函數的時候,會生成一個實例,該實例的隱式原型__proto__ ===該函數的prototype原型對象

    在構造函數中this指向當前實例

    最後再將實例對象返回

    function myNew(func){
        
        //第一步 將函數的 prototype 指向 o 對象的__proto__
        let o = Object.create(func.prototype)
        
        //第二步 通過call改變 this的指向,使之指向 o
        let ret = func.call(o)
        
        //第三步 如果構造函數裏面有返回對象,則返回這個對象,沒有則返回 o 對象
        return typeof ret === 'object' ? ret : o
    
    }
    

    檢測:

    function M(){}
    
    let m = myNew(M); // 等價於 new M 這裏只是模擬
    console.log(m instanceof M); // instanceof 檢測實例
    console.log(m instanceof Object);
    console.log(m.__proto__.constructor === M);
    

    本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※教你寫出一流的銷售文案?

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※回頭車貨運收費標準

    ※別再煩惱如何寫文案,掌握八大原則!

    ※超省錢租車方案

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

  • C# 反射與特性(十):EMIT 構建代碼

    目錄

    • 構建代碼
      • 1,程序集(Assembly)
      • 2,模塊(Module)
      • 3,類型(Type)
      • 4,DynamicMethod 定義方法與添加 IL

    前面,本系列一共寫了 九 篇關於反射和特性相關的文章,講解了如何從程序集中通過反射將信息解析出來,以及實例化類型。

    前面的九篇文章中,重點在於讀數據,使用已經構建好的數據結構(元數據等),接下來,我們將學習 .NET Core 中,關於動態構建代碼的知識。

    其中表達式樹已經在另一個系列寫了,所以本系列主要是講述 反射,Emit ,AOP 等內容。

    如果現在總結一下,反射,與哪些數據結構相關?

    我們可以從 AttributeTargets 枚舉中窺見:

    public enum AttributeTargets
    {
       All=16383,
       Assembly=1,
       Module=2,
       Class=4,
       Struct=8,
       Enum=16,
       Constructor=32,
       Method=64,
       Property=128,
       Field=256,
       Event=512,
       Interface=1024,
       Parameter=2048,
       Delegate=4096,
       ReturnValue=8192
    }
    

    分別是程序集、模塊、類、結構體、枚舉、構造函數、方法、屬性、字段、事件、接口、參數、委託、返回值。

    以往的文章中,已經對這些進行了很詳細的講解,我們可以中反射中獲得各種各樣的信息。當然,我們也可以通過動態代碼,生成以上數據結構。

    動態代碼的其中一種方式是表達式樹,我們還可以使用 Emit 技術、Roslyn 技術來編寫;相關的框架有 Natasha、CS-Script 等。

    構建代碼

    首先我們引入一個命名空間:

    using System.Reflection.Emit;
    

    Emit 命名空間中裏面有很多用於構建動態代碼的類型,例如 AssemblyBuilder,這個類型用於構建程序集。類推,構建其它數據結構例如方法屬性,則有 MethodBuilderPropertyBuilder

    1,程序集(Assembly)

    AssemblyBuilder 類型定義並表示動態程序集,它是一個密封類,其定義如下:

    public sealed class AssemblyBuilder : Assembly
    

    AssemblyBuilderAccess 定義動態程序集的訪問模式,在 .NET Core 中,只有兩個枚舉:

    枚舉 說明
    Run 1 可以執行但無法保存該動態程序集。
    RunAndCollect 9 當動態程序集不再可供訪問時,將自動卸載該程序集,並回收其內存。

    .NET Framework 中,有 RunAndSave 、Save 等枚舉,可用於保存構建的程序集,但是在 .NET Core 中,是沒有這些枚舉的,也就是說,Emit 構建的程序集只能在內存中,是無法保存成 .dll 文件的。

    另外,程序集的構建方式(API)也做了變更,如果你百度看到文章 AppDomain.CurrentDomain.DefineDynamicAssembly,那麼你可以關閉創建了,說明裡面的很多代碼根本無法在 .NET Core 下跑。

    好了,不再贅述,我們來看看創建一個程序集的代碼:

                AssemblyName assemblyName = new AssemblyName("MyTest");
                AssemblyBuilder assBuilder = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run);
    

    構建程序集,分為兩部分:

    • AssemblyName 完整描述程序集的唯一標識。
    • AssemblyBuilder 構建程序集

    一個完整的程序集,有很多信息的,版本、作者、構建時間、Token 等,這些可以使用

    AssemblyName 來設置。

    一般一個程序集需要包含以下內容:

    • 簡單名稱。
    • 版本號。
    • 加密密鑰對。
    • 支持的區域性。

    你可以參考以下示例:

                AssemblyName assemblyName = new AssemblyName("MyTest");
                assemblyName.Name = "MyTest";   // 構造函數中已經設置,此處可以忽略
    
                // Version 表示程序集、操作系統或公共語言運行時的版本號.
                // 構造函數比較多,可以選用 主版本號、次版本號、內部版本號和修訂號
                // 請參考 https://docs.microsoft.com/zh-cn/dotnet/api/system.version?view=netcore-3.1
                assemblyName.Version = new Version("1.0.0");
                assemblyName.CultureName = CultureInfo.CurrentCulture.Name; // = "zh-CN" 
                assemblyName.SetPublicKeyToken(new Guid().ToByteArray());
    

    最終程序集的 AssemblyName 显示名稱是以下格式的字符串:

    Name <,Culture = CultureInfo> <,Version = Major.Minor.Build.Revision> <, StrongName> <,PublicKeyToken> '\0'
    

    例如:

    ExampleAssembly, Version=1.0.0.0, Culture=en, PublicKeyToken=a5d015c7d5a0b012
    

    另外,創建程序集構建器使用 AssemblyBuilder.DefineDynamicAssembly() 而不是 new AssemblyBuilder()

    2,模塊(Module)

    程序集和模塊之間的區別可以參考

    https://stackoverflow.com/questions/9271805/net-module-vs-assembly

    https://stackoverflow.com/questions/645728/what-is-a-module-in-net

    模塊是程序集內代碼的邏輯集合,每個模塊可以使用不同的語言編寫,大多數情況下,一個程序集包含一個模塊。程序集包括了代碼、版本信息、元數據等。

    MSDN指出:“模塊是沒有 Assembly 清單的 Microsoft 中間語言(MSIL)文件。”。

    這些就不再扯淡了。

    創建完程序集后,我們繼續來創建模塊。

                AssemblyName assemblyName = new AssemblyName("MyTest");
                AssemblyBuilder assBuilder = AssemblyBuilder.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run);
    
                ModuleBuilder moduleBuilder = assBuilder.DefineDynamicModule("MyTest");             // ⬅
    

    3,類型(Type)

    目前步驟:

    Assembly -> Module -> Type 或 Enum
    

    ModuleBuilder 中有個 DefineType 方法用於創建 classstructDefineEnum方法用於創建 enum

    這裏我們分別說明。

    創建類或結構體:

    TypeBuilder typeBuilder = moduleBuilder.DefineType("MyTest.MyClass",TypeAttributes.Public);
    

    定義的時候,注意名稱是完整的路徑名稱,即命名空間+類型名稱。

    我們可以先通過反射,獲取已經構建的代碼信息:

                Console.WriteLine($"程序集信息:{type.Assembly.FullName}");
                Console.WriteLine($"命名空間:{type.Namespace} , 類型:{type.Name}");
    

    結果:

    程序集信息:MyTest, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
    命名空間:MyTest , 類型:MyClass
    

    接下來將創建一個枚舉類型,並且生成枚舉。

    我們要創建一個這樣的枚舉:

    namespace MyTest
    {
        public enum MyEnum
        {
            Top = 1,
            Bottom = 2,
            Left = 4,
            Right = 8,
            All = 16
        }
    }
    

    使用 Emit 的創建過程如下:

    EnumBuilder enumBuilder = moduleBuilder.DefineEnum("MyTest.MyEnum", TypeAttributes.Public, typeof(int));
    

    TypeAttributes 有很多枚舉,這裏只需要知道聲明這個枚舉類型為 公開的(Public);typeof(int) 是設置枚舉數值基礎類型。

    然後 EnumBuilder 使用 DefineLiteral 方法來創建枚舉。

    方法 說明
    DefineLiteral(String, Object) 在枚舉類型中使用指定的常量值定義命名的靜態字段。

    代碼如下:

                enumBuilder.DefineLiteral("Top", 0);
                enumBuilder.DefineLiteral("Bottom", 1);
                enumBuilder.DefineLiteral("Left", 2);
                enumBuilder.DefineLiteral("Right", 4);
                enumBuilder.DefineLiteral("All", 8);
    

    我們可以使用反射將創建的枚舉打印出來:

            public static void WriteEnum(TypeInfo info)
            {
                var myEnum = Activator.CreateInstance(info);
                Console.WriteLine($"{(info.IsPublic ? "public" : "private")} {(info.IsEnum ? "enum" : "class")} {info.Name}");
                Console.WriteLine("{");
                var names = Enum.GetNames(info);
                int[] values = (int[])Enum.GetValues(info);
                int i = 0;
                foreach (var item in names)
                {
                    Console.WriteLine($" {item} = {values[i]}");
                    i++;
                }
                Console.WriteLine("}");
            }
    

    Main 方法中調用:

     WriteEnum(enumBuilder.CreateTypeInfo());
    

    接下來,類型創建成員,就複雜得多了。

    4,DynamicMethod 定義方法與添加 IL

    下面我們來為 類型創建一個方法,並通過 Emit 向程序集中動態添加 IL。這裏並不是使用 MethodBuider,而是使用 DynamicMethod。

    在開始之前,請自行安裝反編譯工具 dnSpy 或者其它工具,因為這裏涉及到 IL 代碼。

    這裏我們先忽略前面編寫的代碼,清空 Main 方法。

    我們創建一個類型:

        public class MyClass{}
    

    這個類型什麼都沒有。

    然後使用 Emit 動態創建一個 方法,並且附加到 MyClass 類型中:

                // 動態創建一個方法並且附加到 MyClass 類型中
                DynamicMethod dyn = new DynamicMethod("Foo",null,null,typeof(MyClass));
                ILGenerator iLGenerator = dyn.GetILGenerator();
    
                iLGenerator.EmitWriteLine("HelloWorld");
                iLGenerator.Emit(OpCodes.Ret);
    
                dyn.Invoke(null,null);
    

    運行後會打印字符串。

    DynamicMethod 類型用於構建方法,定義並表示可以編譯、執行和丟棄的一種動態方法。 丟棄的方法可用於垃圾回收。。

    ILGenerator 是 IL 代碼生成器。

    EmitWriteLine 作用是打印字符串,

    OpCodes.Ret 標記 結束方法的執行,

    Invoke 將方法轉為委託執行。

    上面的示例比較簡單,請認真記一下。

    下面,我們要使用 Emit 生成一個這樣的方法:

            public int Add(int a,int b)
            {
                return a + b;
            }
    

    看起來很簡單的代碼,要用 IL 來寫,就變得複雜了。

    ILGenerator 正是使用 C# 代碼的形式去寫 IL,但是所有過程都必須按照 IL 的步驟去寫。

    其中最重要的,便是 OpCodes 枚舉了,OpCodes 有幾十個枚舉,代表了 IL 的所有操作功能。

    請參考:https://docs.microsoft.com/zh-cn/dotnet/api/system.reflection.emit.opcodes?view=netcore-3.1

    如果你點擊上面的鏈接查看 OpCodes 的枚舉,你可以看到,很多 功能碼,這麼多功能碼是記不住的。我們現在剛開始學習 Emit,這樣就會難上加難。

    所以,我們要先下載能夠查看 IL 代碼的工具,方便我們探索和調整寫法。

    我們看看此方法生成的 IL 代碼:

      .method public hidebysig instance int32
        Add(
          int32 a,
          int32 b
        ) cil managed
      {
        .maxstack 2
        .locals init (
          [0] int32 V_0
        )
    
        // [14 9 - 14 10]
        IL_0000: nop
    
        // [15 13 - 15 26]
        IL_0001: ldarg.1      // a
        IL_0002: ldarg.2      // b
        IL_0003: add
        IL_0004: stloc.0      // V_0
        IL_0005: br.s         IL_0007
    
        // [16 9 - 16 10]
        IL_0007: ldloc.0      // V_0
        IL_0008: ret
    
      } // end of method MyClass::Add
    

    看不懂完全沒關係,因為筆者也看不懂。

    目前我們已經獲得了上面兩大部分的信息,接下來我們使用 DynamicMethod 來動態編寫方法。

    定義 Add 方法並獲取 IL 生成工具:

                DynamicMethod dynamicMethod = new DynamicMethod("Add",typeof(int),new Type[] { typeof(int),typeof(int)});
                ILGenerator ilCode = dynamicMethod.GetILGenerator();
    

    DynamicMethod 用於定義一個方法;ILGenerator是 IL 生成器。當然也可以將此方法附加到一個類型中,完整代碼示例如下:

                // typeof(Program),表示將此動態編寫的方法附加到 MyClass 中
                DynamicMethod dynamicMethod = new DynamicMethod("Add", typeof(int), new Type[] { typeof(int), typeof(int) },typeof(MyClass));
    
    
                ILGenerator ilCode = dynamicMethod.GetILGenerator();
    
                ilCode.Emit(OpCodes.Ldarg_0); // a,將索引為 0 的自變量加載到計算堆棧上。
                ilCode.Emit(OpCodes.Ldarg_1); // b,將索引為 1 的自變量加載到計算堆棧上。
                ilCode.Emit(OpCodes.Add);     // 將兩個值相加並將結果推送到計算堆棧上。
    
                // 下面指令不需要,默認就是彈出計算堆棧的結果
                //ilCode.Emit(OpCodes.Stloc_0); // 將索引 0 處的局部變量加載到計算堆棧上。
                //ilCode.Emit(OpCodes.Br_S);    // 無條件地將控制轉移到目標指令(短格式)。
                //ilCode.Emit(OpCodes.Ldloc_0); // 將索引 0 處的局部變量加載到計算堆棧上。
    
                ilCode.Emit(OpCodes.Ret);     // 即 return,從當前方法返回,並將返回值(如果存在)從被調用方的計算堆棧推送到調用方的計算堆棧上。
    
                // 方法1
                Func<int, int, int> test = (Func<int, int, int>)dynamicMethod.CreateDelegate(typeof(Func<int, int, int>));
                Console.WriteLine(test(1, 2));
    
                // 方法2
                int sum = (int)dynamicMethod.Invoke(null, BindingFlags.Public, null, new object[] { 1, 2 }, CultureInfo.CurrentCulture);
                Console.WriteLine(sum);
    

    實際以上代碼與我們反編譯出來的 IL 編寫有所差異,具體俺也不知道為啥,在群里問了調試了,註釋掉那麼幾行代碼,才通過的。

    本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※超省錢租車方案

    ※別再煩惱如何寫文案,掌握八大原則!

    ※回頭車貨運收費標準

    ※教你寫出一流的銷售文案?

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

  • 「譯」靜態單賦值小冊 – 1. 介紹

    「譯」靜態單賦值小冊 – 1. 介紹

    有一本小冊子Static Single Assignment Book寫的很好,內容又較少,試着翻譯一下,意譯較多(說人話),不是嚴肅的翻譯,感興趣的可以看看。頻率可能是周更。。anyway,stay tuned~

    在日常編程中,名字是一個很有用的東西。這本書想傳遞的關鍵內容是對於每個不同的東西給它一個獨一無二的名字可以消除很多不確定性以及不精確性。

    舉個例子,如果你無意中聽到一段對話中有’Homer’這個詞,沒有上下文的情況下你不知道他說的是Homer Simpson(辛普森)還是古希臘詩人荷馬還是你認識的某個叫Homer的人。但是只要你聽到對話提及Springfield (辛普森一家)而不是Smyrna(希臘詩歌),你就能知道他們說的是辛普森一家這個電視劇。不過話又說回來,如果每個人都有一個獨一無二的名字,那麼就不可能混淆電視劇角色和古希臘文學人物,這個問題都不會成立。

    這本書主要討論靜態單賦值(Static Single Assignment Form,SSA)形式,它是一種變量的命名約定。術語static說明SSA與屬性和代碼分析相關,術語single說明SSA強制變量名具有唯一性。術語assignment表示變量的定義。舉個例子,在下面的代碼中:

    x = y + 1;
    

    變量x被賦予表達式(y+1)的值。這是一個定義,或者對於x來說是賦值語句。編譯器工程師會說上面的賦值語句將值(y+1)儲存到左值x中。

    1.1 SSA定義

    關於SSA最簡單,限制最少的定義如下:

    “如果每個變量在程序中有且只有一個賦值語句,那麼該程序是SSA形式”

    但是實際上SSA還有很多變體,有更多的限制。這些變體可能使變量定義和使用與圖論的一些特性有關,或者封裝一些特定的控制流/數據流信息。每個SSA變體都有特設的性質。基本的SSA變體將會在第二章討論,本書的第三部分還會討論更多這部分的內容。

    所有SSA變體,包括上面最簡單的定義都有一個最基本屬性就是引用透明性(referential transparency),所謂引用透明性是指程序中的每個變量只有一個定義,變量的值和它所在程序的位置無關。我們可能根據分支的條件完善對於某個變量的認識。舉個例子,不用看代碼我們就知道下面if語句后緊跟着的then/else條件塊中x的值

    if (x == 0)
    

    因為x的值在這個if語句中是沒有改變的。函數式編程語言寫的程序是引用透明的,引用透明性對於形式化方法和數學推理很有用,因為表達式的值只依賴它的子表達式而不依賴求值的順序或者表達式的副作用,或者其它表達式。對於一個引用透明的程序,考慮下面的代碼片段:

    x = 1;
    y = x + 1;
    x = 2;
    z = x + 1;
    

    一個naive(而且不正確)的分析器可能認為y和z的值相等,因為他們的定義是一樣的,都是(x+1),然而x的值取決於當前代碼位置是在第二個賦值的前面還是後面,即變量的值取決於上下文。當編譯器將這個代碼段轉換為SSA形式時,它會具有引用透明性。轉換的過程會為一個變量的多次定義使用不同的名字(譯註:x1和x2)。使用SSA形式后,只有當x1和x2相等時y和z才相等。

    x1 = 1;
    y = x1 + 1;
    x2 = 2;
    z = x2 + 1;
    

    1.2 SSA的非形式化語義

    在前一節中,我們看到了如何通過簡單的重命名將代碼轉換為SSA形式。賦值語句左邊被定義的變量叫做target,在SSA中,每個target都有唯一的名字。反過來,賦值語句右邊可以多次使用target,在這裏它們叫做source。貫穿本書,SSA的target名字定義都是變量名再加一個下標這種形式。一般來說這是不重要的實現細節,雖然它對於編譯器debug來說很有用。

    φ函數是SSA最重要的一個概念,它很特別,又叫做偽賦值函數(pseudo-assignment function)。有些人也叫它notational fiction。ɸ函數的用途是合併來自不同路徑的值,一般出現在控制流的合併點。

    考慮下面的代碼示例和它對應的控制流圖(Control Flow Graph,CFG)表示:

    在if不同分支中,y有不同的定義。y的不同定義最終在print那個地方交匯。當編譯器將該代碼轉換為SSA形式時,y的不同定義被命名為y1和y2。print既可以使用y1也可以使用y2,這取決於if的條件。在這種情況下,需要用φ函數引入新的變量y3,它的參數是y1和y2。因此SSA版本的上述程序如下:

    就放置位置來說,φ函數一般是放到控制流交匯點,即CFG中有多個前驅基本塊的那個基本塊頭部。如果有n條路徑可以進入基本塊b,那麼在基本塊b頭部的φ函數有n個參數。φ函數會動態的選擇正確的參數。φ函數根據n個參數,創建新的變量名,這個名字是唯一的,因為它要保證SSA的基本性質。因此,在上面的例子中,如果控制流從基本塊A流向下面的基本塊,那麼y3使用φ函數選擇y1作為它的值,反之φ函數使用y2作為它的值。注意CFG圖φ函數的參數y1和y2前面還加了基本塊的標籤,這種形式是比較多餘的,在本書的後面部分,這個基本塊標籤能不加就不加,除非沒了它會引起歧義。

    這裏還要強調一下,如果基本塊頭部有多個φ函數,這些φ函數是并行的,即,它們是同時執行,不需要順序執行。這一點是很重要的,因為在經過一些優化,比如複寫傳播(copy propagation)后φ函數的target可能是其它φ函數的source。在SSA解構階段,φ函數會被消除(譯註:就是編譯器不需要SSA形式,想將它轉換為其它IR,這就叫SSA解構),在解構階段使用常規的複製操作序列化,這點會在17.6小結描述。這個小細節對於寄存器分配后的代碼來說是相當重要的。

    嚴格來說,φ函數不能被軟件直接執行,因為進入φ函數的控制流沒有被顯式的編碼進φ函數的參數。這是可以接受的,因為φ函數通常只用於程序的靜態分析。然而,有很多擴展使得φ函數可以執行,如 φif 或者γ函數(參見第12章),它有一個額外的參數,告訴φ函數選擇那個值。關於這個會在第12章,第16章和第18章討論。

    接下來我們再展示一個例子,它說明了一個循環控制流解構的SSA形式。下面是非SSA形式的程序和SSA形式的控制流圖:

    SSA代碼在循環頭部新增了兩個φ函數。它們合併循環前的值定義和循環中的值的定義。

    要注意不要混淆SSA和自動并行化優化中的(動態)靜態賦值這兩個概念。SSA不會阻止在程序執行的時候對一個變量的多次定義,比如,上面的SSA代碼中,變量y3和x3在循環體內,每次循環都會重定義它們。

    SSA構造的詳細描述會在第3章給出,現在只需要明白下面的內容:

    1. 如果程序的交匯點的某個變量有多個定義,那麼會在交匯點插入φ函數
    2. 整數下標用於重命名原來程序中的變量x和y

    1.3 與傳統數據流分析的比較

    在未來的第11章我們會提到,SSA主要的一個優點是它對數據流分析(data-flow analysis)很友好。數據流分析在程序編譯的時候收集信息,為未來的代碼優化做準備。在程序運行時,這些信息會在變量間流動。靜態分析通過在控制流圖中傳播這些信息,得以捕獲關於數據流的一些事實(fact)。這種方式在傳統的數據流分析中很常見。

    通常,如果程序是一種功能性的(functional)或者稀疏(sparse)的表示,如SSA形式,那麼數據流信息能程序中更高效的傳播。當程序被轉換為SSA形式時,變量在定義點被重命名。對於一個確鑿的數據流問題,比如常量傳播,它表現為一個程序點的集合,在這些程序點數據流事實可能改變。因此可以直接關聯數據流事實和變量名字,而不是在每個程序點為所有變量維護各自的數據流事實的集合,下圖展示了一個非零值分析(non-zero value analysis)

    對於程序中的每個變量,分析的目標是靜態確定哪些變量在運行時包含0值(即null)。在這裏0就表示變量為null,0打一把叉表示不為null,T表示可能為null。上圖(a)表示傳統的數據流分析,我們會在六個基本塊的入口點和出處都計算一次變量x和y信息。而在上圖(b)的基於SSA的數據流分析中,我們只需要在變量定義處計算一下,然後就能獲得六個數據流事實。

    對於其它的數據流問題,屬性也可能在變量定義之外發生改變,這些問題只要插入一些φ函數就能放入稀疏數據流分析的框架中,第11章會有一個例子討論這個。總的來說,目前這個例子說明了SSA能給分析算法帶來的關鍵好處是:

    1. 數據流信息直接從定義語句處傳播到使用它的地方,即通過def-use鏈,這個鏈條由SSA命名方式隱示給出。相反,傳統的數據流分析需要將信息傳遍整個程序,即便在很多地方這些信息都沒改變,或者不相關。
    2. 基於SSA的數據流分析更簡潔。在示例中,比起傳統方式,基於SSA的分析只有很少的數據流事實。

    這本書的第二部分給出了一個完整的基於SSA數據流分析的描述。

    1.4 此情此景此SSA

    歷史背景。在整個20世紀80年代,優化編譯器技術越來越成熟,各種中間表示被提出,它們包含了數據依賴,使得數據流分析在這些中間表示上很容易進行。在這些中間表示背後的設計理念是顯式/隱式包含變量定義和使用的關係,即def-use鏈條,使得數據流信息能有效的傳播。程序依賴圖(program dependence graph)和程序依賴網(program dependence web)均屬此類IR。第12章還會討論這些風格的IR的更多細節。

    靜態單賦值是由IBM Research開發的一種IR,並在20世紀80年代末的幾篇研究論文中公開發表。SSA由於其符合直覺的性質和直觀的構造算法得到了廣泛的應用。SSA給出了一個標準化的變量def-use鏈,簡化了很多數據流分析技術。

    當前狀況。當前主流的商業編譯器和開源編譯器,包括GCC,LLVM,HotSpot Java虛擬機,V8 JavaScript引擎都將SSA作為程序分析中的關鍵表示。由於在SSA執行優化速度快而且高效,那些即時編譯器(JIT)會在一些高級地、與平台無關的表示(如Java字節碼,CLI字節碼,LLVM bitcode)上廣泛使用SSA。
    SSA最初是為了簡化高級程序表示的變形而開發而創建的,因為其良好的特性,能夠簡化算法和減少計算複雜性。今天,SSA形式甚至被用於最後的代碼生成階段(見第四部分),即後端。好幾個工業編譯器和學術編譯器,既有靜態,也有just-in-time,都在它們的後端使用SSA,如LLVM,HotSpot,LAO,libFirm,Mono。很多使用SSA的編譯器在編譯快要結束時,即寄存器分配前才解構SSA。最近的研究甚至能在寄存器分配期間也使用SSA,SSA形式會保持到非常非常後面的機器代碼生成過程才會被解構。

    SSA與高級語言。到目前為止,我們展示了在低級代碼上使用SSA形式做分析的優勢。有趣的是,在高級代碼上如果強制遵循某些準則也可能具有SSA的性質。根據SISAL語言的定義,程序自動具備引用透明性,因為變量不允許多次賦值。其它語言也能有SSA的性質,比如Java的變量加個final或者C#的變量加個const/readonly。

    強制寫出具有SSA性質的高級語言程序主要好處是這些程序能具備不變形,這簡化了併發編程。豬肚的數據能在多個線程中自由的共享,沒有任何數據依賴問題。數據依賴對於多核處理器來說是一個大問題。

    在函數式編程語言中,引用透明是語言的基本特性。因此函數式編程隱式具有SSA性質。第6章會介紹SSA和函數式編程。

    1.5 餘下本章

    本章引入了SSA的符號表示,本書的剩下部分就SSA的各個方面詳細討論。本書的終極目標是:

    1. 清晰的描述SSA能為程序分析帶來哪些好處
    2. 消除那些阻止人們使用SSA的謬誤

    本節還剩下一些內容,它們與下一章的一些主題相關。

    1.5.1 SSA的好處

    SSA對於變量命名有嚴格要求,每個變量的名字都是獨一無二的。賦值語言和控制流交匯點會引入新的變量名。這些簡化了表達變量def-use關係的數據結構實現和變量存活範圍。本書第二部分關注基於SSA的數據流恩熙,使用SSA主要有三個好處:

    編譯時受益。如果程序是SSA形式,很多編譯器優化可以高效的進行,因為引用透明性意味着數據流信息直接與變量關聯,而不是每個程序點的變量。關於這一點我們已經在1.3的非零值分析中演示過了。

    編譯器開發受益。SSA使得程序分析和轉換能更容易表達。這意味着編譯器工程師能更高產,可以寫更多的pass,並且能debug更多的pass(譯註:smile)。舉個例子,基於SSA的GCC4.x的死代碼優化比非GCC3.x的非SSA死代碼優化實現總代碼少了40%。

    程序運行時受益。理論上,能基於SSA實現的分析和優化也能基於其它非SSA形式。前一點提到過,基於SSA的實現代碼更少,因此很多基於SSA的編譯器優化也能更高效進行,關於這一點的示例是一類控制流不敏感分析(control-flow insensitive analysis),具體參見論文Using static single assignment form to improve flowinsensitive pointer analysis

    1.5.2 SSA謬論

    一些人認為SSA很複雜很繁瑣,不能高效表達程序。這本書的目的就是讓讀者免去這些擔憂。下面的表單展示了關於SSA常見的謬論,以及破除謬論的章節。

    謬論 破除謬論
    SSA讓變量數爆炸 第二章會回顧SSA的主要變體,一些變體引入的變量數比原始SSA形式少很多
    SSA的性質難以維持 第三章和第五章討論了一些修復SSA性質的簡單技術(因為一些優化可能重寫中間表示,導致SSA性質被破壞)
    SSA的性質難以維持 第三章和第十七章展示了高效且效果顯著的SSA解構算法的複製操作

    本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※自行創業缺乏曝光? 網頁設計幫您第一時間規劃公司的形象門面

    網頁設計一頭霧水該從何著手呢? 台北網頁設計公司幫您輕鬆架站!

    ※想知道最厲害的網頁設計公司"嚨底家"!

    ※別再煩惱如何寫文案,掌握八大原則!

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!

  • Java 中隊列同步器 AQS(AbstractQueuedSynchronizer)實現原理

    Java 中隊列同步器 AQS(AbstractQueuedSynchronizer)實現原理

    前言

    在 Java 中通過鎖來控制多個線程對共享資源的訪問,使用 Java 編程語言開發的朋友都知道,可以通過 synchronized 關鍵字來實現鎖的功能,它可以隱式的獲取鎖,也就是說我們使用該關鍵字並不需要去關心鎖的獲取和釋放過程,但是在提供方便的同時也意味着其靈活性的下降。例如,有這樣的一個場景,先獲取鎖 A,然後再獲取鎖 B,當鎖 B 獲取到之後,釋放鎖 A 同時獲取鎖 C,當獲取鎖 C 后,再釋放鎖 B 同時獲取鎖 D,依次類推,像這種比較複雜的場景,使用 synchronized 關鍵字就比較難實現了。
    在 Java SE 5 之後,新增加了 Lock 接口和一系列的實現類來提供和 synchronized 關鍵字一樣的功能,它需要我們显示的進行鎖的獲取和釋放,除此之外還提供了可響應中斷的鎖獲取操作以及超時獲取鎖等同步特性。JDK 中提供的 Lock 接口實現類大部分都是聚合一個同步器 AQS 的子類來實現多線程的訪問控制的,下面我們看看這個構建鎖和其它同步組件的基礎框架——隊列同步器 AQS(AbstractQueuedSynchronizer)。

    AQS 基礎數據結構

    同步隊列

    隊列同步器 AQS(下文簡稱為同步器)主要是依賴於內部的一個 FIFO(first-in-first-out)雙向隊列來對同步狀態進行管理的,當線程獲取同步狀態失敗時,同步器會將當前線程和當前等待狀態等信息封裝成一個內部定義的節點 Node,然後將其加入隊列,同時阻塞當前線程;當同步狀態釋放時,會將同步隊列中首節點喚醒,讓其再次嘗試去獲取同步狀態。同步隊列的基本結構如下:

    隊列節點 Node

    同步隊列使用同步器中的靜態內部類 Node 用來保存獲取同步狀態的線程的引用、線程的等待狀態、前驅節點和後繼節點。

    同步隊列中 Node 節點的屬性名稱和具體含義如下錶所示:

    屬性類型和名稱 描述
    volatile int waitStatus 當前節點在隊列中的等待狀態
    volatile Node prev 前驅節點,當節點加入同步隊列時被賦值(使用尾部添加方式)
    volatile Node next 後繼節點
    volatile Thread thread 獲取同步狀態的線程
    Node nextWaiter 等待隊列中的後繼節點,如果當前節點是共享的,則該字段是一個 SHARED 常量

    每個節點線程都有兩種鎖模式,分別為 SHARED 表示線程以共享的模式等待鎖,EXCLUSIVE 表示線程以獨佔的方式等待鎖。同時每個節點的等待狀態 waitStatus 只能取以下錶中的枚舉值:

    枚舉值 描述
    SIGNAL 值為 -1,表示該節點的線程已經準備完畢,等待資源釋放
    CANCELLED 值為 1,表示該節點線程獲取鎖的請求已經取消了
    CONDITION 值為 -2,表示該節點線程等待在 Condition 上,等待被其它線程喚醒
    PROPAGATE 值為 -3,表示下一次共享同步狀態獲取會無限進行下去,只在 SHARED 情況下使用
    0 值為 0,初始狀態,初始化的默認值
    同步狀態 state

    同步器內部使用了一個名為 state 的 int 類型的變量表示同步狀態,同步器的主要使用方式是通過繼承,子類通過繼承並實現它的抽象方法來管理同步狀態,同步器給我們提供了如下三個方法來對同步狀態進行更改。

    方法簽名 描述
    protected final int getState() 獲取當前同步狀態
    protected final void setState(int newState) 設置當前同步狀態
    protected final boolean compareAndSetState(int expect, int update) 使用 CAS 設置當前狀態,該方法能夠保證狀態設置的原子性

    在獨享鎖中同步狀態 state 這個值通常是 0 或者 1(如果是重入鎖的話 state 值就是重入的次數),在共享鎖中 state 就是持有鎖的數量。

    獨佔式同步狀態獲取與釋放

    同步器中提供了 acquire(int arg) 方法來進行獨佔式同步狀態的獲取,獲取到了同步狀態也就是獲取到了鎖,該方法源碼如下所示:

    public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
    

    方法首先會調用 tryAcquire 方法嘗試去獲取鎖,查看方法的源碼可以發現,同步器並未對該方法進行實現(只是拋出一個不支持操作異常 UnsupportedOperationException),這個方法是需要後續同步組件的開發人員自己去實現的,如果方法返回 true 則表示當前線程成功獲取到鎖,調用 selfInterrupt() 中斷當前線程(PS:這裏留給大家一個問題:為什麼獲取了鎖以後還要中斷線程呢?),方法結束返回,如果方法返回 false 則表示當前線程獲取鎖失敗,也就是說有其它線程先前已經獲取到了鎖,此時就需要把當前線程以及等待狀態等信息添加到同步隊列中,下面來看看同步器在線程未獲取到鎖時具體是如何實現。
    通過源碼發現,當獲取鎖失敗時,會執行判斷條件與操作的後半部分 acquireQueued(addWaiter(Node.EXCLUSIVE), arg),首先指定鎖模式為 Node.EXCLUSIVE 調用 addWaiter 方法,該方法源碼如下:

    private Node addWaiter(Node mode) {
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        Node pred = tail;
        if (pred != null) {
            node.prev = pred;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }
    

    通過方法參數指定的鎖模式(共享鎖 or 獨佔鎖)和當前線程構造出一個 Node 節點,如果同步隊列已經初始化,那麼首先會進行一次從尾部加入隊列的嘗試,使用 compareAndSetTail 方法保證原子性,進入該方法源碼可以發現是基於 sun.misc 包下提供的 Unsafe 類來實現的。如果首次嘗試加入同步隊列失敗,會再次調用 enq 方法進行入隊操作,繼續跟進 enq 方法源碼如下:

    private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }
    

    通過其源碼可以發現和第一次嘗試加入隊列的代碼類似,只是該方法裏面加了同步隊列初始化判斷,使用 compareAndSetHead 方法保證設置頭節點的原子性,同樣它底層也是基於 Unsafe 類,然後外層套了一個 for (; 死循環,循環唯一的退出條件是從隊尾入隊成功,也就是說如果從該方法成功返回了就表示已經入隊成功了,至此,addWaiter 執行完畢返回當前 Node 節點。然後以該節點作為 acquireQueued 方法的入參繼續進行其它步驟,該方法如下所示:

    final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }
    

    可以看到,該方法本質上也是通過一個死循環(自旋)去獲取鎖並且支持中斷,在循環體外面定義兩個標記變量,failed 標記是否成功獲取到鎖,interrupted 標記在等待的過程中是否被中斷過。方法首先通過 predecessor 獲取當前節點的前驅節點,噹噹前節點的前驅節點是 head 頭節點時就調用 tryAcquire 嘗試獲取鎖,也就是第二個節點則嘗試獲取鎖,這裏為什麼要從第二個節點才嘗試獲取鎖呢?是因為同步隊列本質上是一個雙向鏈表,在雙向鏈表中,第一個節點並不存儲任何數據是虛節點,只是起到一個佔位的作用,真正存儲數據的節點是從第二個節點開始的。如果成功獲取鎖,也就是 tryAcquire 方法返回 true 后,將 head 指向當前節點並把之前找到的頭節點 p 從隊列中移除,修改是否成功獲取到鎖標記,結束方法返回中斷標記。
    如果當前節點的前驅節點 p 不是頭節點或者前驅節點 p 是頭節點但是獲取鎖操作失敗,那麼會調用 shouldParkAfterFailedAcquire 方法判斷當前 node 節點是否需要被阻塞,這裏的阻塞判斷主要是為了防止長時間自旋給 CPU 帶來非常大的執行開銷,浪費資源。該方法源碼如下:

    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            /*
              * This node has already set status asking a release
              * to signal it, so it can safely park.
              */
            return true;
        if (ws > 0) {
            /*
              * Predecessor was cancelled. Skip over predecessors and
              * indicate retry.
              */
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            /*
              * waitStatus must be 0 or PROPAGATE.  Indicate that we
              * need a signal, but don't park yet.  Caller will need to
              * retry to make sure it cannot acquire before parking.
              */
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }
    

    方法參數為當前節點的前驅節點以及當前節點,主要是靠前驅節點來判斷是否需要進行阻塞,首先獲取到前驅節點的等待狀態 ws,如果節點狀態 ws 為 SIGNAL,表示前驅節點的線程已經準備完畢,等待資源釋放,方法返回 true 表示可以阻塞,如果 ws > 0,通過上文可以知道節點只有一個狀態 CANCELLED(值為 1) 滿足該條件,表示該節點線程獲取鎖的請求已經取消了,會通過一個 do-while 循環向前查找 CANCELLED 狀態的節點並將其從同步隊列中移除,否則進入 else 分支,使用 compareAndSetWaitStatus 原子操作將前驅節點的等待狀態修改為 SIGNAL,以上這兩種情況都不需要進行阻塞方法返回 false。
    當經過判斷後需要阻塞的話,也就是 compareAndSetWaitStatus 方法返回 true 時,會通過 parkAndCheckInterrupt 方法阻塞掛起當前線程,並返回當前線程的中斷標識。方法如下:

    private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }
    

    線程阻塞是通過 LockSupport 這個工具類實現的,深入其源碼可以發現它底層也是基於 Unsafe 類實現的。如果以上兩個方法都返回 true 的話就更新中斷標記。這裏還有一個問題就是什麼時候會將一個節點的等待狀態 waitStatus 修改為 CANCELLED 節點線程獲取鎖的請求取消狀態呢?細心的朋友可能已經發現了,在上文貼出的 acquireQueued 方法源碼中的 finally 塊中會根據 failed 標記來決定是否調用 cancelAcquire 方法,這個方法就是用來將節點狀態修改為 CANCELLED 的,方法的具體實現留給大家去探索。至此 AQS 獨佔式同步狀態獲取鎖的流程就完成了,下面通過一個流程圖來看看整體流程:

    下面再看看獨佔式鎖釋放的過程,同步器使用 release 方法來讓我們進行獨佔式鎖的釋放,其方法源碼如下:

    public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }
    

    首先調用 tryRelease 方法嘗試進行鎖釋放操作,繼續跟進該方法發現同步器只是拋出了一個不支持操作異常 UnsupportedOperationException,這裏和上文獨佔鎖獲取中 tryAcquire 方法是一樣的套路,需要開發者自己定義鎖釋放操作。

    通過其 JavaDoc 可以得知,如果返回 false,則表示釋放鎖失敗,方法結束。該方法如果返回 true,則表示當前線程釋放鎖成功,需要通知隊列中等待獲取鎖的線程進行鎖獲取操作。首先獲取頭節點 head,如果當前頭節點不為 null,並且其等待狀態不是初始狀態(0),則解除線程阻塞掛起狀態,通過 unparkSuccessor 方法實現,該方法源碼如下:

    private void unparkSuccessor(Node node) {
        /*
          * If status is negative (i.e., possibly needing signal) try
          * to clear in anticipation of signalling.  It is OK if this
          * fails or if status is changed by waiting thread.
          */
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);
    
        /*
          * Thread to unpark is held in successor, which is normally
          * just the next node.  But if cancelled or apparently null,
          * traverse backwards from tail to find the actual
          * non-cancelled successor.
          */
        Node s = node.next;
        if (s == null || s.waitStatus > 0) {
            s = null;
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        if (s != null)
            LockSupport.unpark(s.thread);
    }
    

    首先獲取頭節點的等待狀態 ws,如果狀態值為負數(Node.SIGNAL or Node.PROPAGATE),則通過 CAS 操作將其改為初始狀態(0),然後獲取頭節點的後繼節點,如果後繼節點為 null 或者後繼節點狀態為 CANCELLED(獲取鎖請求已取消),就從隊列尾部開始尋找第一個狀態為非 CANCELLED 的節點,如果該節點不為空則使用 LockSupport 的 unpark 方法將其喚醒,該方法底層是通過 Unsafe 類的 unpark 實現的。這裏需要從隊尾查找非 CANCELLED 狀態的節點的原因是,在之前的獲取獨佔鎖失敗時的入隊 addWaiter 方法實現中,該方法如下:

    假設一個線程執行到了上圖中的 ① 處,② 處還沒有執行,此時另一個線程恰好執行了 unparkSuccessor 方法,那麼就無法通過從前向後查找了,因為節點的後繼指針 next 還沒賦值呢,所以需要從后往前進行查找。至此,獨佔式鎖釋放操作就結束了,同樣的,最後我們也通過一個流程圖來看看整個鎖釋放的過程:

    獨佔式可中斷同步狀態獲取

    同步器提供了 acquireInterruptibly 方法來進行可響應中斷的獲取鎖操作,方法實現源碼如下:

    public final void acquireInterruptibly(int arg)
            throws InterruptedException {
        if (Thread.interrupted())
            throw new InterruptedException();
        if (!tryAcquire(arg))
            doAcquireInterruptibly(arg);
    }
    

    方法首先檢查當前線程的中斷狀態,如果已中斷,則直接拋出中斷異常 InterruptedException 即響應中斷,否則調用 tryAcquire 方法嘗試獲取鎖,如果獲取成功則方法結束返回,獲取失敗調用 doAcquireInterruptibly 方法,跟進該方法如下:

    private void doAcquireInterruptibly(int arg)
        throws InterruptedException {
        final Node node = addWaiter(Node.EXCLUSIVE);
        boolean failed = true;
        try {
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }
    

    仔細觀察可以發現該方法實現源碼和上文中 acquireQueued 方法的實現基本上類似,只是這裏把入隊操作 addWaiter 放到了方法裏面了,還有一個區別就是當在循環體內判斷需要進行中斷時會直接拋出異常來響應中斷,兩個方法的對比如下:

    其它步驟和獨佔式鎖獲取一致,流程圖大體上和不響應中斷的鎖獲取差不多,只是在最開始多了一步線程中斷狀態檢查和循環是會拋出中斷異常而已。

    獨佔式超時獲取同步狀態

    同步器提供了 tryAcquireNanos 方法可以超時獲取同步狀態(也就是鎖),該方法提供了之前 synchronized 關鍵字不支持的超時獲取的特性,通過該方法我們可以在指定時間段 nanosTimeout 內獲取鎖,如果獲取到鎖則返回 true,否則,返回 false。方法源碼如下:

    public final boolean tryAcquireNanos(int arg, long nanosTimeout)
            throws InterruptedException {
        if (Thread.interrupted())
            throw new InterruptedException();
        return tryAcquire(arg) ||
            doAcquireNanos(arg, nanosTimeout);
    }
    

    首先會調用 tryAcquire 方法嘗試獲取一次鎖,如果獲取鎖成功則立即返回,否則調用 doAcquireNanos 方法進入超時獲取鎖流程。通過上文可以得知,同步器的 acquireInterruptibly 方法在等待獲取同步狀態時,如果當前線程被中斷了,會拋出中斷異常 InterruptedException 並立刻返回。超時獲取鎖的流程其實是在響應中斷的基礎上增加了超時獲取的特性,doAcquireNanos 方法的源碼如下:

    private boolean doAcquireNanos(int arg, long nanosTimeout)
            throws InterruptedException {
        if (nanosTimeout <= 0L)
            return false;
        final long deadline = System.nanoTime() + nanosTimeout;
        final Node node = addWaiter(Node.EXCLUSIVE);
        boolean failed = true;
        try {
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return true;
                }
                nanosTimeout = deadline - System.nanoTime();
                if (nanosTimeout <= 0L)
                    return false;
                if (shouldParkAfterFailedAcquire(p, node) &&
                    nanosTimeout > spinForTimeoutThreshold)
                    LockSupport.parkNanos(this, nanosTimeout);
                if (Thread.interrupted())
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }
    

    由以上方法實現源碼可以看出,針對超時獲取這裏主要實現思路是:先使用當前時間加上參數傳入的超時時間間隔 deadline 計算出超時的時間點,然後每次進行循環的時候使用超時時間點 deadline 減去當前時間得到剩餘的時間 nanosTimeout,如果剩餘時間小於 0 則證明當前獲取鎖操作已經超時,方法結束返回 false,反如果剩餘時間大於 0。
    可以看到在裏面執行自旋的時候和上面獨佔式同步獲取鎖狀態 acquireQueued 方法那裡是一樣的套路,即噹噹前節點的前驅節點為頭節點時調用 tryAcquire 嘗試獲取鎖,如果獲取成功則返回。

    除了超時時間計算那裡不同外,還有個不同的地方就是在超時獲取鎖失敗之後的操作,如果當前線程獲取鎖失敗,則判斷剩餘超時時間 nanosTimeout 是否小於 0,如果小於 0 則表示已經超時方法立即返回,反之則會判斷是否需要進行阻塞掛起當前線程,如果通過 shouldParkAfterFailedAcquire 方法判斷需要掛起阻塞當前線程,還要進一步比較超時剩餘時間 nanosTimeout 和 spinForTimeoutThreshold 的大小,如果小於等於 spinForTimeoutThreshold 值(1000 納秒)的話,將不會使當前線程進行超時等待,而是再次進行自旋過程。
    加後面這個判斷的主要原因在於,在非常短(小於 1000 納秒)的時間內的等待無法做到十分精確,如果這時還進行超時等待的話,反而會讓我們指定 nanosTimeout 的超時從整體上給人感覺反而不太精確,因此,在剩餘超時時間非常短的情況下,同步器會再次自旋進行超時獲取鎖的過程,獨佔式超時獲取鎖整個過程如下所示:

    共享式同步狀態獲取與釋放

    共享鎖顧名思義就是可以多個線程共用一個鎖,在同步器中使用 acquireShared 來獲取共享鎖(同步狀態),方法源碼如下:

    public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)
            doAcquireShared(arg);
    }
    

    首先通過 tryAcquireShared 嘗試獲取共享鎖,該方法是一個模板方法在同步器中只是拋出一個不支持操作異常,需要開發人員自己去實現,同時方法的返回值有三種不同的類型分別代表三種不同的狀態,其含義如下:

    1. 小於 0 表示當前線程獲取鎖失敗
    2. 等於 0 表示當前線程獲取鎖成功,但是之後的線程在沒有鎖釋放的情況下獲取鎖將失敗,也就是說這個鎖是共享模式下的最後一把鎖了
    3. 大於 0 表示當前線程獲取鎖成功,並且還有剩餘的鎖可以獲取

    當方法 tryAcquireShared 返回值小於 0 時,也就是獲取鎖失敗,將會執行方法 doAcquireShared,繼續跟進該方法:

    private void doAcquireShared(int arg) {
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head) {
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        if (interrupted)
                            selfInterrupt();
                        failed = false;
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }
    

    方法首先調用 addWaiter 方法封裝當前線程和等待狀態為共享模塊的節點並將其添加到等待同步隊列中,可以發現在共享模式下節點的 nextWaiter 屬性是固定值 Node.SHARED。然後循環獲取當前節點的前驅節點,如果前驅節點是頭節點的話就嘗試獲取共享鎖,如果返回值大於等於 0 表示獲取共享鎖成功,則調用 setHeadAndPropagate 方法,更新頭節點同時如果有可用資源,則向後傳播,喚醒後繼節點,接下來會檢查一下中斷標識,如果已經中斷則中斷當前線程,方法結束返回。如果返回值小於 0,則表示獲取鎖失敗,需要掛起阻塞當前線程或者繼續自旋獲取共享鎖。下面看看 setHeadAndPropagate 方法的具體實現:

    private void setHeadAndPropagate(Node node, int propagate) {
        Node h = head; // Record old head for check below
        setHead(node);
        /*
            * Try to signal next queued node if:
            *   Propagation was indicated by caller,
            *     or was recorded (as h.waitStatus either before
            *     or after setHead) by a previous operation
            *     (note: this uses sign-check of waitStatus because
            *      PROPAGATE status may transition to SIGNAL.)
            * and
            *   The next node is waiting in shared mode,
            *     or we don't know, because it appears null
            *
            * The conservatism in both of these checks may cause
            * unnecessary wake-ups, but only when there are multiple
            * racing acquires/releases, so most need signals now or soon
            * anyway.
            */
        if (propagate > 0 || h == null || h.waitStatus < 0 ||
            (h = head) == null || h.waitStatus < 0) {
            Node s = node.next;
            if (s == null || s.isShared())
                doReleaseShared();
        }
    }
    

    首先將當前獲取到鎖的節點設置為頭節點,然後方法參數 propagate > 0 時表示之前 tryAcquireShared 方法的返回值大於 0,也就是說當前還有剩餘的共享鎖可以獲取,則獲取當前節點的後繼節點並且後繼節點是共享節點時喚醒節點去嘗試獲取鎖,doReleaseShared 方法是同步器共享鎖釋放的主要邏輯。

    同步器提供了 releaseShared 方法來進行共享鎖的釋放,方法源碼如下所示:

    public final boolean releaseShared(int arg) {
        if (tryReleaseShared(arg)) {
            doReleaseShared();
            return true;
        }
        return false;
    }
    

    首先調用 tryReleaseShared 方法嘗試釋放共享鎖,方法返回 false 代表鎖釋放失敗,方法結束返回 false,否則就表示成功釋放鎖,然後執行 doReleaseShared 方法,進行喚醒後繼節點並檢查它是否可以向後傳播等操作。繼續跟進該方法如下:

    private void doReleaseShared() {
            /*
            * Ensure that a release propagates, even if there are other
            * in-progress acquires/releases.  This proceeds in the usual
            * way of trying to unparkSuccessor of head if it needs
            * signal. But if it does not, status is set to PROPAGATE to
            * ensure that upon release, propagation continues.
            * Additionally, we must loop in case a new node is added
            * while we are doing this. Also, unlike other uses of
            * unparkSuccessor, we need to know if CAS to reset status
            * fails, if so rechecking.
            */
        for (;;) {
            Node h = head;
            if (h != null && h != tail) {
                int ws = h.waitStatus;
                if (ws == Node.SIGNAL) {
                    if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                        continue;            // loop to recheck cases
                    unparkSuccessor(h);
                }
                else if (ws == 0 &&
                            !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                    continue;                // loop on failed CAS
            }
            if (h == head)                   // loop if head changed
                break;
        }
    }
    

    可以看到和獨佔式鎖釋放不同的是,在共享模式下,狀態同步和釋放可以同時執行,其原子性由 CAS 來保證,如果頭節點改變了也會繼續循環。每次共享節點在共享模式下喚醒時,頭節點都會指向它,這樣就可以保證可以獲取到共享鎖的所有後續節點都可以喚醒了。

    如何自定義同步組件

    在 JDK 中基於同步器實現的一些類絕大部分都是聚合了一個或多個繼承了同步器的類,使用同步器提供的模板方法自定義內部同步狀態的管理,然後通過這個內部類去實現同步狀態管理的功能,其實這從某種程度上來說使用了 模板模式。比如 JDK 中可重入鎖 ReentrantLock、讀寫鎖 ReentrantReadWriteLock、信號量 Semaphore 以及同步工具類 CountDownLatch 等,其源碼部分截圖如下:

    通過上文可以知道,我們基於同步器可以分別自定義獨佔鎖同步組件和共享鎖同步組件,下面以實現一個在同一個時刻最多只允許 3 個線程訪問,其它線程的訪問將被阻塞的同步工具 TripletsLock 為例,很顯然這個工具是共享鎖模式,主要思路就是去實現一個 JDk 中的 Lock 接口來提供面向使用者的方法,比如,調用 lock 方法獲取鎖,使用 unlock 來對鎖進行釋放等,在 TripletsLock 類內部有一個自定義同步器 Sync 繼承自同步器 AQS,用來對線程的訪問和同步狀態進行控制,當線程調用 lock 方法獲取鎖時,自定義同步器 Sync 先計算出獲取到鎖后的同步狀態,然後使用 Unsafe 類操作來保證同步狀態更新的原子性,由於同一時刻只能 3 個線程訪問,這裏我們可以將同步狀態 state 的初始值設置為 3,表示當前可用的同步資源數量,當有線程成功獲取到鎖時將同步狀態 state 減 1,有線程成功釋放鎖時將同步狀態加 1,同步狀態的取值範圍為 0、1、2、3,同步狀態為 0 時表示沒有可用同步資源,這個時候如果有線程訪問將被阻塞。下面來看看這個自定義同步組件的實現代碼:

    /**
     * @author mghio
     * @date: 2020-06-13
     * @version: 1.0
     * @description:
     * @since JDK 1.8
     */
    public class TripletsLock implements Lock {
    
      private final Sync sync = new Sync(3);
    
      private static final class Sync extends AbstractQueuedSynchronizer {
        public Sync(int state) {
          setState(state);
        }
    
        Condition newCondition() {
          return new ConditionObject();
        }
    
        @Override
        protected int tryAcquireShared(int reduceCount) {
          for (; ;) {
            int currentState = getState();
            int newState = currentState - reduceCount;
            if (newState < 0 || compareAndSetState(currentState, newState)) {
              return newState;
            }
          }
        }
    
        @Override
        protected boolean tryReleaseShared(int count) {
          for (; ;) {
            int currentState = getState();
            int newState = currentState + count;
            if (compareAndSetState(currentState, newState)) {
              return true;
            }
          }
        }
      }
    
      @Override
      public void lock() {
        sync.acquireShared(1);
      }
    
      @Override
      public void lockInterruptibly() throws InterruptedException {
        sync.acquireInterruptibly(1);
      }
    
      @Override
      public boolean tryLock() {
        return sync.tryAcquireShared(1) > 0;
      }
    
      @Override
      public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
        return sync.tryAcquireNanos(1, unit.toNanos(timeout));
      }
    
      @Override
      public void unlock() {
        sync.releaseShared(1);
      }
    
      @Override
      public Condition newCondition() {
        return sync.newCondition();
      }
    }
    

    下面啟動 20 個線程測試看看自定義同步同步工具類 TripletsLock 是否達到我們的預期。測試代碼如下:

    /**
     * @author mghio
     * @date: 2020-06-13
     * @version: 1.0
     * @description:
     * @since JDK 1.8
     */
    public class TripletsLockTest {
      private final Lock lock = new TripletsLock();
      private final DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
    
      @Test
      public void testTripletsLock() {
        // 啟動 20 個線程
        for (int i = 0; i < 20; i++) {
          Thread worker = new Runner();
          worker.setDaemon(true);
          worker.start();
        }
    
        for (int i = 0; i < 20; i++) {
          second(2);
          System.out.println();
        }
      }
    
      private class Runner extends Thread {
        @Override
        public void run() {
          for (; ;) {
            lock.lock();
            try {
              second(1);
              System.out.println(dateFormat.format(new Date()) + " ----> " + Thread.currentThread().getName());
              second(1);
            } finally {
              lock.unlock();
            }
          }
        }
      }
    
      private static void second(long seconds) {
        try {
          TimeUnit.SECONDS.sleep(seconds);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }
    }
    

    測試結果如下:

    從以上測試結果可以發現,同一時刻只有三個線程可以獲取到鎖,符合預期,這裏需要明確的是這個鎖獲取過程是非公平的。

    總結

    本文主要是對同步器中的基礎數據結構、獨佔式與共享式同步狀態獲取與釋放過程做了簡要分析,由於水平有限如有錯誤之處還請留言討論。隊列同步器 AbstractQueuedSynchronizer 是 JDK 中很多的一些多線程併發工具類的實現基礎框架,對其深入學習理解有助於我們更好的去使用其特性和相關工具類。

    參考文章

    Java併發編程的藝術
    Java Synchronizer – AQS Learning
    從 ReentrantLock 的實現看 AQS 的原理及應用
    The java.util.concurrent Synchronizer Framework

    本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※別再煩惱如何寫文案,掌握八大原則!

    ※教你寫出一流的銷售文案?

    ※超省錢租車方案

    ※廣告預算用在刀口上,台北網頁設計公司幫您達到更多曝光效益

    ※產品缺大量曝光嗎?你需要的是一流包裝設計!