標籤: 網頁設計

  • 120行代碼打造.netcore生產力工具-小而美的後台異步組件

    120行代碼打造.netcore生產力工具-小而美的後台異步組件

    相信絕大部分開發者都接觸過用戶註冊的流程,通常情況下大概的流程如下所示:

    1. 接收用戶提交註冊信息
    2. 持久化註冊信息(數據庫+redis)
    3. 發送註冊成功短信(郵件)
    4. 寫操作日誌(可選)

    偽代碼如下:

    public async Task<IActionResult> Reg([FromBody] User user)
    {
        _logger.LogInformation("持久化數據開始");
        await Task.Delay(50);
        _logger.LogInformation("持久化結束");
        _logger.LogInformation("發送短信開始");
        await Task.Delay(100);
        _logger.LogInformation("發送短信結束");
        _logger.LogInformation("操作日誌開始");
        await _logRepository.Insert(new Log { Txt = "註冊日誌" });
        _logger.LogInformation("操作日誌結束");
        return Ok("註冊成功");
    }
    
    

    在以上的代碼中,我使用Task.Delay方法阻塞主線程,用以模擬實際場景中的執行耗時。以上流程應該是包含了絕大部分註冊流程所需要的操作。對於任何開發者來講,以上業務流程沒任何難度,無非是順序的執行各個流程的代碼即可。

    稍微有點開發經驗的應該會將以上的流程進行拆分,但有些人可能就要問了,為什麼要拆分呢?拆分之後的代碼應該怎麼寫呢?下面我們就來簡單聊下如此場景的正確打開方式。

    首先,註冊成功的依據應該是是否成功的將用戶信息持久化(至於是先持久化到數據庫,異或是先寫到redis不在本篇文章討論的範疇),至於發送註冊短信(郵件)以及寫日誌的操作應該不能成為影響註冊是否成功的因素,而發送短信/郵件等相關操作通常情況下也是比較耗時的,所以在對此接口做性能優化時,可優先考慮將短信/郵件以及寫日誌等相關操作與主流程(持久化數據)拆分,使其不阻塞主流程的執行,從而達到提高響應速度的目的。

    知道了為什麼要拆,但具體如何拆分呢?怎樣才能用最少的改動,達到所需的目的呢?

    條條大路通羅馬,所以要達成我們的目的也是有很多方案的,具體選擇哪種方案需要根據具體的業務場景,業務體量等多種因素綜合考慮,下面我將一一介紹分析相關方案。

    在正式介紹可用方案前,筆者想先介紹一種很多新手容易錯誤使用的一種方案(因為筆者就曾經天真的使用過這種錯誤的方案)。

    提到異步,絕大部分.net開發者應該第一想到的就是Task,async,await等,的確,async,await的語法糖簡化了.net開發者異步編程的門檻,減少了很多代碼量。通常一個返回Task類型的方法,在被調用時,會在方法的前面加上await,表示需要等待此方法的執行結果,再繼續執行後面的代碼。但如果不加await時,則不會等待方法的執行結果,進而也不會阻塞主線程。所以,有些人可能就會將發送短信/郵件以及寫日誌的操作如下方式進行改造。

    public async Task<IActionResult> Reg1([FromBody] User user)
    {
        _logger.LogInformation("持久化數據開始");
        await Task.Delay(50);
        _logger.LogInformation("持久化結束");
        _ = Task.Run(async () =>
         {
             _logger.LogInformation("發送短信開始");
             await Task.Delay(100);
             _logger.LogInformation("發送短信結束");
             _logger.LogInformation("操作日誌開始");
             await _logRepository.Insert(new Log { Txt = "註冊日誌" });
             _logger.LogInformation("操作日誌結束");
         });
        return Ok("註冊成功");
    }
    

    然後使用jmeter分別壓測改造前和改造后的接口,結果如下:

    有沒有被驚訝到?就這樣一個簡單的改造,吞吐量就提高了三四倍。既然已經提高了三四倍,那為什麼說這是一種錯誤的改造方法嗎?各位看官且往下看。

    熟悉.netcore的大佬,應該都知道.netcore的依賴注入的生命周期吧。通常情況下,注入的生命周期包括:Singleton,Scope,Transient。
    在以上的流程中,假如寫操作日誌的實例的生命周期是Scope,當在Task中調用Controller獲取到的實例的方法時,因為Task.Run並沒有阻塞主線程,當調用Action return后,當前請求的scope注入的對象會被回收,如果對象被回收之後,Task.Run還未執行完,則會報System.ObjectDisposedException: Cannot access a disposed object. 異常。意思是,不能訪問一個已disposed的對象。正確的做法是使用IServiceScopeFactory創建一個新的作用域,在新的作用域中獲取獲取日誌倉儲服務的實例。這樣就可以避免System.ObjectDisposedException異常了。
    改造后的示例代碼如下:

    public async Task<IActionResult> Reg1([FromBody] User user)
    {
        _logger.LogInformation("持久化數據開始");
        await Task.Delay(50);
        _logger.LogInformation("持久化結束");
        _ = Task.Run(async () =>
        {
            using (var scope = _scopeFactory.CreateScope())
            {
                var sp = scope.ServiceProvider;
                var logRepository = sp.GetService<ILogRepository>();
                _logger.LogInformation("發送短信開始");
                await Task.Delay(100);
                _logger.LogInformation("發送短信結束");
    
                _logger.LogInformation("操作日誌開始");
                await logRepository.Insert(new Log { Txt = "註冊日誌" });
                _logger.LogInformation("操作日誌結束");
            }
        });
        return Ok("註冊成功");
    }
    

    雖然得到了正解,但上述的代碼着實有點多,如果一個項目有多個相似的業務場景,就要考慮對CreateScope相關的操作進行封裝。

    下面就來一一介紹下筆者覺得實現此業務場景的幾種方案。
    1.消息隊列
    2.Quartz任務調度組件
    3.Hangfire任務調度組件
    4.Weshare.TransferJob(推薦)
    首先說下消息隊列的方式。準確的說,消息隊列應該是這種場景的最優解決方案,消息隊列的其中一個比較重要的特性就是解耦,從而提高吞吐量。但並不是所有的應用程序都需要上消息隊列。有些業務場景使用消息隊列時,往往會給人一種”殺雞用牛刀”的感覺。

    其次Quartz和Hangfire都是任務調度框架,都提供了可實現以上業務場景的邏輯,但Quartz和Hangfire都需要持久化作業數據。雖然Hangfire提供了內存版本,但經過我的測試,發現Hangfire的內存版本特別消耗內存,所以不太推薦使用任務調度框架來實現類似於這樣的業務邏輯。

    最後,也就是本文的重點,筆者結合了消息隊列和任務調度的思想,實現了一個輕量級的轉移作業到後台執行的組件。此組件完美的解決了Scope生命周期實例獲取的問題,一行代碼將不需要等待的操作轉移到後台線程執行。
    接入步驟如下:
    1.使用nuget安裝Weshare.TransferJob
    2.在Stratup中注入服務。

    services.AddTransferJob();
    

    3.通過構造函數或其他方法獲取到IBackgroundRunService的實例。
    4.調用實例的Transfer方法將作業轉移到後台線程。

    _backgroundRunService.Transfer(log=>log.Insert(new Log(){Txt = "註冊日誌"}));
    

    就是這麼簡單的實現了這樣的業務場景,不僅簡化了代碼,而且大大提高了系統的吞吐量。

    下面再來一起分析下Weshare.TransferJob的核心代碼(畢竟文章要點題)。各位器宇不凡的看官請繼續往下看。
    下面的代碼是AddTransferJob方法的實現:

    public static IServiceCollection AddTransferJob(this IServiceCollection services)
    {
        services.AddSingleton<IBackgroundRunService, BackgroundRunService>();
        services.AddHostedService<TransferJobHostedService>();
        return services;
    }
    

    聰明”絕頂”的各位看官應該已經發現上述代碼的關鍵所在。是的, 你沒有看錯,此組件的就是利用.net core提供的HostedService在後台執行被轉移的作業的。
    我們再來一起看看TransferJobHostedService的代碼:

    public class TransferJobHostedService:BackgroundService
    {
        private IBackgroundRunService _runService;
        public TransferJobHostedService(IBackgroundRunService runService)
        {
            _runService = runService;
        }
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                await _runService.Execute(stoppingToken);
            }
        }
    }
    

    這個類的代碼也很簡單,重寫了BackgroundService類的ExecuteAsync,循環調用IBackgroundRunService實例的Execute方法。所以,最最關鍵的代碼是IBackgroundRunService的實現類中。
    詳細代碼如下:

    public class BackgroundRunService : IBackgroundRunService
    {
        private readonly SemaphoreSlim _slim;
        private readonly ConcurrentQueue<LambdaExpression> queue;
        private ILogger<BackgroundRunService> _logger;
        private readonly IServiceProvider _serviceProvider;
        public BackgroundRunService(ILogger<BackgroundRunService> logger, IServiceProvider serviceProvider)
        {
            _slim = new SemaphoreSlim(1);
            _logger = logger;
            _serviceProvider = serviceProvider;
            queue = new ConcurrentQueue<LambdaExpression>();
        }
        public async Task Execute(CancellationToken cancellationToken)
        {
            try
            {
                await _slim.WaitAsync(cancellationToken);
                if (queue.TryDequeue(out var job))
                {
                    using (var scope = _serviceProvider.GetRequiredService<IServiceScopeFactory>().CreateScope())
                    {
                        var action = job.Compile();
                        var isTask = action.Method.ReturnType == typeof(Task);
                        var parameters = job.Parameters;
                        var pars = new List<object>();
                        if (parameters.Any())
                        {
                            var type = parameters[0].Type;
                            var param = scope.ServiceProvider.GetRequiredService(type);
                            pars.Add(param);
                        }
                        if (isTask)
                        {
                            await (Task)action.DynamicInvoke(pars.ToArray());
                        }
                        else
                        {
                            action.DynamicInvoke(pars.ToArray());
                        }
                    }
                }
            }
            catch (Exception e)
            {
                _logger.LogError(e.ToString());
            }
        }
        public void Transfer<T>(Expression<Func<T, Task>> expression)
        {
            queue.Enqueue(expression);
            _slim.Release();
        }
        public void Transfer(Expression<Action> expression)
        {
            queue.Enqueue(expression);
            _slim.Release();
        }
    }
    

    納尼?嫌代碼多看不懂?那咱們一起來剖析下吧。
    首先,此類有三個較重要的私有變量,對應的類型分別是SemaphoreSlim, ConcurrentQueue ,IServiceProvider。
    其中SemaphoreSlim是為了控制後台作業執行的順序的,在構造函數中初始化了此對象的信號量為1,表示在後台服務的ExecuteAsync方法的循環中每次只能有一個作業執行。
    ConcurrentQueue 的對象是用來存儲被轉移到後台服務執行的作業的邏輯,所以使用LambdaExpression作為隊列的類型。
    IServiceProvider是為了解決依賴注入的生命周期的。

    然後在Execute方法中,第一行代碼如下:

    await _slim.WaitAsync(cancellationToken);
    

    作用是等待一個信號量,當沒有可用的信號量時,會阻塞線程的執行,這樣在後台服務的ExecuteAsync方法的死循環就不會一直執行下去,只有獲取到信號量才會繼續執行。
    當獲取到信號量后,則說明有新的作業等待執行,所以此時則需要從隊列中讀出要執行的LambdaExpression表達式,創建一個新的Scope后,編譯此表達式樹,判斷返回類型,獲取泛型的具體類型,最後獲取到泛型對應的實例,執行對應的方法。

    另外,Transfer方法就是暴露給調用者的方法,用於將表達式樹寫到隊列中,同時釋放信號量。

    到此為止,Weshare.TransferJob的實現原理已分析完畢,由於此組件的原理只是將任務轉移到後台進行執行,所以並不是適合對事務有要求的場景。正如本文開頭所假設的場景,TransferJob最適合的場景還是那些和主操作關聯性較低的、失敗或成功並不會影響業務的正常運行。
    同時,此組件的定位就是小而美,像延遲執行、定時執行的功能在最初的規劃中其實是有的,後來發現這些功能quartz已經有了,所以沒必要重複造這樣的輪子。
    後期會根據使用場景,嘗試加入異常重試機制,以及異常通知回調機制。

    最後,不知道有沒有較真的看官想計算下代碼量是否超過120行。
    為了證明我不是標題黨,現將此組件進行開源,地址是:
    https://github.com/fuluteam/WeShare.TransferJob

    橋豆麻袋,筆者辛苦敲的代碼,難道各位看官想白嫖嗎? 點個贊再走唄。點完贊還有力氣的話,如果git上能點個star的話,那也是最好不過的。小生這廂先行謝過。

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

    【其他文章推薦】

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

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

    ※回頭車貨運收費標準

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

    ※超省錢租車方案

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

  • 心痛!抹香鯨遭漁網纏住痛苦掙扎 潛水員花3天仍割不完

    摘錄自2020年7月22日自由時報報導

    義大利當地時間18日,一條抹香鯨在利帕里島(Lipari)附近海域被廢棄漁網纏住,義大利海警花了數天時間企圖割開漁網,但由於抹香鯨躁動,導致進度緩慢。

    綜合外媒報導,一隻抹香鯨被發現受困於義大利利帕里島海域,牠被廢棄漁網困住無法游離,義大利海警獲報後,即刻出動潛水員救援,他們企圖割開漁網,但由於抹香鯨情緒不穩,相當躁動,令潛水員相當困擾,也因此把牠命為「Fury」(憤怒之意),潛水員花了3天時間才將部分漁網割下。

    不料恢復行動力的「Fury」開始下潛失去蹤影,儘管纏在「Fury」身上的大部分漁網已經割開,不過牠的尾巴仍有魚網纏繞住,救難人員正積極尋找其下落。

    生物多樣性
    海洋
    國際新聞
    抹香鯨
    漁網

    本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※為什麼 USB CONNECTOR 是電子產業重要的元件?

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

    ※台北網頁設計公司全省服務真心推薦

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

    ※推薦評價好的iphone維修中心

  • 研究:全球礁鯊數量銳減 部分海域已絕跡

    摘錄自2020年7月22日中央社報導

    22日發布在科學期刊「自然」(Nature)的一份調查,首度全面了解全球哪裡的礁鯊實際上已滅絕。這份調查為期四年,在近60個國家超過370個暗礁海域進行研究。

    加拿大達爾豪希大學(Dalhousie University)副教授麥克尼爾(Aaron MacNeil)表示:「我們原本預期……地球上每個暗礁海域應該都有鯊魚出沒,結果發現我們調查的海域中,有20%沒有任何鯊魚,這令人非常擔心。」

    調查顯示,在卡達、印度、越南及肯亞等八個國家的暗礁海域,完全找不到鯊魚。這不代表這些國家的海域沒有鯊魚,但證明當地暗礁海域的鯊魚數量少得可憐。這表示礁鯊在當地生態系統已失去任何角色,也就是牠們已經功能性滅絕。

    研究指出,破壞性的捕魚活動最可能是礁鯊數量大減罪魁禍首。「使用流刺網和延繩捕魚,對原本數量相對豐沛的礁鯊帶來最大負面影響。」

    生物多樣性
    物種保育
    土地利用
    農林漁牧業
    國際新聞
    全球
    鯊魚
    暗礁
    滅絕
    捕魚
    生態系統
    流刺網
    延繩釣

    本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

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

    網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

    ※想知道購買電動車哪裡補助最多?台中電動車補助資訊懶人包彙整

    南投搬家公司費用,距離,噸數怎麼算?達人教你簡易估價知識!

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

    ※超省錢租車方案

  • 世界經濟論壇:綠色振興將可創造一年300兆元收益

    環境資訊中心綜合外電;姜唯 編譯;林大利 審校

    本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

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

    網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

    ※Google地圖已可更新顯示潭子電動車充電站設置地點!!

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

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

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

  • 印尼帝汶島海灘驚現大鯨魚屍體

    摘錄自2020年7月24日法廣印尼報導

    印度尼西亞南部帝汶島沙灘附近,發現了一具上百噸重大鯨魚的屍體。當局目前還沒搞懂這頭大型哺乳動物的死因。

    當地漁民是在20日早上發現這具長達29米的大鯨魚。海事當局認為這頭海洋生物已有70歲,無生命的漂移,可能已長達數年之久。死因則尚無定論。其屍體上沒有發現任何傷痕,但當地媒體提到了上一個的案例。

    2018年,另一頭鯨魚曾擱淺在附近的另個小島Kapota。那頭鯨魚的胃裡發現了眾多塑膠垃圾:115個塑膠杯,還有4個塑膠瓶。這一發現讓人感到震驚,儘管這最終並沒有被確定是造成大鯨魚死亡的原因,但已足以引起科學界的重視。

    生物多樣性
    海洋
    國際新聞
    印尼
    鯨魚
    海洋垃圾

    本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

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

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

    南投搬家公司費用需注意的眉眉角角,別等搬了再說!

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

  • 漢娜颶風肆虐德州 吹倒美墨邊境圍牆

    摘錄自2020年7月27日東森國際新聞美國報導

    美國德州(Texas)新冠肺炎疫情持續肆虐,周末又遭颶風漢娜(Hanna)侵襲。漢娜是今年第一個颶風,它已經摧毀許多船隻、淹沒街道、造成電力中斷、甚至吹倒一部分美墨邊境圍牆,災情相當嚴重。

    據外媒《WDSU News》報導,國家颶風中心(National Hurricane Center)表示,目前颶風漢娜已降為熱帶低氣壓,以每小時超過50英里的風速橫越美墨邊境,並在德州南部和墨西哥東北部的部分地區降下了超過300毫米的豪雨。

    州長格雷格·阿博特 (Greg Abbott)於週六(25日)在一場記者會中表示,「任何颶風都是一場巨大的挑戰,但這次的挑戰非常複雜,必且比以往更加嚴峻,因為這場颶風正在席捲著新冠肺炎的其中一個震央。」

    土地利用
    國際新聞
    美國
    颶風
    災害

    本站聲明:網站內容來源環境資訊中心https://e-info.org.tw/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

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

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

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

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

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

  • 【Spring註解驅動開發】使用@Import註解給容器中快速導入一個組件

    寫在前面

    我們可以將一些bean組件交由Spring管理,並且Spring支持單實例bean和多實例bean。我們自己寫的類,可以通過包掃描+標註註解(@Controller、@Servcie、@Repository、@Component)的形式將其註冊到IOC容器中,如果不是我們自己寫的類,比如,我們在項目中引入了一些第三方的類庫,此時,我們需要將這些第三方類庫中的類註冊到Spring容器中,該怎麼辦呢?此時,我們就可以使用@Bean和@Import註解將這些類快速的導入Spring容器中。接下來,我們來一起探討下如何使用@Import註解給容器中快速導入一個組件。

    項目工程源碼已經提交到GitHub:https://github.com/sunshinelyz/spring-annotation

    註冊bean的方式

    向Spring容器中註冊bean通常有以下幾種方式:

    • 包掃描+標註註解(@Controller、@Servcie、@Repository、@Component),通常用於自己寫的類。
    • @Bean註解,通常用於導入第三方包中的組件。
    • @Import註解,快速向Spring容器中導入組件。

    @Import註解概述

    Spring 3.0之前,創建Bean可以通過xml配置文件與掃描特定包下面的類來將類注入到Spring IOC容器內。而在Spring 3.0之後提供了JavaConfig的方式,也就是將IOC容器里Bean的元信息以java代碼的方式進行描述。我們可以通過@Configuration與@Bean這兩個註解配合使用來將原來配置在xml文件里的bean通過java代碼的方式進行描述

    @Import註解提供了@Bean註解的功能,同時還有xml配置文件里 標籤組織多個分散的xml文件的功能,當然在這裡是組織多個分散的@Configuration

    先看一下@Import註解的源碼:

    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface Import {
        /**
          * {@link Configuration}, {@link ImportSelector}, {@link ImportBeanDefinitionRegistrar}
          * or regular component classes to import.
          */
         Class<?>[] value();
    }
    

    從源碼里可以看出@Import可以配合 Configuration , ImportSelector, ImportBeanDefinitionRegistrar 來使用,下面的or表示也可以把Import當成普通的Bean使用。

    @Import只允許放到類上面,不能放到方法上。下面我們來看具體的使用方式。

    @Import註解的使用方式

    @Import註解的三種用法主要包括:

    • 直接填class數組方式
    • ImportSelector方式【重點】
    • ImportBeanDefinitionRegistrar方式

    注意:我們先來看第一種方法:直接填class數組的方式,其他的兩種方式我們後面繼續講。

    @Import導入組件的簡單示例

    沒有使用@Import註解的效果

    首先,我們創建一個Department類,這個類是一個空類,沒有成員變量和方法,如下所示。

    package io.mykit.spring.plugins.register.bean;
    
    /**
     * @author binghe
     * @version 1.0.0
     * @description 測試@Import註解的bean
     */
    public class Department {
    }
    

    接下來,我們先在SpringBeanTest類中創建testAnnotationConfig7()方法,輸出Spring容器中所有的bean,來查看是否存在Department類對應的bean實例,以此來判斷Spring容器中是否註冊有Department類對應的bean實例。

    @Test
    public void testAnnotationConfig7(){
        ApplicationContext context = new AnnotationConfigApplicationContext(PersonConfig2.class);
        String[] names = context.getBeanDefinitionNames();
        Arrays.stream(names).forEach(System.out::println);
    }
    

    運行SpringBeanTest類的testAnnotationConfig7()方法,輸出的結果信息如下所示。

    org.springframework.context.annotation.internalConfigurationAnnotationProcessor
    org.springframework.context.annotation.internalAutowiredAnnotationProcessor
    org.springframework.context.annotation.internalCommonAnnotationProcessor
    org.springframework.context.event.internalEventListenerProcessor
    org.springframework.context.event.internalEventListenerFactory
    personConfig2
    person
    binghe001
    

    可以看到Spring容器中並沒有Department類對應的bean實例。

    使用@Import註解的效果

    我們在PersonConfig2類上添加@Import註解,並將Department類標註到註解中,如下所示。

    @Configuration
    @Import(Department.class)
    public class PersonConfig2 {
    

    此時,我們再次運行SpringBeanTest類的testAnnotationConfig7()方法,輸出的結果信息如下所示。

    org.springframework.context.annotation.internalConfigurationAnnotationProcessor
    org.springframework.context.annotation.internalAutowiredAnnotationProcessor
    org.springframework.context.annotation.internalCommonAnnotationProcessor
    org.springframework.context.event.internalEventListenerProcessor
    org.springframework.context.event.internalEventListenerFactory
    personConfig2
    io.mykit.spring.plugins.register.bean.Department
    person
    binghe001
    

    可以看到,輸出結果中打印了io.mykit.spring.plugins.register.bean.Department,說明使用@Import導入bean時,id默認是組件的全類名。

    @Import註解支持同時導入多個類,例如,我們再次創建一個Employee類,如下所示。

    package io.mykit.spring.plugins.register.bean;
    /**
     * @author binghe
     * @version 1.0.0
     * @description 測試@Import註解的bean
     */
    public class Employee {
    }
    

    接下來,我們也將Employee類添加到@Import註解中,如下所示。

    @Configuration
    @Import({Department.class, Employee.class})
    public class PersonConfig2 {
    

    此時,我們再次運行SpringBeanTest類的testAnnotationConfig7()方法,輸出的結果信息如下所示。

    org.springframework.context.annotation.internalConfigurationAnnotationProcessor
    org.springframework.context.annotation.internalAutowiredAnnotationProcessor
    org.springframework.context.annotation.internalCommonAnnotationProcessor
    org.springframework.context.event.internalEventListenerProcessor
    org.springframework.context.event.internalEventListenerFactory
    personConfig2
    io.mykit.spring.plugins.register.bean.Department
    io.mykit.spring.plugins.register.bean.Employee
    person
    binghe001
    

    可以看到,結果信息中同時輸出了io.mykit.spring.plugins.register.bean.Department和io.mykit.spring.plugins.register.bean.Employee,說明Department類的bean實例和Employee類的bean實例都導入到Spring容器中了。

    好了,咱們今天就聊到這兒吧!別忘了給個在看和轉發,讓更多的人看到,一起學習一起進步!!

    項目工程源碼已經提交到GitHub:https://github.com/sunshinelyz/spring-annotation

    寫在最後

    如果覺得文章對你有點幫助,請微信搜索並關注「 冰河技術 」微信公眾號,跟冰河學習Spring註解驅動開發。公眾號回復“spring註解”關鍵字,領取Spring註解驅動開發核心知識圖,讓Spring註解驅動開發不再迷茫。

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

    【其他文章推薦】

    ※為什麼 USB CONNECTOR 是電子產業重要的元件?

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

    ※台北網頁設計公司全省服務真心推薦

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

    ※推薦評價好的iphone維修中心

  • 新基建下,智慧交通發展新規劃:智慧隧道監控可視化系統

    新基建下,智慧交通發展新規劃:智慧隧道監控可視化系統

    前言 隨着當代經濟的發展,交通環境日益緊張,加上山區地區的交通運輸的需求,隧道的交通建設開發方興未艾。
    隧道交通的規劃越來越完備,而對於隧道內監控管理維護卻顯得有些不足。而
    工業4.0的崛起,逐步進入了智能化的新時代,伴隨着
    工業互聯網的新興力量,工控可視化系統應運而生,不僅能起到日常的監控管理維護,在發現事故或險情時能第一時間採取
    應急預案;還能通過實時數據的採集反饋,遠程操控設備運行以及預測設備的優良性能,從而達到更立體更全面的工控系統的運行。
    HT for Web
     不止自主研發了強大的基於 HTML5 的 2D、3D 渲染引擎,為可視化提供了豐富的展示效果。介於 2D 組態和 3D 組態上,Hightopo(以下簡稱 HT )的 HT for Web 產品上的有着豐富的組態化可供選擇,本文將介紹如何運用 HT 豐富的 2/3D 組態搭建出一個隧道監控可視化系統的解決方案 監控隧道內的車道堵塞情況、隧道內的車禍現場,在隧道中显示當前車禍位置並在隧道口給予提示等功能都是非常有必要的。這個隧道監控可視化系統的主要內容包括:照明、風機、車道指示燈、交通信號燈、情報板、消防、火災報警、車行橫洞、風向儀、微波車檢、隧道緊急逃生出口的控制以及事故模擬等等。  
    界面簡介及效果預覽  

    預覽鏈接:http://www.hightopo.com/demo/tunnel2/index.html

    上圖中的各種設備都可以雙擊,此時 camera 的位置會從當前位置移動到雙擊的設備的正前方;隧道入口的展示牌會自動輪播,出現事故時會展示牌中的內容會由“限速80,請開車燈”變為“超車道兩車追尾,請減速慢行”;兩隧道中間的逃生通道上方的指示牌是可以點擊的,點擊切換為藍綠色激活狀態,兩旁的逃生通道門也會打開,再單擊指示牌變為灰色,門關閉;還有一個事故現場模擬,雙擊兩旁變壓器中其中一個,在隧道內會出現一個“事故現場圖標”,單擊此圖標,出現彈出框显示事故等等等等。

     
    代碼實現
    一、場景搭建 整個隧道都是基於 3D 場景上繪製的,先來看看怎麼搭建 3D 場景:

    // 數據容器 dm = new ht.DataModel(); // 3d 場景 g3d = new ht.graph3d.Graph3dView(dm); // 將場景添加到 body 中 g3d.addToDOM();

    上面代碼中的 addToDOM 函數,是一個將組件添加到 body 體中的函數的封裝,定義如下:

    addToDOM = function(){ var self = this, // 獲取組件的底層 div view = self.getView(), style = view.style; // 將組件底層div添加進body中  document.body.appendChild(view); // ht 默認將所有的組件的position都設置為absolute絕對定位 style.left = '0'; style.right = '0'; style.top = '0'; style.bottom = '0'; // 窗口大小改變事件,調用刷新函數 window.addEventListener('resize', function () { self.iv(); }, false); }

     
    二、JSON反序列化 整個場景是由名為 隧道1.json 的文件導出而成的,我只需要用代碼將 json 文件中的內容轉換為我需要的部分即可:

    // xhrLoad 函數是一個異步加載文件的函數 ht.Default.xhrLoad('./scenes/隧道1.json', function(text) { // 將 json 文件中的文本轉為我們需要的 json 格式的內容 var json = ht.Default.parse(text); // 反序列化數據容器,解析用於生成對應的Data對象並添加到數據容器 這裏相當於把 json 文件中生成的 ht.Node 節點反序列化到數據容器中,這樣數據容器中就有這個節點了  dm.deserialize(json); });

    由於 xhrLoad 函數是一個異步加載函數,所以如果 dm 數據容器反序列化未完成就直接調用了其中的節點,那麼會造成數據獲取不到的結果,所以一般來說我是將一些邏輯代碼寫在這個函數內部,或者給邏輯代碼設置 timeout 錯開時間差。

    首先,由於數據都是存儲在 dm 數據容器中的(通過 dm.add(node) 添加的),所以我們要獲取數據除了可以通過 id、tag 等獨立的方式,還可以通過遍曆數據容器來獲取多個元素。由於這個場景比較複雜,模型的面也比較多,鑒於設備配置,我將能 Batch 批量的元素都進行了批量。

    批量是 HT 實現下的一種特有的機制,批量能提高性能的原理在於,當圖元一個個獨立繪製模型時性能較差,而當一批圖元聚合成一個大模型進行一次性的繪製時, 則會極大提高 WebGL 刷新性能,執行代碼如下

    dm.each(function(data) { // 對“電話”進行批量 if (data.s('front.image') === 'assets/sos電話.png'){ data.s('batch', 'sosBatch'); } // 逃生通道批量(透明度也會影響性能) else if (data.s('all.color') === 'rgba(222,222,222,0.18)') { data.s('batch', 'emergencyBatch'); } else if (data.s('shape3d') === 'models/隧道/攝像頭.json' || data.s('shape3d') === 'models/隧道/橫洞.json' || data.s('shape3d') === 'models/隧道/捲簾門.json') { // 個別攝像頭染色了 不做批量 if(!data.s('shape3d.blend')) // 基礎批量什麼也不做 data.s('batch', 'basicBatch'); } else if (data.s('shape3d') === 'models/大型變壓器/變壓器.json') { data.s('batch', 'tileBatch'); data.setToolTip('單擊漫遊,雙擊車禍地點出現圖標'); } else if (data.getDisplayName() === '地面') { // 設置隧道“地面”不可選中 data.s('3d.selectable', false); } else if (data.s('shape3d') === 'models/隧道/排風.json') { // 排風扇的模型比較複雜,所以做批量 data.s('batch', 'fanBatch'); } else if (data.getDisplayName() === 'arrow') { // 隧道兩旁的箭頭路標 if (data.getTag() === 'arrowLeft') data.s('shape3d.image', 'displays/abc.png'); else data.s('shape3d.image', 'displays/abc2.png'); data.s({ 'shape3d': 'billboard', // 緩存,設置了 cache 的代價是需要設置 invalidateShape3dCachedImage 'shape3d.image.cache': true, // 設置這個值,圖片上的鋸齒就不會太明顯了(若圖片類型為 json,則設置 shape3d.dynamic.transparent) 'shape3d.transparent': true }); g3d.invalidateShape3dCachedImage(data); } // 隧道入口處的情報板 else if (data.getTag() === 'board' || data.getTag() === 'board1') { // 業務屬性,用來控制文本的位置[x,y,width,height] data.a('textRect', [0, 2, 244, 46]); // 業務屬性,設置文本內容 data.a('limitText', '限速80,請開車燈'); var min = -245; var name = 'board' + data.getId(); window[name] = setInterval(function() { // 設置情報板中的文字向左滾動,並且當文字全部显示時重複閃爍三次  circleFunc(data, window[name], min); }, 100); } //給逃生通道上方的指示板 動態設置顏色 var infos = ['人行橫洞1', '人行橫洞2', '人行橫洞3', '人行橫洞4', '車行橫洞1', '車行橫洞2', '車行橫洞3']; infos.forEach(function(info) { if(data.getDisplayName() === info) { data.a('emergencyColor', 'rgb(138, 138, 138)'); } }); infos = ['車道指示器', '車道指示器1', '車道指示器2', '車道指示器3']; infos.forEach(function(info) { if (data.getDisplayName() === info) { // 考慮到性能問題 將六面體變換為 billboard 類型元素 createBillboard(data, 'assets/車道信號-過.png', 'assets/車道信號-過.png', info); } }); });

    上面有一處設置了 tooltip 文字提示信息,在 3d 中,要显示這個文字提示信息,就需要設置 g3d.enableToolTip() 函數,默認 3d 組件是關閉這個功能的。  
    三、邏輯代碼
    情報板滾動條 我就直接按照上面代碼中提到的方法進行解釋,首先是 circleFunc 情報板文字循環移動的函數,在這個函數中我們用到了業務屬性 limitText 設置情報板中的文字屬性以及 textRect 設置情報板中文字的移動位置屬性:

    // 設置情報板中的文字向左滾動,並且當文字全部显示時重複閃爍三次 function circleFunc(data, timer, min) { // 獲取當前業務屬性 limitText 的內容 var text = data.a('limitText'); // 設置業務屬性 textRect 文本框的坐標和大小 data.a('textRect', [data.a('textRect')[0]-5, 2, 244, 46]); if (parseInt(data.a('textRect')) <= parseInt(min)) { data.a('textRect', [255, 2, 244, 46]); } else if (data.a('textRect')[0] === 0) { clearInterval(timer); var index = 0; // 設置多個 timer 是因為能夠進入這個函數中的不止一個 data,如果在同一時間多個 data 設置同一個 timer,那肯定只會對最後一個節點進行動畫。後面還有很多這種陷阱,要注意 var testName = 'testTimer' + data.getId(); window[testName] = setInterval(function() { index++; // 如果情報板中文本內容為空 if(data.a('limitText') === '') { setTimeout(function() { // 設置為傳入的 text 值 data.a('limitText', text); }, 100); } else { setTimeout(function() { // 若情報板中的文本內容不為空,則設置為空 data.a('limitText', ''); }, 100); } // 重複三次 if(index === 11) { clearInterval(window[testName]); data.a('limitText', text); } }, 100); setTimeout(function() { timer = setInterval(function() { // 回調函數  circleFunc(data, timer, min); }, 100); }, 1500); } } 

    由於 WebGL 對瀏覽器的要求不低,為了能盡量多的適應各大瀏覽器,我們將所有的“道路指示器” ht.Node 類型的六面體全部換成 billboard 類型的節點,性能能提升不少。

    http://www.hightopo.com 設置 billboard 的方法很簡單,獲取當前的六面體節點,然後給這些節點設置:

    node.s({
        'shape3d': 'billboard', 'shape3d.image': imageUrl, 'shape3d.image.cache': true }); // 還記得用 shape3d.image.cache 的代價么? g3d.invalidateShape3dCachedImage(node); 

    當然,因為 billboard 不能雙面显示不同的圖片,只是一個“面”,所以我們還得在這個節點的位置創建另一個節點,在這個節點的“背面”显示圖片,並且跟這個節點的配置一模一樣,不過位置要稍稍偏移一點。  
    Camera 緩慢偏移 其他動畫部分比較簡單,我就不在這裏多說了,這裡有一個雙擊節點能將視線從當前 camera 位置移動到雙擊節點正前方的位置的動畫我提一下。我封裝了兩個函數 setEye 和 setCenter,分別用來設置 camera 的位置和目標位置的:

    // 設置“目標”位置 function setCenter(center, finish) { // 獲取當前“目標”位置,為一個數組,而 getCenter 數組會在視線移動的過程中不斷變化,所以我們先拷貝一份 var c = g3d.getCenter().slice(0), // 當前x軸位置和目標位置的差值 dx = center[0] - c[0], dy = center[1] - c[1], dz = center[2] - c[2]; // 啟動 500 毫秒的動畫過度  ht.Default.startAnim({ duration: 500, action: function(v, t) { // 將“目標”位置緩慢從當前位置移動到設置的位置處  g3d.setCenter([ c[0] + dx * v, c[1] + dy * v, c[2] + dz * v ]); } }); }; // 設置“眼睛”位置 function setEye(eye, finish) { // 獲取當前“眼睛”位置,為一個數組,而 getEye 數組會在視線移動的過程中不斷變化,所以我們先拷貝一份 var e = g3d.getEye().slice(0), dx = eye[0] - e[0], dy = eye[1] - e[1], dz = eye[2] - e[2]; // 啟動 500 毫秒的動畫過度  ht.Default.startAnim({ duration: 500, // 將 Camera 位置緩慢地從當前位置移動到設置的位置 action: function(v, t) { g3d.setEye([ e[0] + dx * v, e[1] + dy * v, e[2] + dz * v ]); } }); };

    後期我們要設置的時候就直接調用這兩個函數,並設置參數為我們目標的位置即可。比如我這個場景中的各個模型,由於不同視角對應的各個模型的旋轉角度也不同,我只能找幾個比較有代表性的 0°,90°,180°以及360° 這四種比較典型的角度了。所以繪製 3D 場景的時候,我也盡量設置節點的旋轉角度為這四个中的一種(而且對於我們這個場景來說,基本上只在 y 軸上旋轉了):

    // 獲取事件對象的三維坐標 var p3 = e.data.p3(), // 獲取事件對象的三維尺寸 s3 = e.data.s3(), // 獲取事件對象的三維旋轉值 r3 = e.data.r3(); // 設置“目標”位置為當前事件對象的三維坐標值 setCenter(p3); // 如果節點的 y 軸旋轉值 不為 0 if (r3[1] !== 0) { // 浮點負數得做轉換才能進行比值 if (parseFloat(r3[1].toFixed(5)) === parseFloat(-3.14159)) { // 設置camera 的目標位置 setEye([p3[0], p3[1]+s3[1], p3[2] * Math.abs(r3[1]*2.3/6)]);  } else if (parseFloat(r3[1].toFixed(4)) === parseFloat(-1.5708)) { setEye([p3[0] * Math.abs(r3[1]/1.8), p3[1]+s3[1], p3[2]]);  } else { setEye([p3[0] *r3[1], p3[1]+s3[1], p3[2]]); } } else { setEye([p3[0], p3[1]+s3[1]*2, p3[2]+1000]); }

     
    事故模擬現場 最後來說說模擬的事故現場吧,這段還是比較接近實際項目的。操作流程如下:雙擊“變壓器”–>隧道中間某個部分會出現一個“事故現場”圖標–>單擊圖標,彈出對話框,显示當前事故信息–>點擊確定,則事故現場之前的燈都显示為紅色×,並且隧道入口的情報板上的文字显示為“超車道兩車追尾,請減速慢行”–>再雙擊一次“變壓器”,場景恢復事故之前的狀態。 在 HT 中,可通過 Graph3dView#addInteractorListener(簡寫為 mi)來監聽交互過程:

    g3d.addInteractorListener(function(e) { if(e.kind === 'doubleClickData') { // 有“事故”圖標節點存在 if (e.data.getTag() === 'jam') return; // 如果雙擊對象是變壓器 if (e.data.s('shape3d') === 'models/大型變壓器/變壓器.json') { index++; // 通過唯一標識 tag 標籤獲取“事故”圖標節點對象 var jam = dm.getDataByTag('jam'); if(index === 1){ var jam = dm.getDataByTag('jam'); jam.s({ // 設置節點在 3d 上可見 '3d.visible': true, // 設置節點為 billboard 類型 'shape3d': 'billboard', // 設置 billboard 的显示圖片 'shape3d.image': 'assets/車禍.png', // 設置 billboard 圖片是否緩存 'shape3d.image.cache': true, // 是否始終面向鏡頭 'shape3d.autorotate': true, // 默認保持圖片原本大小,設置為數組模式則可以設置圖片显示在界面上的大小 'shape3d.fixSizeOnScreen': [30, 30], }); // cache 的代價是節點需要設置這個函數  g3d.invalidateShape3dCachedImage(jam); } else { jam.s({ // 第二次雙擊變壓器就將所有一切恢復“事故”之前的狀態 '3d.visible': false }); dm.each(function(data) { var p3 = data.p3(); if ((p3[2] < jam.p3()[2]) && data.getDisplayName() === '車道指示器1') { data.s('shape3d.image', 'assets/車道信號-過.png'); } if(data.getTag() === 'board1') { data.a('limitText', '限速80,請開車燈'); } }); index = 0; } } } });

    既然“事故”節點圖標出現了,接着點擊圖標出現“事故信息彈出框”,監聽事件同樣是在 mi(addInteractorListener)中,但是這次監聽的是單擊事件,我們知道,監聽雙擊事件時會觸發一次單擊事件,為了避免這種情況,我在單擊事件裏面做了演示:

    // 點擊圖元 else if (e.kind === 'clickData'){ timer = setTimeout(function() { clearTimeout(timer); // 如果是“事故”圖標節點 if (e.data.getTag() === 'jam') { // 創建一個對話框  createDialog(e.data); } }, 200); }

    在上面的雙擊事件中我沒有 clearTimeout,怕順序問題給大家造成困擾,要記得加一下。 彈出框如下: 這個彈出框是由兩個 ht.widget.FormPane 表單構成的,左邊的表單隻有一行,行高為 140,右邊的表單是由 5 行構成的,點擊確定,則“事故”圖標節點之前的道路指示燈都換成紅色×的圖標:

    // 彈出框右邊的表單 function createForm4(node, dialog) { // 表單組件 var form = new ht.widget.FormPane(); // 設置表單組件的寬 form.setWidth(200); // 設置表單組件的高 form.setHeight(200); // 獲取表單組件的底層 div var view = form.getView(); // 將表單組件添加到 body 中  document.body.appendChild(view); var infos = [ '編輯框內容為:2輛', '編輯框內容為:客車-客車', '編輯框內容為:無起火', '編輯框內容為:超車道' ]; infos.forEach(function(info) { // 向表單中添加行  form.addRow([ info // 第二個參數為行寬度,小於1的值為相對值 ], [0.1]); }); form.addRow([ { // 添加一行的“確認”按鈕  button: { label: '確認', // 按鈕點擊事件觸發 onClicked: function() { // 隱藏對話框  dialog.hide(); dm.each(function(data) { var p3 = data.p3(); // 改變“車道指示器”的显示圖片為紅色×,這裏我是根據“事故”圖標節點的坐標來判斷“車道显示器”是在前還是在後的 if ((p3[2] < node.p3()[2]) && data.getDisplayName() === '車道指示器1') { data.s('shape3d.image', 'assets/車道信號-禁止.png'); } // 將隧道口的情報板上的文字替換 if(data.getTag() === 'board1') { data.a('limitText', '超車道兩車追尾,請減速慢行'); } }); } } } ], [0.1]); return form; }

     
    總結 伴隨着新基建的建設興起,是以新發展理念為引領,以技術創新為驅動,以信息網絡為基礎,面向高質量發展需要,提供数字轉型、智能升級、融合創新等服務的基礎設施體系的完備,國家正邁入新時代的建設,也迎來了新時代的挑戰與機遇。隧道交通的監控可以歸納為工控管理與智慧交通建設的產物,同樣具有極為重要的意義。在眾多行業上所積累的經驗,HT 已經實現了許多不同領域建設的案例,例如 路口監管可視化系統,有興趣的話也可以了解一下!   2019 我們也更新了數百個工業互聯網 2D/3D 可視化案例集,在這裏你能發現許多新奇的實例,也能發掘出不一樣的工業互聯網: https://mp.weixin.qq.com/s/ZbhB6LO2kBRPrRIfHlKGQA 同時,你也可以查看更多案例及效果: https://www.hightopo.com/demos/index.html 本站聲明:網站內容來源於博客園,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

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

    網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

    ※想知道購買電動車哪裡補助最多?台中電動車補助資訊懶人包彙整

    南投搬家公司費用,距離,噸數怎麼算?達人教你簡易估價知識!

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

    ※超省錢租車方案

  • 掌握SpringBoot-2.3的容器探針:實戰篇

    掌握SpringBoot-2.3的容器探針:實戰篇

    歡迎訪問我的GitHub

    https://github.com/zq2599/blog_demos

    • 內容:原創文章分類匯總,及配套源碼,涉及Java、Docker、K8S、DevOPS等
      經過多篇知識積累終於來到實戰章節,親愛的讀者們,請將裝備就位,一起動手體驗SpringBoot官方帶給我們的最新技術;

    關於《SpringBoot-2.3容器化技術》系列

    • 《SpringBoot-2.3容器化技術》系列,旨在和大家一起學習實踐2.3版本帶來的最新容器化技術,讓咱們的Java應用更加適應容器化環境,在雲計算時代依舊緊跟主流,保持競爭力;
    • 全系列文章分為主題和輔助兩部分,主題部分如下:
    1. 《體驗SpringBoot(2.3)應用製作Docker鏡像(官方方案)》;
    2. 《詳解SpringBoot(2.3)應用製作Docker鏡像(官方方案)》;
    3. 《掌握SpringBoot-2.3的容器探針:基礎篇》;
    4. 《掌握SpringBoot-2.3的容器探針:深入篇》;
    5. 《掌握SpringBoot-2.3的容器探針:實戰篇》;
    • 輔助部分是一些參考資料和備忘總結,如下:
    1. 《SpringBoot-2.3鏡像方案為什麼要做多個layer》;
    2. 《設置非root賬號不用sudo直接執行docker命令》;
    3. 《開發階段,將SpringBoot應用快速部署到K8S》;

    SpringBoot-2.3容器探針知識點小結

    經過前面的知識積累,我們知道了SpringBoot-2.3新增的探針規範以及適用場景,這裏做個簡短的回顧:

    1. kubernetes要求業務容器提供一個名為livenessProbe的地址,kubernetes會定時訪問該地址,如果該地址的返回碼不在200到400之間,kubernetes認為該容器不健康,會殺死該容器重建新的容器,這個地址就是存活探針
    2. kubernetes要求業務容器提供一個名為readinessProbe的地址,kubernetes會定時訪問該地址,如果該地址的返回碼不在200到400之間,kubernetes認為該容器無法對外提供服務,不會把請求調度到該容器,這個地址就是就緒探針
    3. SpringBoot的2.3.0.RELEASE發布了兩個新的actuator地址,/actuator/health/liveness/actuator/health/readiness,前者用作存活探針,後者用作就緒探針,這兩個地址的返回值來自兩個新增的actuator:Liveness StateReadiness State
    4. SpringBoot應用根據特殊環境變量是否存在來判定自己是否運行在容器環境,如果是,/actuator/health/liveness/actuator/health/readiness這兩個地址就有返回碼,具體的值是和應用的狀態有對應關係的,例如應用啟動過程中,/actuator/health/readiness返回503,啟動成功后返回200
    5. 業務應用可以通過Spring系統事件機制來讀取Liveness StateReadiness State,也可以訂閱這兩個actuator的變更事件;
    6. 業務應用可以通過Spring系統事件機制來修改Liveness StateReadiness State,此時/actuator/health/liveness和/actuator/health/readiness的返回值都會發生變更,從而影響kubernetes對此容器的行為(參照第一點和第二點),例如livenessProbe返回碼變成503,導致kubernetes認為容器不健康,從而殺死容器;

    小結完畢,接下來開始實打實的編碼和操作實戰,驗證上述理論;

    實戰環境信息

    本次實戰有兩個環境:開發和運行環境,其中開發環境信息如下:

    1. 操作系統:Ubuntu 20.04 LTS 桌面版
    2. CPU :2.30GHz × 4,內存:32G,硬盤:1T NVMe
    3. JDK:1.8.0_231
    4. MAVEN:3.6.3
    5. SpringBoot:2.3.0.RELEASE
    6. Docker:19.03.10
    7. 開發工具:IDEA 2020.1.1 (Ultimate Edition)

    運行環境信息如下:

    1. 操作系統:CentOS Linux release 7.8.2003
    2. Kubernetes:1.15

    事實證明,用Ubuntu桌面版作為開發環境是可行的,體驗十分順暢,IDEA、SubLime、SSH、Chrome、微信都能正常使用,下圖是我的Ubuntu開發環境:

    實戰內容簡介

    本次實戰包括以下內容:

    1. 開發SpringBoot應用,部署在kubernetes;
    2. 檢查應用狀態和kubernetes的pod狀態的關聯變化;
    3. 修改Readiness State,看kubernetes是否還會把請求調度到pod;
    4. 修改Liveness State,看kubernetes會不是殺死pod;

    源碼下載

    1. 本次實戰用到了一個普通的SpringBoot工程,源碼可在GitHub下載到,地址和鏈接信息如下錶所示(https://github.com/zq2599/blog_demos):
    名稱 鏈接 備註
    項目主頁 https://github.com/zq2599/blog_demos 該項目在GitHub上的主頁
    git倉庫地址(https) https://github.com/zq2599/blog_demos.git 該項目源碼的倉庫地址,https協議
    git倉庫地址(ssh) git@github.com:zq2599/blog_demos.git 該項目源碼的倉庫地址,ssh協議
    1. 這個git項目中有多個文件夾,本章的應用在probedemo文件夾下,如下圖紅框所示:

    開發SpringBoot應用

    1. 請在IDEA上安裝lombok插件:
    1. 在IDEA上新建名為probedemo的SpringBoot工程,版本選擇2.3.0
    1. 該工程的pom.xml內容如下,注意要有spring-boot-starter-actuatorlombok依賴,另外插件spring-boot-maven-plugin也要增加layers節點:
    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
        <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>2.3.0.RELEASE</version>
            <relativePath/> <!-- lookup parent from repository -->
        </parent>
        <groupId>com.bolingcavalry</groupId>
        <artifactId>probedemo</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <name>probedemo</name>
        <description>Demo project for Spring Boot</description>
    
        <properties>
            <java.version>1.8</java.version>
        </properties>
    
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
    
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
                <scope>test</scope>
                <exclusions>
                    <exclusion>
                        <groupId>org.junit.vintage</groupId>
                        <artifactId>junit-vintage-engine</artifactId>
                    </exclusion>
                </exclusions>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-actuator</artifactId>
            </dependency>
    
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
            </dependency>
        </dependencies>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                    <version>2.3.0.RELEASE</version>
                    <!--該配置會在jar中增加layer描述文件,以及提取layer的工具-->
                    <configuration>
                        <layers>
                            <enabled>true</enabled>
                        </layers>
                    </configuration>
                </plugin>
            </plugins>
        </build>
    </project>
    
    1. 應用啟動類ProbedemoApplication是個最普通的啟動類:
    package com.bolingcavalry.probedemo;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    
    @SpringBootApplication
    public class ProbedemoApplication {
        public static void main(String[] args) {
            SpringApplication.run(ProbedemoApplication.class, args);
        }
    }
    
    1. 增加一個監聽類,可以監聽存活和就緒狀態的變化:
    package com.bolingcavalry.probedemo.listener;
    
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.boot.availability.AvailabilityChangeEvent;
    import org.springframework.boot.availability.AvailabilityState;
    import org.springframework.context.event.EventListener;
    import org.springframework.stereotype.Component;
    
    /**
     * description: 監聽系統事件的類 <br>
     * date: 2020/6/4 下午12:57 <br>
     * author: willzhao <br>
     * email: zq2599@gmail.com <br>
     * version: 1.0 <br>
     */
    @Component
    @Slf4j
    public class AvailabilityListener {
    
        /**
         * 監聽系統消息,
         * AvailabilityChangeEvent類型的消息都從會觸發此方法被回調
         * @param event
         */
        @EventListener
        public void onStateChange(AvailabilityChangeEvent<? extends AvailabilityState> event) {
            log.info(event.getState().getClass().getSimpleName() + " : " + event.getState());
        }
    }
    
    1. 增加名為StateReader的Controller的Controller,用於獲取存活和就緒狀態:
    package com.bolingcavalry.probedemo.controller;
    
    import org.springframework.boot.availability.ApplicationAvailability;
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    import javax.annotation.Resource;
    import java.util.Date;
    
    @RestController
    @RequestMapping("/statereader")
    public class StateReader {
    
        @Resource
        ApplicationAvailability applicationAvailability;
    
        @RequestMapping(value="/get")
        public String state() {
            return "livenessState : " + applicationAvailability.getLivenessState()
                   + "<br>readinessState : " + applicationAvailability.getReadinessState()
                   + "<br>" + new Date();
        }
    }
    
    1. 增加名為StateWritter的Controller,用於設置存活和就緒狀態:
    package com.bolingcavalry.probedemo.controller;
    
    import org.springframework.boot.availability.AvailabilityChangeEvent;
    import org.springframework.boot.availability.LivenessState;
    import org.springframework.boot.availability.ReadinessState;
    import org.springframework.context.ApplicationEventPublisher;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import javax.annotation.Resource;
    import java.util.Date;
    
    /**
     * description: 修改狀態的controller <br>
     * date: 2020/6/4 下午1:21 <br>
     * author: willzhao <br>
     * email: zq2599@gmail.com <br>
     * version: 1.0 <br>
     */
    @RestController
    @RequestMapping("/staterwriter")
    public class StateWritter {
    
        @Resource
        ApplicationEventPublisher applicationEventPublisher;
    
        /**
         * 將存活狀態改為BROKEN(會導致kubernetes殺死pod)
         * @return
         */
        @RequestMapping(value="/broken")
        public String broken(){
            AvailabilityChangeEvent.publish(applicationEventPublisher, StateWritter.this, LivenessState.BROKEN);
            return "success broken, " + new Date();
        }
    
        /**
         * 將存活狀態改為CORRECT
         * @return
         */
        @RequestMapping(value="/correct")
        public String correct(){
            AvailabilityChangeEvent.publish(applicationEventPublisher, StateWritter.this, LivenessState.CORRECT);
            return "success correct, " + new Date();
        }
    
        /**
         * 將就緒狀態改為REFUSING_TRAFFIC(導致kubernetes不再把外部請求轉發到此pod)
         * @return
         */
        @RequestMapping(value="/refuse")
        public String refuse(){
            AvailabilityChangeEvent.publish(applicationEventPublisher, StateWritter.this, ReadinessState.REFUSING_TRAFFIC);
            return "success refuse, " + new Date();
        }
    
        /**
         * 將就緒狀態改為ACCEPTING_TRAFFIC(導致kubernetes會把外部請求轉發到此pod)
         * @return
         */
        @RequestMapping(value="/accept")
        public String accept(){
            AvailabilityChangeEvent.publish(applicationEventPublisher, StateWritter.this, ReadinessState.ACCEPTING_TRAFFIC);
            return "success accept, " + new Date();
        }
    
    }
    
    1. 增加名為Hello的controller,此接口能返回當前pod的IP地址,在後面測試時會用到:
    package com.bolingcavalry.probedemo.controller;
    
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import java.net.Inet4Address;
    import java.net.InetAddress;
    import java.net.NetworkInterface;
    import java.net.SocketException;
    import java.util.ArrayList;
    import java.util.Date;
    import java.util.Enumeration;
    import java.util.List;
    
    /**
     * description: hello demo <br>
     * date: 2020/6/4 下午4:38 <br>
     * author: willzhao <br>
     * email: zq2599@gmail.com <br>
     * version: 1.0 <br>
     */
    @RestController
    public class Hello {
    
        /**
         * 返回的是當前服務器IP地址,在k8s環境就是pod地址
         * @return
         * @throws SocketException
         */
        @RequestMapping(value="/hello")
        public String hello() throws SocketException {
            List<Inet4Address> addresses = getLocalIp4AddressFromNetworkInterface();
            if(null==addresses || addresses.isEmpty()) {
                return  "empty ip address, " + new Date();
            }
    
            return addresses.get(0).toString() + ", " + new Date();
        }
    
        public static List<Inet4Address> getLocalIp4AddressFromNetworkInterface() throws SocketException {
            List<Inet4Address> addresses = new ArrayList<>(1);
            Enumeration e = NetworkInterface.getNetworkInterfaces();
            if (e == null) {
                return addresses;
            }
            while (e.hasMoreElements()) {
                NetworkInterface n = (NetworkInterface) e.nextElement();
                if (!isValidInterface(n)) {
                    continue;
                }
                Enumeration ee = n.getInetAddresses();
                while (ee.hasMoreElements()) {
                    InetAddress i = (InetAddress) ee.nextElement();
                    if (isValidAddress(i)) {
                        addresses.add((Inet4Address) i);
                    }
                }
            }
            return addresses;
        }
    
        /**
         * 過濾迴環網卡、點對點網卡、非活動網卡、虛擬網卡並要求網卡名字是eth或ens開頭
         * @param ni 網卡
         * @return 如果滿足要求則true,否則false
         */
        private static boolean isValidInterface(NetworkInterface ni) throws SocketException {
            return !ni.isLoopback() && !ni.isPointToPoint() && ni.isUp() && !ni.isVirtual()
                    && (ni.getName().startsWith("eth") || ni.getName().startsWith("ens"));
        }
    
        /**
         * 判斷是否是IPv4,並且內網地址並過濾迴環地址.
         */
        private static boolean isValidAddress(InetAddress address) {
            return address instanceof Inet4Address && address.isSiteLocalAddress() && !address.isLoopbackAddress();
        }
    }
    

    以上就是該SpringBoot工程的所有代碼了,請確保可以編譯運行;

    製作Docker鏡像

    1. 在pom.xml所在目錄創建文件Dockerfile,內容如下:
    # 指定基礎鏡像,這是分階段構建的前期階段
    FROM openjdk:8u212-jdk-stretch as builder
    # 執行工作目錄
    WORKDIR application
    # 配置參數
    ARG JAR_FILE=target/*.jar
    # 將編譯構建得到的jar文件複製到鏡像空間中
    COPY ${JAR_FILE} application.jar
    # 通過工具spring-boot-jarmode-layertools從application.jar中提取拆分后的構建結果
    RUN java -Djarmode=layertools -jar application.jar extract
    
    # 正式構建鏡像
    FROM openjdk:8u212-jdk-stretch
    WORKDIR application
    # 前一階段從jar中提取除了多個文件,這裏分別執行COPY命令複製到鏡像空間中,每次COPY都是一個layer
    COPY --from=builder application/dependencies/ ./
    COPY --from=builder application/spring-boot-loader/ ./
    COPY --from=builder application/snapshot-dependencies/ ./
    COPY --from=builder application/application/ ./
    ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
    
    1. 先編譯構建工程,執行以下命令:
    mvn clean package -U -DskipTests 
    
    1. 編譯成功后,通過Dockerfile文件創建鏡像:
    sudo docker build -t bolingcavalry/probedemo:0.0.1 .
    
    1. 鏡像創建成功:

    SpringBoot的鏡像準備完畢,接下來要讓kubernetes環境用上這個鏡像;

    將鏡像加載到kubernetes環境

    此時的鏡像保存在開發環境的電腦上,可以有以下三種方式加載到kubernetes環境:

    1. push到私有倉庫,kubernetes上使用時也從私有倉庫獲取;
    2. push到hub.docker.com,kubernetes上使用時也從hub.docker.com獲取,目前我已經將此鏡像push到hub.docker.com,您在kubernetes直接使用即可,就像nginx、tomcat這些官方鏡像一樣下載;
    3. 在開發環境執行docker save bolingcavalry/probedemo:0.0.1 > probedemo.tar,可將此鏡像另存為本地文件,再scp到kubernetes服務器,再在kubernetes服務器執行docker load < /root/temp/202006/04/probedemo.tar就能加載到kubernetes服務器的本地docker緩存中;

    以上三種方法的優缺點整理如下:

    1. 首推第一種,但是需要您搭建私有倉庫;
    2. 由於springboot-2.3官方對鏡像構建作了優化,第二種方法也就執行第一次的時候上傳和下載很耗時,之後修改java代碼重新構建時,不論上傳還是下載都很快(只上傳下載某個layer);
    3. 在開發階段,使用第三種方法最為便捷,但如果kubernetes環境有多台機器,就不合適了,因為鏡像是存在指定機器的本地緩存的;

    我的kubernetes環境只有一台電腦,因此用的是方法三,參考命令如下(建議安裝sshpass,就不用每次輸入帳號密碼了):

    # 將鏡像保存為tar文件
    sudo docker save bolingcavalry/probedemo:0.0.1 > probedemo.tar
    
    # scp到kubernetes服務器
    sshpass -p 888888 scp ./probedemo.tar root@192.168.50.135:/root/temp/202006/04/ 
      
    # 遠程執行ssh命令,加載docker鏡像
    sshpass -p 888888 ssh root@192.168.50.135 "docker load < /root/temp/202006/04/probedemo.tar"
    

    kubernetes部署deployment和service

    1. 在kubernetes創建名為probedemo.yaml的文件,內容如下,注意pod副本數是2,另外請關注livenessProbe和readinessProbe的參數配置:
    apiVersion: v1
    kind: Service
    metadata:
      name: probedemo
    spec:
      type: NodePort
      ports:
        - port: 8080
          nodePort: 30080
      selector:
        name: probedemo
    ---
    apiVersion: extensions/v1beta1
    kind: Deployment
    metadata:
      name: probedemo
    spec:
      replicas: 2
      template:
        metadata:
          labels:
            name: probedemo
        spec:
          containers:
            - name: probedemo
              image: bolingcavalry/probedemo:0.0.1
              tty: true
              livenessProbe:
                httpGet:
                  path: /actuator/health/liveness
                  port: 8080
                initialDelaySeconds: 5
                failureThreshold: 10
                timeoutSeconds: 10
                periodSeconds: 5
              readinessProbe:
                httpGet:
                  path: /actuator/health/readiness
                  port: 8080
                initialDelaySeconds: 5
                timeoutSeconds: 10
                periodSeconds: 5
              ports:
                - containerPort: 8080
              resources:
                requests:
                  memory: "512Mi"
                  cpu: "100m"
                limits:
                  memory: "1Gi"
                  cpu: "500m"
    
    1. 執行命令kubectl apply -f probedemo..yaml,即可創建deployment和service:
    1. 這裏要重點關注的是livenessProbeinitialDelaySecondsfailureThreshold參數,initialDelaySeconds等於5,表示pod創建5秒后檢查存活探針,如果10秒內應用沒有完成啟動,存活探針不返回200,就會重試10次(failureThreshold等於10),如果重試10次后存活探針依舊無法返回200,該pod就會被kubernetes殺死重建,要是每次啟動都耗時這麼長,pod就會不停的被殺死重建;
    2. 執行命令kubectl apply -f probedemo.yaml,創建deployment和service,如下圖,可見在第十秒的時候pod創建成功,但是此時還未就緒:
    1. 繼續查看狀態,創建一分鐘后兩個pod終於就緒:
    1. kubectl describe命令查看pod狀態,事件通知显示存活和就緒探針都有失敗情況,不過因為有重試,因此後來狀態會變為成功:

    至此,從編碼到部署都完成了,接下來驗證SpringBoot-2.3.0.RELEASE的探針技術;

    驗證SpringBoot-2.3.0.RELEASE的探針技術

    1. 監聽類AvailabilityListener的作用是監聽狀態變化,看看pod日誌,看AvailabilityListener的代碼是否有效,如下圖紅框,在應用啟動階段AvailabilityListener被成功回調,打印了存活和就緒狀態:
    1. kubernetes所在機器的IP地址是192.168.50.135,因此SpringBoot服務的訪問地址是http://192.168.50.135:30080/xxx

    2. 訪問地址http://192.168.50.135:30080/actuator/health/liveness,返回碼如下圖紅框,可見存活探針已開啟:

    1. 就緒探針也正常:
    1. 打開兩個瀏覽器,都訪問:http://192.168.50.135:30080/hello,多次Ctrl+F5強刷,如下圖,很快就能得到不同結果,證明響應來自不同的Pod:
    1. 訪問:http://192.168.50.135:30080/statereader/get,可以得到存活和就緒的狀態,可見StateReader的代碼已經生效,可以通過ApplicationAvailability接口取得狀態:
    1. 修改就緒狀態,訪問:http://192.168.50.135:30080/statewriter/refuse,如下圖紅框,可見收到請求的pod,其就緒狀態已經出現了異常,證明StateWritter.java中修改就緒狀態后,可以讓kubernetes感知到這個pod的異常
    1. 用瀏覽器反覆強刷hello接口,返回的Pod地址也只有一個,證明只有一個Pod在響應請求:
    1. 嘗試恢復服務,注意請求要在服務器後台發送,而且IP地址要用剛才被設置為refuse的pod地址
    curl http://10.233.90.195:8080/statewriter/accept
    
    1. 如下圖,狀態已經恢復:
    1. 最後再來試試將存活狀態從CORRECT改成BROKEN,瀏覽器訪問:http://192.168.50.135:30080/statewriter/broken
    2. 如下圖紅框,重啟次數變成1,表示pod被殺死了一次,並且由於重啟導致當前還未就緒,證明在SpringBoot中修改了存活探針的狀態,是會觸發kubernetes殺死pod的
    1. 等待pod重啟、就緒探針正常后,一切恢復如初:
    1. 強刷瀏覽器,如下圖紅框,兩個Pod都能正常響應:

    官方忠告

    • 至此,《掌握SpringBoot-2.3的容器探針》系列就全部完成了,從理論到實踐,咱們一起學習了SpringBoot官方帶給我們的容器化技術,最後以一段官方忠告來結尾,大家一起將此忠告牢記在心:
    • 我對以上內容的理解:選擇外部系統的服務作為探針的時候要謹慎(外部系統可能是數據庫,也可能是其他web服務),如果外部系統出現問題,會導致kubernetes殺死pod(存活探針問題),或者導致kubernetes不再調度請求到pod(就緒探針問題);(再請感謝大家容忍我的英語水平)

    歡迎關注我的公眾號:程序員欣宸

    https://github.com/zq2599/blog_demos

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

    【其他文章推薦】

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

    網頁設計公司推薦不同的風格,搶佔消費者視覺第一線

    ※Google地圖已可更新顯示潭子電動車充電站設置地點!!

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

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

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

  • 不到15萬,軸距超過3米還有誰!

    不到15萬,軸距超過3米還有誰!

    而依維柯的中控 則採用了深灰色的內飾配色,整體看起來略微有些沉悶。並且空調的出風口以及手套箱的設計相對過於緊湊,讓人感覺整个中控台略微有些凌亂。四輻式的方向盤造型也難以提升駕駛員的駕駛興趣。馬兒跑得快還要不吃草作為全新一代輕客的代表之作,光有光鮮的外表、時尚的造型可不夠,選擇這類車型的消費者們更為看重的必然是它們的內在能力。

    拼家底誰更厚?

    全順:全順在輕客領域摸爬滾打也有將近五十年歷史了。它的原型車可以追溯到1953年福特生產的一款輕型商用車FK1000,它於1961被稱作Ford Taunus Transit。1965年,福特汽車英國公司推出第一代福特Transit全順,迅速取得了輕型貨車領域的主導地位,並在此後一直霸佔着歐洲輕型客貨車銷量冠軍,成為了輕型客貨車的代名詞。

    依維柯:作為歐洲的輕型商用車之一,依維柯在歐洲市場佔據很大的市場份額。在1975年的時候依維柯公司正式成立,1978年第一代依維柯Daily誕生。並且一直發展到今日,依維柯一共經歷了六次換代。

    誰才是真正的“顏值帝”

    外觀設計各花入各眼,但就目前的眼光來看,新全順外觀設計相比起老款可謂是翻天覆地,時尚動感的外型設計,微微收緊的車頭、熏黑的大燈以及一條筆直斜向上一直延伸到車尾的線條設計,彷彿讓新全順一躍跳出了輕客這個領域。

    而依維柯則是傳統的造型設計,菱形的前大燈以及方方正正的車頭設計讓它很難與時尚設計扯上關係,保持了輕客一貫的傳統印象,但沒能給人眼前一亮的感覺。

    內飾誰更前衛

    新全順的內飾設計採用了福特最新家族的設計語言,非對稱式的中控設計,外加黑色內飾加銀色鍍鉻配色,整體顯得更有檔次感,中控面板和門把手對於細節的處理也非常細緻,非常符合新全順的車型定位。

    而依維柯的中控 則採用了深灰色的內飾配色,整體看起來略微有些沉悶。並且空調的出風口以及手套箱的設計相對過於緊湊,讓人感覺整个中控台略微有些凌亂。四輻式的方向盤造型也難以提升駕駛員的駕駛興趣。

    馬兒跑得快還要不吃草

    作為全新一代輕客的代表之作,光有光鮮的外表、時尚的造型可不夠,選擇這類車型的消費者們更為看重的必然是它們的內在能力。就這方面來看,全順的表現還是極為優秀的。從動力上看新全順所搭載的2.0T柴油渦輪增壓發動機雖在排量上比起依維柯的2.5T渦輪發動機稍顯劣勢,但由於全順採用了新的渦輪技術使得在排量吃虧的情況下,依然在能在功率上追上對手,並且在扭矩上還略微有優勢。

    江鈴福特-全順

    南京依維柯-power Daily

    【聽宣判pK結果】:兩位老對手的定位雖然相近,但是結合兩輛車的表現,新全順更加出色,在各個項目的表現上都要更勝一籌。而依維柯則保留了純粹的商用車氣息,缺乏了一點新時代的便利性以及通用性。本站聲明:網站內容來源於http://www.auto6s.com/,如有侵權,請聯繫我們,我們將及時處理

    【其他文章推薦】

    ※為什麼 USB CONNECTOR 是電子產業重要的元件?

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

    ※台北網頁設計公司全省服務真心推薦

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

    ※推薦評價好的iphone維修中心