如何優雅地停止 Spring Boot 應用?

首先來介紹下什麼是優雅地停止,簡而言之,就是對應用進程發送停止指令之後,能保證正在執行的業務操作不受影響,可以繼續完成已有請求的處理,但是停止接受新請求

在 Spring Boot 2.3 中增加了新特性優雅停止,目前 Spring Boot 內置的四個嵌入式 Web 服務器(Jetty、Reactor Netty、Tomcat 和 Undertow)以及反應式和基於 Servlet 的 Web 應用程序都支持優雅停止。

下面,我們先用新版本嘗試下:

Spring Boot 2.3 優雅停止

首先創建一個 Spring Boot 的 Web 項目,版本選擇 2.3.0.RELEASE,Spring Boot 2.3.0.RELEASE 版本內置的 Tomcat 為 9.0.35

然後需要在 application.yml 中添加一些配置來啟用優雅停止的功能:

# 開啟優雅停止 Web 容器,默認為 IMMEDIATE:立即停止
server:
  shutdown: graceful

# 最大等待時間
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

其中,平滑關閉內置的 Web 容器(以 Tomcat 為例)的入口代碼在 org.springframework.boot.web.embedded.tomcatGracefulShutdown 里,大概邏輯就是先停止外部的所有新請求,然後再處理關閉前收到的請求,有興趣的可以自己去看下。

內嵌的 Tomcat 容器平滑關閉的配置已經完成了,那麼如何優雅關閉 Spring 容器了,就需要 Actuator 來實現 Spring 容器的關閉了。

然後加入 actuator 依賴,依賴如下所示:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

然後接着再添加一些配置來暴露 actuator 的 shutdown 接口:

# 暴露 shutdown 接口
management:
  endpoint:
    shutdown:
      enabled: true
  endpoints:
    web:
      exposure:
        include: shutdown

其中通過 Actuator 關閉 Spring 容器的入口代碼在 org.springframework.boot.actuate.context 包下 ShutdownEndpoint 類中,主要的就是執行 doClose() 方法關閉並銷毀 applicationContext,有興趣的可以自己去看下。

配置搞定后,然後在 controller 包下創建一個 WorkController 類,並有一個 work 方法,用來模擬複雜業務耗時處理流程,具體代碼如下:

@RestController
public class WorkController {

    @GetMapping("/work")
    public String work() throws InterruptedException {
        // 模擬複雜業務耗時處理流程
        Thread.sleep(10 * 1000L);
        return "success";
    }
}

然後,我們啟動項目,先用 Postman 請求 http://localhost:8080/work 處理業務:

然後在這個時候,調用 http://localhost:8080/actuator/shutdown 就可以執行優雅地停止,返回結果如下:

{
    "message": "Shutting down, bye..."
}

如果在這個時候,發起新的請求 http://localhost:8080/work,會沒有反應:

再回頭看第一個請求,返回了結果:success

其中有幾條服務日誌如下:

2020-05-20 23:05:15.163  INFO 102724 --- [     Thread-253] o.s.b.w.e.tomcat.GracefulShutdown        : Commencing graceful shutdown. Waiting for active requests to complete
2020-05-20 23:05:15.287  INFO 102724 --- [tomcat-shutdown] o.s.b.w.e.tomcat.GracefulShutdown        : Graceful shutdown complete
2020-05-20 23:05:15.295  INFO 102724 --- [     Thread-253] o.s.s.concurrent.ThreadPoolTaskExecutor  : Shutting down ExecutorService 'applicationTaskExecutor'

從日誌中也可以看出來,當調用 shutdown 接口的時候,會先等待請求處理完畢后再優雅地停止。

到此為止,Spring Boot 2.3 的優雅關閉就講解完了,是不是很簡單呢?如果是在之前不支持優雅關閉的版本如何去做呢?

Spring Boot 舊版本優雅停止

在這裏介紹 GitHub 上 issue 里 Spring Boot 開發者提供的一種方案:

選取的 Spring Boot 版本為 2.2.6.RELEASE,首先要實現 TomcatConnectorCustomizer 接口,該接口是自定義 Connector 的回調接口:

@FunctionalInterface
public interface TomcatConnectorCustomizer {

	void customize(Connector connector);
}

除了定製 Connector 的行為,還要實現 ApplicationListener<ContextClosedEvent> 接口,因為要監聽 Spring 容器的關閉事件,即當前的 ApplicationContext 執行 close() 方法,這樣我們就可以在請求處理完畢後進行 Tomcat 線程池的關閉,具體的實現代碼如下:

@Bean
public GracefulShutdown gracefulShutdown() {
    return new GracefulShutdown();
}

private static class GracefulShutdown implements TomcatConnectorCustomizer, ApplicationListener<ContextClosedEvent> {
    private static final Logger log = LoggerFactory.getLogger(GracefulShutdown.class);

    private volatile Connector connector;

    @Override
    public void customize(Connector connector) {
        this.connector = connector;
    }

    @Override
    public void onApplicationEvent(ContextClosedEvent event) {
        this.connector.pause();
        Executor executor = this.connector.getProtocolHandler().getExecutor();
        if (executor instanceof ThreadPoolExecutor) {
            try {
                ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor;
                threadPoolExecutor.shutdown();
                if (!threadPoolExecutor.awaitTermination(30, TimeUnit.SECONDS)) {
                    log.warn("Tomcat thread pool did not shut down gracefully within 30 seconds. Proceeding with forceful shutdown");
                }
            } catch (InterruptedException ex) {
                Thread.currentThread().interrupt();
            }
        }
    }
}

有了定製的 Connector 回調,還需要在啟動過程中添加到內嵌的 Tomcat 容器中,然後等待監聽到關閉指令時執行,addConnectorCustomizers 方法可以把定製的 Connector 行為添加到內嵌的 Tomcat 中,具體代碼如下:

@Bean
public ConfigurableServletWebServerFactory tomcatCustomizer() {
    TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory();
    factory.addConnectorCustomizers(gracefulShutdown());
    return factory;
}

到此為止,內置的 Tomcat 容器平滑關閉的操作就完成了,Spring 容器優雅停止上面已經說過了,再次就不再贅述了。

通過測試,同樣可以達到上面那樣優雅停止的效果。

總結

本文主要講解了 Spring Boot 2.3 版本和舊版本的優雅停止,避免強制停止導致正在處理的業務邏輯會被中斷,進而導致產生業務異常的情形。

另外使用 Actuator 的同時要注意安全問題,比如可以通過引入 security 依賴,打開安全限制並進行身份驗證,設置單獨的 Actuator 管理端口並配置只對內網開放等。

本文的完整代碼在 https://github.com/wupeixuan/SpringBoot-Learngraceful-shutdown 目錄下。

最好的關係就是互相成就,大家的在看、轉發、留言三連就是我創作的最大動力。

參考

https://github.com/spring-projects/spring-boot/issues/4657

https://github.com/wupeixuan/SpringBoot-Learn

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

【其他文章推薦】

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

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

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

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

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

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

※回頭車貨運收費標準

MySQL查詢優化利刃-EXPLAIN

有一個 ?

遇到這樣一個疑問:當where查詢中In一個索引字段作為條件,那麼在查詢中還會使用到索引嗎?

SELECT * FROM table_name WHERE column_index in (expr)

上面的sql語句檢索會使用到索引嗎?帶着這個問題,在網上查找了很多文章,但是有的說 in 會導致放棄索引,全表掃描;有的說Mysql5.5之前的版本不會走,之後的innodb版本會走索引…

越看越迷糊,那答案到底是怎樣的呢?

唯有實踐是檢驗真理的唯一方式!

拿出我們的利刃——EXPLAIN,去剖析 SELECT 語句,一探究竟!

EXPLAIN 的用法

在 SELECT 語句前加上 EXPLAIN 就可以了 ,例如:

EXPLAIN SELECT * FROM table_name [WHERE Clause]

EXPLAIN 的輸出

EXPLAIN 命令的輸出內容為一個表格形式,表的每一個字段含義如下:

列名 解釋
id SELECT 查詢的標識符. 每個 SELECT 都會自動分配一個唯一的標識符
select_type SELECT 查詢的類型
table 查詢的是哪個表
partitions 匹配的分區
type join 類型
possible_keys 此次查詢中可能選用的索引
key 此次查詢中確切使用到的索引
ref 哪個字段或常數與 key 一起被使用;與索引比較的列
rows 显示此查詢一共掃描了多少行, 這個是一個估計值
filtered 表示此查詢條件所過濾的數據的百分比
extra 額外的信息
select_type
查詢類型 解釋
SIMPLE 表示此查詢不包含 UNION 查詢或子查詢
PRIMARY 表示此查詢是最外層的查詢
UNION 表示此查詢是 UNION 的第二或隨後的查詢
DEPENDENT UNION UNION 中的第二個或後面的查詢語句, 取決於外面的查詢
UNION RESULT UNION 的結果
SUBQUERY 子查詢中的第一個 SELECT
DEPENDENT SUBQUERY 子查詢中的第一個 SELECT,取決於外面的查詢。子查詢依賴於外層查詢的結果
MATERIALIZED Materialized subquery
table

表示查詢涉及的表或衍生表 。 這也可以是以下值之一:

  • <unionM,N>:該行指的是具有和id值的行 的 M並集 N。
  • :該行是指用於與該行的派生表結果id的值 N。派生表可能來自FROM子句中的子查詢 。
  • :該行是指該行的物化子查詢的結果,其id 值為N。
partitions

查詢將匹配記錄的分區。該值適用NULL於未分區的表。

type

聯接類型。 提供了判斷查詢是否高效的重要依據依據。通過 type 字段,我們判斷此次查詢是全表掃描還是索引掃描等。 從最佳類型到最差類型:

  • system: 該表只有一行(=系統表)。這是const聯接類型的特例 。

  • const: 針對主鍵或唯一索引的等值查詢掃描,最多只返回一行數據。const 查詢速度非常快,因為它僅僅讀取一次即可 。

    SELECT * FROM tbl_name WHERE primary_key=1;
    
    SELECT * FROM tbl_name
      WHERE primary_key_part1=1 AND primary_key_part2=2;
    
  • eq_ref: 此類型通常出現在多表的 join 查詢,表示對於前表的每一個結果,都只能匹配到后表的一行結果。並且查詢的比較操作通常是 =,查詢效率較高

    SELECT * FROM ref_table,other_table
      WHERE ref_table.key_column=other_table.column;
    
    SELECT * FROM ref_table,other_table
      WHERE ref_table.key_column_part1=other_table.column
      AND ref_table.key_column_part2=1;
    
  • ref : 此類型通常出現在多表的 join 查詢,針對於非唯一或非主鍵索引,或者是使用了最左前綴規則索引的查詢。ref可以用於使用=或<=> 運算符進行比較的索引列。

    SELECT * FROM ref_table WHERE key_column=expr;
    
    SELECT * FROM ref_table,other_table
      WHERE ref_table.key_column=other_table.column;
    
    SELECT * FROM ref_table,other_table
      WHERE ref_table.key_column_part1=other_table.column
      AND ref_table.key_column_part2=1;
    
  • ref_or_null: 這種連接類型類似於 ref,但是除了MySQL會額外搜索包含NULL值的行。此聯接類型優化最常用於解析子查詢。

    SELECT * FROM ref_table
      WHERE key_column=expr OR key_column IS NULL;
    
  • unique_subquery: 只是一個索引查找函數,它完全替代了子查詢以提高效率。

    value IN (SELECT primary_key FROM single_table WHERE some_expr)
    
  • index_subquery:此連接類型類似於 unique_subquery。它代替IN子查詢,但適用於以下形式的子查詢中的非唯一索引。

  • range: 表示使用索引範圍查詢, 通過索引字段範圍獲取表中部分數據記錄。這個類型通常出現在 =,<>,>,>=,<,<=,IS NULL,<=>,BETWEEN,IN() 操作中。

    當 type 是 range 時,那麼 EXPLAIN 輸出的 ref 字段為 NULL,並且 key_len 字段是此次查詢中使用到的索引的最長的那個 。

    SELECT * FROM tbl_name
      WHERE key_column = 10;
    
    SELECT * FROM tbl_name
      WHERE key_column BETWEEN 10 and 20;
    
    SELECT * FROM tbl_name
      WHERE key_column IN (10,20,30);
    
    SELECT * FROM tbl_name
      WHERE key_part1 = 10 AND key_part2 IN (10,20,30);
    
  • index: 表示全索引掃描(full index scan)和 ALL 類型類似,只不過 ALL 類型是全表掃描,而 index 類型則僅僅掃描所有的索引,而不掃描數據。

    index 類型通常出現在: 所要查詢的數據直接在索引樹中就可以獲取到,而不需要掃描數據。當是這種情況時,Extra 字段 會显示 Using index

  • ALL: 表示全表掃描,這個類型的查詢是性能最差的查詢之一。

    我們的查詢不應該出現 ALL 類型的查詢,因為這樣的查詢在數據量大的情況下,對數據庫的性能是巨大的災難。如一個查詢是 ALL 類型查詢,那麼一般來說可以對相應的字段添加索引來避免 。

possible_keys

表示 MySQL 在查詢時,能夠使用到的索引。

即使有些索引在 possible_keys 中出現,但是並不表示此索引會真正地被 MySQL 使用到。MySQL 在查詢時具體使用了哪些索引,由 key 字段決定。

key

是 MySQL 在當前查詢時所真正使用到的索引。

key_len

表示查詢優化器使用了索引的字節數。

這個字段可以評估組合索引是否完全被使用,或只有最左部分字段被使用到。key_len 的計算規則如下:

  • 字符串
    • char(n): n 字節長度
    • varchar(n): 如果是 utf8 編碼, 則是 3n + 2字節; 如果是 utf8mb4 編碼, 則是 4n + 2 字節
  • 數值類型
  • TINYINT: 1字節
  • SMALLINT: 2字節
  • MEDIUMINT: 3字節
  • INT: 4字節
  • BIGINT: 8字節
  • 時間類型
  • DATE: 3字節
  • TIMESTAMP: 4字節
  • DATETIME: 8字節
  • 字段屬性: NULL 屬性 佔用一個字節。如果一個字段是 NOT NULL 的, 則沒有此屬性
rows

查詢優化器根據統計信息,估算 SQL 要查找到結果集需要掃描讀取的數據行數。這個值非常直觀显示 SQL 的效率好壞,原則上 rows 越少越好。

這個 rows 就是 mysql 認為必須要逐行去檢查和判斷的記錄的條數。舉個例子來說,假如有一個語句 select * from t where column_a = 1 and column_b = 2; 全表假設有 100 條記錄,column_a 字段有索引(非聯合索引),column_b沒有索引。column_a = 1 的記錄有 20 條, column_a = 1 and column_b = 2 的記錄有 5 條。

Extra

EXplain 中的很多額外的信息會在 Extra 字段显示,常見的有以下幾種內容:

  • Using filesort:當 Extra 中有 Using filesort 時,表示 MySQL 需額外的排序操作,不能通過索引順序達到排序效果。一般有 Using filesort,都建議優化去掉,因為這樣的查詢 CPU 資源消耗大。
  • Using index:”覆蓋索引掃描”,表示查詢在索引樹中就可查找所需數據,不用掃描表數據文件,往往說明性能不錯
  • Using temporary:查詢有使用臨時表,一般出現於排序,分組和多表 join 的情況,查詢效率不高,建議優化
  • Using where: WHERE子句用於限制哪些行與下一個表匹配或發送給客戶端 。

得出結論

說到最後,那 WHERE column_index in (expr) 到底走不走索引呢? 答案是不確定的。

走不走索引是由 expr 來決定的,不是一概而論走還是不走。

SELECT * FROM a WHERE id in (1,23,456,7,8)
-- id 是主鍵,查詢是走索引的。type = range,key = PRIMARY
SELECT * FROM a WHERE id in (SELECT b.a_id FROM b WHERE some_expr)
-- id 是主鍵,如果 some_expr 是一個索引查詢,那麼 select a 將走索引;
-- some_expr 不是索引查詢,那麼 select a 將全表掃描;

上面是兩個通用案例,但到底對不對了,還是自己去實踐最好了,拿起EXPLAIN去剖析吧~

參考文章: https://dev.mysql.com/doc/refman/5.7/en/explain-output.html#explain

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

【其他文章推薦】

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

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

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

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

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

※回頭車貨運收費標準

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

聊聊Asp.net過濾器Filter那一些事

  最近在整理優化.net代碼時,發現幾個很不友好的處理現象:登錄判斷、權限認證、日誌記錄、異常處理等通用操作,在項目中的action中到處都是。在代碼優化上,這一點是很重要着力點。這時.net中的過濾器、攔截器(Filter)就派上用場了。現在根據這幾天的實際工作,對其做了一個簡單的梳理,分享出來,以供大家參考交流,如有寫的不妥之處,多多指出,多多交流。

概述:

.net中的Filter中主要包括以下4大類:Authorize(授權),ActionFilter(自定義),HandleError(錯誤處理)。

過濾器

類名

實現接口

描述

授權

AuthorizeAttribute

IAuthorizationFilter

此類型(或過濾器)用於限制進入控制器或控制器的某個行為方法,比如:登錄、權限、訪問控制等等

異常

HandleErrorAttribute

IExceptionFilter

用於指定一個行為,這個被指定的行為處理某個行為方法或某個控制器裏面拋出的異常,比如:全局異常統一處理。

自定義

ActionFilterAttribute

IActionFilterIResultFilter

用於進入行為之前或之後的處理或返回結果的之前或之後的處理,比如:用戶請求日誌詳情日誌記錄

 

AuthorizeAttribute:認證授權

認證授權主要是對所有action的訪問第一入口認證,對用戶的訪問做第一道監管過濾攔截閘口。

實現方式:需要自定義一個類,繼承AuthorizeAttribute並重寫OnAuthorization,在OnAuthorization中能夠獲取到用戶請求的所有Request信息,其實我們做的所有認證攔截操作,其所有數據支撐都是來自Request中。

具體驗證流程設計:

IP白名單:這個主要針對的是API做IP限制,只有指定IP才可訪問,非指定IP直接返回

請求頻率控制:這個主要是控制用戶的訪問頻率,主要是針對API做,超出請求頻率直接返回。

登錄認證:登錄認證一般我們採用的是通過在請求的header中傳遞token的方式來進行驗證,這樣即使用與一般的MVC登錄認證,也使用與API接口的Auth認證,並且也不依賴於用戶前端js設置等。

授權認證:授權認證就簡單了,主要是驗證該用戶是否具有該權限,如果不具有,直接做下相應的返回處理。

MVC和API異同:

  命名空間:MVC:System.Web.Http.Filters;API:System.Web.Mvc

  注入方式:在注入方式上,主要包括:全局->控制器Controller->行為Action

  全局註冊:針對所有系統的所有Aciton都使用

  Controller:只針對該Controller下的Action起作用

  Action:只針對該Action起作用

其中全局註冊,針對MVC和API還有一些差異:

  MVC在 FilterConfig.cs中注入
    filters.Add(new XYHMVCAuthorizeAttribute());

  API 在 WebApiConfig.cs 中注入

     config.Filters.Add(new XYHAPIAuthorizeAttribute());

注意事項:在實際使用中,針對認證授權,我們一般都是添加全局認證,但是,有的action又不需要做認證,比如本來的登錄Action等等,那麼該如何排除呢?其實也很簡單,我們只需要在自定定義一個Attribute集成Attribute,或者系統的AllowAnonymousAttribute,在不需要驗證的action中只需要註冊上對於的Attribute,並在驗證前做一個過濾即可,比如:

    // 有 AllowAnonymous 屬性的接口直接開綠燈

            if (actionContext.ActionDescriptor.GetCustomAttributes<AllowAnonymousAttribute>().Any())

            {

                return;

            }

API AuthFilterAttribute實例代碼

/// <summary>
    /// 授權認證過濾器
    /// </summary>
    public class XYHAPIAuthFilterAttribute : AuthorizationFilterAttribute
    {
        /// <summary>
        /// 認證授權驗證
        /// </summary>
        /// <param name="actionContext">請求上下文</param>
        public override void OnAuthorization(HttpActionContext actionContext)
        {
            // 有 AllowAnonymous 屬性的接口直接開綠燈
            if (actionContext.ActionDescriptor.GetCustomAttributes<AllowAnonymousAttribute>().Any())
            {
                return;
            }

            // 在請求前做一層攔截,主要驗證token的有效性和驗簽
            HttpRequest httpRequest = HttpContext.Current.Request;

            // 獲取apikey
            var apikey = httpRequest.QueryString["apikey"];

            // 首先做IP白名單校驗 
            MBaseResult<string> result = new AuthCheckService().CheckIpWhitelist(FilterAttributeHelp.GetIPAddress(actionContext.Request), apikey);

            // 檢驗時間戳
            string timestamp = httpRequest.QueryString["Timestamp"];
            if (result.Code == MResultCodeEnum.successCode)
            {
                // 檢驗時間戳 
                result = new AuthCheckService().CheckTimestamp(timestamp);
            }

            if (result.Code == MResultCodeEnum.successCode)
            {
                // 做請求頻率驗證 
                string acitonName = actionContext.ActionDescriptor.ActionName;
                string controllerName = actionContext.ActionDescriptor.ControllerDescriptor.ControllerName;
                result = new AuthCheckService().CheckRequestFrequency(apikey, $"api/{controllerName.ToLower()}/{acitonName.ToLower()}");
            }

            if (result.Code == MResultCodeEnum.successCode)
            {
                // 簽名校驗

                // 獲取全部的請求參數
                Dictionary<string, string> queryParameters = httpRequest.GetAllQueryParameters();

                result = new AuthCheckService().SignCheck(queryParameters, apikey);

                if (result.Code == MResultCodeEnum.successCode)
                {
                    // 如果有NoChekokenFilterAttribute 標籤 那麼直接不做token認證
                    if (actionContext.ActionDescriptor.GetCustomAttributes<XYHAPINoChekokenFilterAttribute>().Any())
                    {
                        return;
                    }

                    // 校驗token的有效性
                    // 獲取一個 token
                    string token = httpRequest.Headers.GetValues("Token") == null ? string.Empty :
                        httpRequest.Headers.GetValues("Token")[0];

                    result = new AuthCheckService().CheckToken(token, apikey, httpRequest.FilePath);
                }
            }

            // 輸出
            if (result.Code != MResultCodeEnum.successCode)
            {
                // 一定要實例化一個response,是否最終還是會執行action中的代碼
                actionContext.Response = new HttpResponseMessage(HttpStatusCode.OK);
                //需要自己指定輸出內容和類型
                HttpContext.Current.Response.ContentType = "text/html;charset=utf-8";
                HttpContext.Current.Response.Write(JsonConvert.SerializeObject(result));
                HttpContext.Current.Response.End(); // 此處結束響應,就不會走路由系統
            }
        }
    }

 

 MVC AuthFilterAttribute實例代碼

 

/// <summary>
    /// MVC自定義授權
    /// 認證授權有兩個重寫方法
    /// 具體的認證邏輯實現:AuthorizeCore 這個裡面寫具體的認證邏輯,認證成功返回true,反之返回false
    /// 認證失敗處理邏輯:HandleUnauthorizedRequest 前一步返回 false時,就會執行到該方法中
    /// 但是,我平時在應用過程中,一般都是在AuthorizeCore根據不同的認證結果,直接做認證后的邏輯處理
    /// </summary>
    public class XYHMVCAuthorizeAttribute : AuthorizeAttribute
    {
        /// <summary>
        /// 認證邏輯
        /// </summary>
        /// <param name="filterContext">過濾器上下文</param>
        public override void OnAuthorization(AuthorizationContext filterContext)
        {

            // 此處主要寫認證授權的相關驗證邏輯
            // 該部分的驗證一般包括兩個部分
            // 登錄權限校驗
            //   --我們的一般處理方式是,通過header中傳遞一個token來進行邏輯驗證
            //   --當然不同的系統在設計上也不盡相同,有的也會採用session等方式來驗證
            //   --所以最終還是根據其項目本身的實際情況來進行對應的邏輯操作

            // 具體的頁面權限校驗
            // --該部分的驗證是具體的到頁面權限驗證
            // --我看有得小夥伴沒有做到這一個程度,直接將這一步放在前端js來驗證,這樣不是很安全,但是可以攔住小白用戶
            // --當然有的系統根本就沒有做權限控制,那就更不需要這一個邏輯了。
            // --所以最終還是根據其項目本身的實際情況來進行對應的邏輯操作

            // 現在用一個粗暴的方式來簡單模擬實現過,用系統當前時間段秒廚藝3,取餘數
            // 當餘數為0:認證授權通過
            //         1:代表為登錄,調整至登錄頁面
            //         2:代表無訪問權限,調整至無權限提示頁面

            // 當然,在這也還可以做一些IP白名單,IP黑名單驗證  請求頻率驗證等等

            // 說到這而,還有一點需要注意,如果我們選擇的是全局註冊該過濾器,那麼如果有的頁面根本不需要權限認證,比如登錄頁面,那麼我們可以給不需要權限的認證的控制器或者action添加一個特殊的註解 AllowAnonymous ,來排除

            // 獲取Request的幾個關鍵信息
            HttpRequest httpRequest = HttpContext.Current.Request;
            string acitonName = filterContext.ActionDescriptor.ActionName;
            string controllerName = filterContext.ActionDescriptor.ControllerDescriptor.ControllerName;

            // 注意:如果認證不通過,需要設置filterContext.Result的值,否則還是會執行action中的邏輯

            filterContext.Result = null;
            int thisSecond = System.DateTime.Now.Second;
            switch (thisSecond % 3)
            {
                case 0:
                    // 認證授權通過
                    break;
                case 1:
                    // 代表為登錄,調整至登錄頁面
                    // 只有設置了Result才會終結操作
                    filterContext.Result = new RedirectResult("/html/Login.html");
                    break;
                case 2:
                    // 代表無訪問權限,調整至無權限提示頁面
                    filterContext.Result = new RedirectResult("/html/NoAuth.html");
                    break;
            }
        }
    }

 

ActionFilter自定義過濾器

自定義過濾器,主要是監控action請求前後,處理結果返回前後的事件。其中API只有請求前後的兩個方法。

重新方法

方法功能描述

使用於

OnActionExecuting

一個請求在進入到aciton邏輯前執行

MVCAPI

OnActionExecuted

一個請求aciton邏輯執行后執行

MVCAPI

OnResultExecuting

對應的view視圖渲染前執行

MVC

OnResultExecuted

對應的view視圖渲染后執行

MVC

 

在這幾個方法中,我們一般主要用來記錄交互日誌,記錄每一個步驟的耗時情況,以便後續系統優化使用。具體的使用,根據自身的業務場景使用。

其中MVC和API的異同點,和上面說的認證授權的異同類似,不在詳細說明。

下面的一個實例代碼:

API定義過濾器實例DEMO代碼

 

/// <summary>
    /// Action過濾器
    /// </summary>
    public class XYHAPICustomActionFilterAttribute : ActionFilterAttribute
    {
        /// <summary>
        /// Action執行開始
        /// </summary>
        /// <param name="actionContext"></param>
        public override void OnActionExecuting(HttpActionContext actionContext)
        {

        }

        /// <summary>
        /// action執行以後
        /// </summary>
        /// <param name="actionContext"></param>
        public override void OnActionExecuted(HttpActionExecutedContext actionContext)
        {
            try
            {
                // 構建一個日誌數據模型
                MApiRequestLogs apiRequestLogsM = new MApiRequestLogs();

                // API名稱
                apiRequestLogsM.API = actionContext.Request.RequestUri.AbsolutePath;

                // apiKey
                apiRequestLogsM.API_KEY = HttpContext.Current.Request.QueryString["ApiKey"];

                // IP地址
                apiRequestLogsM.IP = FilterAttributeHelp.GetIPAddress(actionContext.Request);

                // 獲取token
                string token = HttpContext.Current.Request.Headers.GetValues("Token") == null ? string.Empty :
                              HttpContext.Current.Request.Headers.GetValues("Token")[0];
                apiRequestLogsM.TOKEN = token;

                // URL
                apiRequestLogsM.URL = actionContext.Request.RequestUri.AbsoluteUri;

                // 返回信息
                var objectContent = actionContext.Response.Content as ObjectContent;
                var returnValue = objectContent.Value;
                apiRequestLogsM.RESPONSE_INFOR = returnValue.ToString();

                // 由於數據庫中最大隻能存儲4000字符串,所以對返回值做一個截取
                if (!string.IsNullOrEmpty(apiRequestLogsM.RESPONSE_INFOR) &&
                    apiRequestLogsM.RESPONSE_INFOR.Length > 4000)
                {
                    apiRequestLogsM.RESPONSE_INFOR = apiRequestLogsM.RESPONSE_INFOR.Substring(0, 2000);
                }

                // 請求參數
                apiRequestLogsM.REQUEST_INFOR = actionContext.Request.RequestUri.Query;

                // 定義一個異步委託 ,異步記錄日誌
                //  Func<MApiRequestLogs, string> action = AddApiRequestLogs;//聲明一個委託
                // IAsyncResult ret = action.BeginInvoke(apiRequestLogsM, null, null);

            }
            catch (Exception ex)
            {

            }
        }
    }

 

HandleError錯誤處理

異常處理對於我們來說很常用,很好的利用異常處理,可以很好的避免全篇的try/catch。異常處理箱單很簡單,值需要自定義集成:ExceptionFilterAttribute,並自定義實現:OnException方法即可。

在OnException我們可以根據自身需要,做一些相應的邏輯處理,比如記錄異常日誌,便於後續問題分析跟進。

OnException還有一個很重要的處理,那就是對異常結果的統一包裝,返回一個很友好的結果給用戶,避免把一些不必要的信息返回給用戶。比如:針對MVC,那麼跟進不同異常,統一調整至友好的提示頁面等等;針對API,那麼我們可以一個統一的返回幾個封裝,便於用戶統一處理結果。

MVC 的異常處理實例代碼:

 

   /// <summary>
    /// MVC自定義異常處理機制
    /// 說道異常處理,其實我們腦海中的第一反應,也該是try/cache操作
    /// 但是在實際開發中,很有可能地址錯誤根本就進入不到try中,又或者沒有被try處理到異常
    /// 該類就發揮了作用,能夠很好的未經捕獲的異常,並做相應的邏輯處理
    /// 自定義異常機制,主要集成HandleErrorAttribute 重寫其OnException方法
    /// </summary>
    public class XYHMVCHandleError : HandleErrorAttribute
    {
        /// <summary>
        /// 處理異常
        /// </summary>
        /// <param name="filterContext">異常上下文</param>
        public override void OnException(ExceptionContext filterContext)
        {
            // 我們在平時的項目中,異常處理一般有兩個作用
            // 1:記錄異常的詳細日誌,便於事後分析日誌
            // 2:對異常的統一友好處理,比如根據異常類型重定向到友好提示頁面

            // 在這裏面既能獲取到未經處理的異常信息,也能獲取到請求信息
            // 在此可以根據實際項目需要做相應的邏輯處理
            // 下面簡單的列舉了幾個關鍵信息獲取方式

            // 控制器名稱 注意,這樣獲取出來的是一個文件的全路徑 
            string contropath = filterContext.Controller.ToString();

            // 訪問目錄的相對路徑
            string filePath = filterContext.HttpContext.Request.FilePath;

            // url完整地址
            string url = (filterContext.HttpContext.Request.Url.AbsoluteUri).ExUrlDeCode();

            // 請求方式 post get
            string httpMethod = filterContext.HttpContext.Request.HttpMethod;

            // 請求IP地址
            string ip = filterContext.HttpContext.Request.GetIPAddress();

            // 獲取全部的請求參數
            HttpRequest httpRequest = HttpContext.Current.Request;
            Dictionary<string, string> queryParameters = httpRequest.GetAllQueryParameters();

            // 獲取異常對象
            Exception ex = filterContext.Exception;

            // 異常描述信息
            string exMessage = ex.Message;

            // 異常堆棧信息
            string stackTrace = ex.StackTrace;

            // 根據實際情況記錄日誌(文本日誌、數據庫日誌,建議具體步驟採用異步方式來完成)


            filterContext.ExceptionHandled = true;

            // 模擬根據不同的做對應的邏輯處理
            int statusCode = filterContext.HttpContext.Response.StatusCode;

            if (statusCode>=400 && statusCode<500)
            {
                filterContext.Result = new RedirectResult("/html/404.html");
            }
            else 
            {
                filterContext.Result = new RedirectResult("/html/500.html");
            }
        }
    }

 

API 的異常處理實例代碼:

 

 /// <summary>
    /// API自定義異常處理機制
    /// 說道異常處理,其實我們腦海中的第一反應,也該是try/cache操作
    /// 但是在實際開發中,很有可能地址錯誤根本就進入不到try中,又或者沒有被try處理到異常
    /// 該類就發揮了作用,能夠很好的未經捕獲的異常,並做相應的邏輯處理
    /// 自定義異常機制,主要集成ExceptionFilterAttribute 重寫其OnException方法
    /// </summary>
    public class XYHAPIHandleError : ExceptionFilterAttribute
    {
        /// <summary>
        /// 處理異常
        /// </summary>
        /// <param name="actionExecutedContext">異常上下文</param>
        public override void OnException(HttpActionExecutedContext actionExecutedContext)
        {
            // 我們在平時的項目中,異常處理一般有兩個作用
            // 1:記錄異常的詳細日誌,便於事後分析日誌
            // 2:對異常的統一友好處理,比如根據異常類型重定向到友好提示頁面

            // 在這裏面既能獲取到未經處理的異常信息,也能獲取到請求信息
            // 在此可以根據實際項目需要做相應的邏輯處理
            // 下面簡單的列舉了幾個關鍵信息獲取方式

            // action名稱 
            string actionName = actionExecutedContext.ActionContext.ActionDescriptor.ActionName;

            // 控制器名稱 
            string controllerName =actionExecutedContext.ActionContext.ControllerContext.ControllerDescriptor.ControllerName;

            // url完整地址
            string url = (actionExecutedContext.Request.RequestUri.AbsoluteUri).ExUrlDeCode();

            // 請求方式 post get
            string httpMethod = actionExecutedContext.Request.Method.Method;

            // 請求IP地址
            string ip = actionExecutedContext.Request.GetIPAddress();

            // 獲取全部的請求參數
            HttpRequest httpRequest = HttpContext.Current.Request;
            Dictionary<string, string> queryParameters = httpRequest.GetAllQueryParameters();

            // 獲取異常對象
            Exception ex = actionExecutedContext.Exception;

            // 異常描述信息
            string exMessage = ex.Message;

            // 異常堆棧信息
            string stackTrace = ex.StackTrace;

            // 根據實際情況記錄日誌(文本日誌、數據庫日誌,建議具體步驟採用異步方式來完成)
            // 自己的記錄日誌落地邏輯略 ......

            // 構建統一的內部異常處理機制,相當於對異常做一層統一包裝暴露
            MBaseResult<string> result = new MBaseResult<string>()
            {
                Code = MResultCodeEnum.systemErrorCode,
                Message = MResultCodeEnum.systemError
            };

            actionExecutedContext.Response = new HttpResponseMessage(HttpStatusCode.OK);
            //需要自己指定輸出內容和類型
            HttpContext.Current.Response.ContentType = "text/html;charset=utf-8";
            HttpContext.Current.Response.Write(JsonConvert.SerializeObject(result));
            HttpContext.Current.Response.End(); // 此處結束響應,就不會走路由系統
        }
    }

 

總結

.net過濾器,我個人的一句話理解就是:對action的各個階段進行統一的監控處理等操作。.net過濾器中,其中每一個種過濾器的執行先後順序為:Authorize(授權)–>ActionFilter自定義–>HandleError(錯誤處理)

好了,就先聊到這而,如果什麼地方說的不對之處,多多指點和多多包涵。我自己寫了一個練習DEMO,裏面會有每一種情況的處理說明。有興趣的可以取下載下來看一看,謝謝。

DEMO在GitHub地址為:https://github.com/xuyuanhong0902/XYH.FilterTest.git

 

END
為了更高的交流,歡迎大家關注我的公眾號,掃描下面二維碼即可關注,謝謝:

 

 

認證授權

時間戳

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

【其他文章推薦】

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

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

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

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

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

※回頭車貨運收費標準

台中搬家公司費用怎麼算?

保存良好!擁2.3米長牙巨象遺骸出土 曾被分屍證據曝光

摘錄自2020年5月21日自由時報報導

德國圖賓根大學(University of Tuebingen)發表新研究報告,稱在該國西北部下薩克森邦一處遺址發現至少10頭巨象的遺骸,其狀態從舊石器時代至今仍保持良好,且按現場痕跡判斷,這些巨象死後曾被獵人分屍取肉,目前無法確定是自然死亡或遭人獵殺。

綜合外媒報導,德國圖賓根大學考古團隊針對下薩克森邦舍寧根遺址(Schoningen)出土的歐洲菱齒象遺骸進行復原與研究,團隊指出,其中一頭體型巨大、保存良好的雌象擁有長達2.3公尺的巨牙,研究人員認為,牠在距今約30萬年前死亡,死後遺骸曾被獵人分割。

負責挖掘行動的考古學家塞蘭格利(Jordi Serangeli)指出,在復原這頭雌象遺骸後推斷其肩高約3.2公尺,重約6.8噸,體型大於現今仍存在於世的非洲象。

生活環境
國際新聞
德國
古菱齒象
化石

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

【其他文章推薦】

台北網頁設計公司這麼多該如何選擇?

※智慧手機時代的來臨,RWD網頁設計為架站首選

※評比南投搬家公司費用收費行情懶人包大公開

※回頭車貨運收費標準

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

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

市值破2.4兆 155家跨國企業連署振興訴求 指名「科學為基礎」的零碳經濟方案

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

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

【其他文章推薦】

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

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

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

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

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

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

【Spring註解驅動開發】使用@Scope註解設置組件的作用域

寫在前面

Spring容器中的組件默認是單例的,在Spring啟動時就會實例化並初始化這些對象,將其放到Spring容器中,之後,每次獲取對象時,直接從Spring容器中獲取,而不再創建對象。如果每次從Spring容器中獲取對象時,都要創建一個新的實例對象,該如何處理呢?此時就需要使用@Scope註解設置組件的作用域。

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

本文內容概覽

  • @Scope註解概述
  • 單實例bean作用域
  • 多實例bean作用域
  • 單實例bean作用域如何創建對象?
  • 多實例bean作用域如何創建對象?
  • 單實例bean注意的事項
  • 多實例bean注意的事項
  • 自定義Scope的實現

@Scope註解概述

@Scope註解能夠設置組件的作用域,我們先來看@Scope註解類的源碼,如下所示。

package org.springframework.context.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.core.annotation.AliasFor;

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Scope {
	@AliasFor("scopeName")
	String value() default "";
    /**
	 * Specifies the name of the scope to use for the annotated component/bean.
	 * <p>Defaults to an empty string ({@code ""}) which implies
	 * {@link ConfigurableBeanFactory#SCOPE_SINGLETON SCOPE_SINGLETON}.
	 * @since 4.2
	 * @see ConfigurableBeanFactory#SCOPE_PROTOTYPE
	 * @see ConfigurableBeanFactory#SCOPE_SINGLETON
	 * @see org.springframework.web.context.WebApplicationContext#SCOPE_REQUEST
	 * @see org.springframework.web.context.WebApplicationContext#SCOPE_SESSION
	 * @see #value
	 */
	@AliasFor("value")
	String scopeName() default "";
    
	ScopedProxyMode proxyMode() default ScopedProxyMode.DEFAULT;
}

從源碼中可以看出,在@Scope註解中可以設置如下值。

ConfigurableBeanFactory#SCOPE_PROTOTYPE
ConfigurableBeanFactory#SCOPE_SINGLETON
org.springframework.web.context.WebApplicationContext#SCOPE_REQUEST
org.springframework.web.context.WebApplicationContext#SCOPE_SESSION

很明顯,在@Scope註解中可以設置的值包括ConfigurableBeanFactory接口中的SCOPE_PROTOTYPE和SCOPE_SINGLETON,以及WebApplicationContext類中SCOPE_REQUEST和SCOPE_SESSION。這些都是什麼鬼?別急,我們來一個個查看。

首先,我們進入到ConfigurableBeanFactory接口中,發現在ConfigurableBeanFactory類中存在兩個常量的定義,如下所示。

public interface ConfigurableBeanFactory extends HierarchicalBeanFactory, SingletonBeanRegistry {
	String SCOPE_SINGLETON = "singleton";
	String SCOPE_PROTOTYPE = "prototype";
    /*****************此處省略N多行代碼*******************/
}

沒錯,SCOPE_SINGLETON就是singleton,SCOPE_PROTOTYPE就是prototype。

那麼,WebApplicationContext類中SCOPE_REQUEST和SCOPE_SESSION又是什麼鬼呢?就是說,當我們使用了Web容器來運行Spring應用時,在@Scope註解中可以設置WebApplicationContext類中SCOPE_REQUEST和SCOPE_SESSION的值,而SCOPE_REQUEST的值就是request,SCOPE_SESSION的值就是session。

綜上,在@Scope註解中的取值如下所示。

  • singleton:表示組件在Spring容器中是單實例的,這個是Spring的默認值,Spring在啟動的時候會將組件進行實例化並加載到Spring容器中,之後,每次從Spring容器中獲取組件時,直接將實例對象返回,而不必再次創建實例對象。從Spring容器中獲取對象,小夥伴們可以理解為從Map對象中獲取對象。
  • prototype:表示組件在Spring容器中是多實例的,Spring在啟動的時候並不會對組件進行實例化操作,而是每次從Spring容器中獲取組件對象時,都會創建一個新的實例對象並返回。
  • request:每次請求都會創建一個新的實例對象,request作用域用在spring容器的web環境中。
  • session:在同一個session範圍內,創建一個新的實例對象,也是用在web環境中。
  • application:全局web應用級別的作用於,也是在web環境中使用的,一個web應用程序對應一個bean實例,通常情況下和singleton效果類似的,不過也有不一樣的地方,singleton是每個spring容器中只有一個bean實例,一般我們的程序只有一個spring容器,但是,一個應用程序中可以創建多個spring容器,不同的容器中可以存在同名的bean,但是sope=aplication的時候,不管應用中有多少個spring容器,這個應用中同名的bean只有一個。

其中,request和session作用域是需要Web環境支持的,這兩個值基本上使用不到,如果我們使用Web容器來運行Spring應用時,如果需要將組件的實例對象的作用域設置為request和session,我們通常會使用request.setAttribute(“key”,object)和session.setAttribute(“key”, object)的形式來將對象實例設置到request和session中,通常不會使用@Scope註解來進行設置。

單實例bean作用域

首先,我們在io.mykit.spring.plugins.register.config包下創建PersonConfig2配置類,在PersonConfig2配置類中實例化一個Person對象,並將其放置在Spring容器中,如下所示。

package io.mykit.spring.plugins.register.config;

import io.mykit.spring.bean.Person;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @author binghe
 * @version 1.0.0
 * @description 測試@Scope註解設置的作用域
 */
@Configuration
public class PersonConfig2 {

    @Bean("person")
    public Person person(){
        return new Person("binghe002", 18);
    }
}

接下來,在SpringBeanTest類中創建testAnnotationConfig2()測試方法,在testAnnotationConfig2()方法中,創建ApplicationContext對象,創建完畢后,從Spring容器中按照id獲取兩個Person對象,並打印兩個對象是否是同一個對象,代碼如下所示。

@Test
public void testAnnotationConfig2(){
    ApplicationContext context = new AnnotationConfigApplicationContext(PersonConfig2.class);
    //從Spring容器中獲取到的對象默認是單實例的
    Object person1 = context.getBean("person");
    Object person2 = context.getBean("person");
    System.out.println(person1 == person2);
}

由於對象在Spring容器中默認是單實例的,所以,Spring容器在啟動時就會將實例對象加載到Spring容器中,之後,每次從Spring容器中獲取實例對象,直接將對象返回,而不必在創建新對象實例,所以,此時testAnnotationConfig2()方法會輸出true。如下所示。

這也驗證了我們的結論:對象在Spring容器中默認是單實例的,Spring容器在啟動時就會將實例對象加載到Spring容器中,之後,每次從Spring容器中獲取實例對象,直接將對象返回,而不必在創建新對象實例。

多實例bean作用域

修改Spring容器中組件的作用域,我們需要藉助於@Scope註解,此時,我們將PersonConfig2類中Person對象的作用域修改成prototype,如下所示。

@Configuration
public class PersonConfig2 {

    @Scope("prototype")
    @Bean("person")
    public Person person(){
        return new Person("binghe002", 18);
    }
}

其實,使用@Scope設置作用域就等同於在XML文件中為bean設置scope作用域,如下所示。

此時,我們再次運行SpringBeanTest類的testAnnotationConfig2()方法,此時,從Spring容器中獲取到的person1對象和person2對象還是同一個對象嗎?

通過輸出結果可以看出,此時,輸出的person1對象和person2對象已經不是同一個對象了。

單實例bean作用域何時創建對象?

接下來,我們驗證下在單實例作用域下,Spring是在什麼時候創建對象的呢?

首先,我們將PersonConfig2類中的Person對象的作用域修改成單實例,並在返回Person對象之前打印相關的信息,如下所示。

@Configuration
public class PersonConfig2 {
    @Scope
    @Bean("person")
    public Person person(){
        System.out.println("給容器中添加Person....");
        return new Person("binghe002", 18);
    }
}

接下來,我們在SpringBeanTest類中創建testAnnotationConfig3()方法,在testAnnotationConfig3()方法中,我們只創建Spring容器,如下所示。

@Test
public void testAnnotationConfig3(){
    ApplicationContext context = new AnnotationConfigApplicationContext(PersonConfig2.class);
}

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

從輸出的結果信息可以看出,Spring容器在創建的時候,就將@Scope註解標註為singleton的組件進行了實例化,並加載到Spring容器中。

接下來,我們運行SpringBeanTest類中的testAnnotationConfig2(),結果信息如下所示。

說明,Spring容器在啟動時,將單實例組件實例化之後,加載到Spring容器中,以後每次從容器中獲取組件實例對象,直接返回相應的對象,而不必在創建新對象。

多實例bean作用域何時創建對象?

如果我們將對象的作用域修改成多實例,那什麼時候創建對象呢?

此時,我們將PersonConfig2類的Person對象的作用域修改成多實例,如下所示。

@Configuration
public class PersonConfig2 {

    @Scope("prototype")
    @Bean("person")
    public Person person(){
        System.out.println("給容器中添加Person....");
        return new Person("binghe002", 18);
    }
}

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

可以看到,終端並沒有輸出任何信息,說明在創建Spring容器時,並不會實例化和加載多實例對象,那多實例對象是什麼時候實例化的呢?接下來,我們在SpringBeanTest類中的testAnnotationConfig3()方法中添加一行獲取Person對象的代碼,如下所示。

@Test
public void testAnnotationConfig3(){
    ApplicationContext context = new AnnotationConfigApplicationContext(PersonConfig2.class);
    Object person1 = context.getBean("person");
}

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

從結果信息中,可以看出,當向Spring容器中獲取Person實例對象時,Spring容器實例化了Person對象,並將其加載到Spring容器中。

那麼,問題來了,此時Spring容器是否只實例化一個Person對象呢?我們在SpringBeanTest類中的testAnnotationConfig3()方法中再添加一行獲取Person對象的代碼,如下所示。

@Test
public void testAnnotationConfig3(){
    ApplicationContext context = new AnnotationConfigApplicationContext(PersonConfig2.class);
    Object person1 = context.getBean("person");
    Object person2 = context.getBean("person");
}

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

從輸出結果可以看出,當對象的Scope作用域為多實例時,每次向Spring容器獲取對象時,都會創建一個新的對象並返回。此時,獲取到的person1和person2就不是同一個對象了,我們也可以打印結果信息來進行驗證,此時在SpringBeanTest類中的testAnnotationConfig3()方法中打印兩個對象是否相等,如下所示。

@Test
public void testAnnotationConfig3(){
    ApplicationContext context = new AnnotationConfigApplicationContext(PersonConfig2.class);
    Object person1 = context.getBean("person");
    Object person2 = context.getBean("person");
    System.out.println(person1 == person2);
}

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

可以看到,當對象是多實例時,每次從Spring容器中獲取對象時,都會創建新的實例對象,並且每個實例對象都不相等。

單實例bean注意的事項

單例bean是整個應用共享的,所以需要考慮到線程安全問題,之前在玩springmvc的時候,springmvc中controller默認是單例的,有些開發者在controller中創建了一些變量,那麼這些變量實際上就變成共享的了,controller可能會被很多線程同時訪問,這些線程併發去修改controller中的共享變量,可能會出現數據錯亂的問題;所以使用的時候需要特別注意。

多實例bean注意的事項

多例bean每次獲取的時候都會重新創建,如果這個bean比較複雜,創建時間比較長,會影響系統的性能,這個地方需要注意。

自定義Scope

如果Spring內置的幾種sope都無法滿足我們的需求的時候,我們可以自定義bean的作用域。

1.如何實現自定義Scope

自定義Scope主要分為三個步驟,如下所示。

(1)實現Scope接口

我們先來看下Scope接口的定義,如下所示。

package org.springframework.beans.factory.config;

import org.springframework.beans.factory.ObjectFactory;
import org.springframework.lang.Nullable;

public interface Scope {

    /**
    * 返回當前作用域中name對應的bean對象
    * name:需要檢索的bean的名稱
    * objectFactory:如果name對應的bean在當前作用域中沒有找到,那麼可以調用這個ObjectFactory來創建這個對象
    **/
    Object get(String name, ObjectFactory<?> objectFactory);

    /**
     * 將name對應的bean從當前作用域中移除
     **/
    @Nullable
    Object remove(String name);

    /**
     * 用於註冊銷毀回調,如果想要銷毀相應的對象,則由Spring容器註冊相應的銷毀回調,而由自定義作用域選擇是不是要銷毀相應的對象
     */
    void registerDestructionCallback(String name, Runnable callback);

    /**
     * 用於解析相應的上下文數據,比如request作用域將返回request中的屬性。
     */
    @Nullable
    Object resolveContextualObject(String key);

    /**
     * 作用域的會話標識,比如session作用域將是sessionId
     */
    @Nullable
    String getConversationId();

}

(2)將Scope註冊到容器

需要調用org.springframework.beans.factory.config.ConfigurableBeanFactory#registerScope的方法,看一下這個方法的聲明

/**
* 向容器中註冊自定義的Scope
*scopeName:作用域名稱
* scope:作用域對象
**/
void registerScope(String scopeName, Scope scope);

(3)使用自定義的作用域

定義bean的時候,指定bean的scope屬性為自定義的作用域名稱。

2.自定義Scope實現案例

例如,我們來實現一個線程級別的bean作用域,同一個線程中同名的bean是同一個實例,不同的線程中的bean是不同的實例。

這裏,要求bean在線程中是共享的,所以我們可以通過ThreadLocal來實現,ThreadLocal可以實現線程中數據的共享。

此時,我們在io.mykit.spring.plugins.register.scope包下新建ThreadScope類,如下所示。

package io.mykit.spring.plugins.register.scope;

import org.springframework.beans.factory.ObjectFactory;
import org.springframework.beans.factory.config.Scope;
import org.springframework.lang.Nullable;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

/**
 * 自定義本地線程級別的bean作用域,不同的線程中對應的bean實例是不同的,同一個線程中同名的bean是同一個實例
 */
public class ThreadScope implements Scope {

    public static final String THREAD_SCOPE = "thread";

    private ThreadLocal<Map<String, Object>> beanMap = new ThreadLocal() {
        @Override
        protected Object initialValue() {
            return new HashMap<>();
        }
    };

    @Override
    public Object get(String name, ObjectFactory<?> objectFactory) {
        Object bean = beanMap.get().get(name);
        if (Objects.isNull(bean)) {
            bean = objectFactory.getObject();
            beanMap.get().put(name, bean);
        }
        return bean;
    }

    @Nullable
    @Override
    public Object remove(String name) {
        return this.beanMap.get().remove(name);
    }

    @Override
    public void registerDestructionCallback(String name, Runnable callback) {
        //bean作用域範圍結束的時候調用的方法,用於bean清理
        System.out.println(name);
    }

    @Nullable
    @Override
    public Object resolveContextualObject(String key) {
        return null;
    }

    @Nullable
    @Override
    public String getConversationId() {
        return Thread.currentThread().getName();
    }
}

在ThreadScope類中,我們定義了一個常量THREAD_SCOPE,在定義bean的時候給scope使用。

接下來,我們在io.mykit.spring.plugins.register.config包下創建PersonConfig3類,並使用@Scope(“thread”)註解標註Person對象的作用域為Thread範圍,如下所示。

package io.mykit.spring.plugins.register.config;

import io.mykit.spring.bean.Person;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;

/**
 * @author binghe
 * @version 1.0.0
 * @description 測試@Scope註解設置的作用域
 */
@Configuration
public class PersonConfig3 {

    @Scope("thread")
    @Bean("person")
    public Person person(){
        System.out.println("給容器中添加Person....");
        return new Person("binghe002", 18);
    }
}

最後,我們在SpringBeanTest類中創建testAnnotationConfig4()方法,在testAnnotationConfig4()方法中創建Spring容器,並向Spring容器中註冊ThreadScope對象,接下來,使用循環創建兩個Thread線程,並分別在每個線程中獲取兩個Person對象,如下所示。

@Test
public void testAnnotationConfig4(){
    AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(PersonConfig3.class);
    //向容器中註冊自定義的scope
    context.getBeanFactory().registerScope(ThreadScope.THREAD_SCOPE, new ThreadScope());

    //使用容器獲取bean
    for (int i = 0; i < 2; i++) { 
        new Thread(() -> {
            System.out.println(Thread.currentThread() + "," + context.getBean("person"));
            System.out.println(Thread.currentThread() + "," + context.getBean("person"));
        }).start();
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

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

從輸出中可以看到,bean在同樣的線程中獲取到的是同一個bean的實例,不同的線程中bean的實例是不同的。

注意:這裏,我將Person類進行了相應的調整,去掉Lombok的註解,手動寫構造函數和setter與getter方法,如下所示。

package io.mykit.spring.bean;

import java.io.Serializable;

/**
 * @author binghe
 * @version 1.0.0
 * @description 測試實體類
 */
public class Person implements Serializable {
    private static final long serialVersionUID = 7387479910468805194L;
    private String name;
    private Integer age;

    public Person() {
    }

    public Person(String name, Integer age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }
}

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

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

寫在最後

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

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

【其他文章推薦】

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

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

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

※超省錢租車方案

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

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

※回頭車貨運收費標準

輕鬆解決Github連接緩慢、圖裂問題

1 簡介

  gayhub(誤)github作為全世界最大的開源代碼庫以及版本控制系統,是用來託管項目以及學習開源技術非常好的平台,是我心中最好的學習網站,我們公眾號(Python大數據分析)的眾多技術文章對應的數據和代碼也都一直託管在github上。

  但熟悉github的朋友應該都被其越來越慢的連接速度,以及“全員圖裂”所困擾:

圖1

  本文就將參考github倉庫(https://github.com/521xueweihan/GitHub520 ),教大家如何在不kexue上網的前提下,簡單幾步解決github訪問緩慢已經各種圖裂的問題。

2 通過修改本地hosts文件加速github

2.1 手動修改更新

  首先我們需要找到自己設備上的hosts文件,不同的平台其存放路徑各不相同,主要的平台hosts文件所在路徑如下:

  • Windows :C:\Windows\System32\drivers\etc\hosts
  • Linux:/etc/hosts
  • Mac:/etc/hosts

  以Windows為例,按照上面的說明,進入C:\Windows\System32\drivers\etc目錄,找到hosts文件:

圖2

  這是一個無拓展名的文件,我們可以使用記事本、notepad++等文本編輯器來打開它,將下面的內容(這部分內容在寫作本文的時候是有效的,如果你在嘗試時它們已經失效了,可以前往上文提到的github倉庫複製最新的,或者參考下文中的第2種方法)複製,並粘貼hosts文件的最後:

# GitHub520 Host Start
185.199.108.154                                   github.githubassets.com
199.232.68.133                                    camo.githubusercontent.com
199.232.68.133                                    github.map.fastly.net
199.232.69.194                                    github.global.ssl.fastly.net
140.82.112.3                                      github.com
140.82.113.5                                      api.github.com
199.232.68.133                                    raw.githubusercontent.com
199.232.68.133                                    user-images.githubusercontent.com
199.232.68.133                                    favicons.githubusercontent.com
199.232.68.133                                    avatars5.githubusercontent.com
199.232.68.133                                    avatars4.githubusercontent.com
199.232.68.133                                    avatars3.githubusercontent.com
199.232.68.133                                    avatars2.githubusercontent.com
199.232.68.133                                    avatars1.githubusercontent.com
199.232.68.133                                    avatars0.githubusercontent.com
# GitHub520 Host End

圖3

  如果保存時需要管理員權限,按照提示以管理員方式重新打開再保存即可,正常情況下在保存退出後會立即生效,如果依然加載不出圖,可以根據自己系統的不同來執行對應的命令刷新DNS重啟機器即可:

  • Windows:ipconfig /flushdns
  • Linux:sudo rcnscd restart
  • Mac:sudo killall -HUP mDNSResponder

  接下來我們來看看這種方法的效果如何,在遵循上述流程修改好hosts文件之後,重新打開圖1對應的README頁面:

圖4

  O(∩_∩)O哈哈~,這時我們成功地加載出了原本裂掉的圖,但這種方式麻煩的地方在於當你配置好hosts之後的確是可以正常訪問github的,但一旦你某天訪問github發現老毛病又出現了,就得重複一遍上述的過程,接下來我們來學習另一種能將上述過程自動化的方法。

2.2 利用SwitchHosts軟件自動更新hosts信息

  SwitchHosts是一個用於快速切換hosts文件的開源軟件(https://github.com/oldj/SwitchHosts ),我們可以通過其官方提供的百度雲盤地址(https://pan.baidu.com/s/1inED1 )下載適合自己系統的版本。

  下載后直接正常安裝,接着以管理員身份打開,點擊左下角+新建hosts,再按照圖5配置好,設置自動刷新時間間隔為你覺得合適的,我選的1小時刷新一次,這樣每隔一小時SwitchHosts就會自動訪問URL並更新hosts信息:

圖5

  點擊刷新按鈕刷新成功后,點擊OK創建完成。其中URL信息是碼雲同步可正常訪問版本(https://gitee.com/xueweihan/codes/6g793pm2k1hacwfbyesl464/raw?blob_name=GitHub520.yml ),因為原始倉庫中的URL為github源會連接失敗。

  創建完成后,把開關打開,讓SwitchHosts在後台靜靜的運行即可:

圖6

  完成后,保持軟件後台運行即可,之後訪問Github同樣解決了問題。

  以上就是本文的全部內容,如有疑問歡迎在評論區與我討論。

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

【其他文章推薦】

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

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

※超省錢租車方案

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

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

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

台中搬家遵守搬運三大原則,讓您的家具不再被破壞!

一文講透Java序列化

 

本文目錄

  • 一、序列化是什麼
  • 二、為什麼需要序列化
  • 三、序列化怎麼用
  • 四、序列化深度探秘
    • 4.1 為什麼必須實現Serializable接口
    • 4.2 被序列化對象的字段是引用時該怎麼辦 
    • 4.3 同一個對象會被序列化多次嗎
    • 4.4 只想序列化對象的部分字段該怎麼辦
    • 4.5 被序列化對象具有繼承關係該怎麼辦
  • 五、serialVersionUID的作用及自動生成
  • 六、序列化的缺點
  • 七、參考文獻

 

前言

 

Oracle 公司計劃廢除 Java 中的古董:序列化技術,因為它帶來了許多嚴重的安全問題(如序列化存儲安全、反序列化安全、傳輸安全等),據統計,至少有3分之1的漏洞是序列化帶來的,這也是 1997 年誕生序列化技術的一個巨大錯誤。但是,序列化技術現在在 Java 應用中無處不在,特別是現在的持久化框架和分佈式技術中,都需要利用序列化來傳輸對象,如:Hibernate、Mybatis、Java RMI、Dubbo等,即對象要存儲或者傳輸都不可避免要用到序列化技術,所以刪除序列化技術將是一個長期的計劃。

 

你在實際工作中可能會很難有機會真正用到Java自帶的序列化技術了,工業界一般也會選擇一些更安全的對象編解碼方案例如Google的Protobuf等。所以,對於Java序列化,我們不必再投入過多的精力學習,你花20分鐘讀完本文所掌握的知識,對於應付日常源碼閱讀中遇到的遺留的Java序列化技術應該是足夠了。

 

一、序列化是什麼

 

序列化機制允許將實現序列化的Java對象轉換成字節序列,這些字節序列可以保存在磁盤上,或通過網絡傳輸,以備以後重新恢復成原來的對象。序列化機制使得對象可以脫離程序的運行而獨立存在。

  • 序列化:將一個Java對象寫入IO流中
  • 反序列化:從IO流中恢復該Java對象

 

本文中用序列化來簡稱整個序列化和反序列化機制。 

 

二、為什麼需要序列化

 

所有可能在網絡上傳輸的對象的類都應該是可序列化的,否則程序將會出現異常,比如RMI(Remote Method Invoke,即遠程方法調用,是JavaEE的基礎)過程中的參數和返回值;所有需要保存到磁盤裡的對象的類都必須可序列化,比如Web應用中需要保存到HttpSession或ServletContext屬性的Java對象。

 

因為序列化是RMI過程的參數和返回值都必須實現的機制,而RMI又是Java EE技術的基礎——所有的分佈式應用常常需要跨平台、跨網絡,所以要求所有傳遞的參數、返回值必須實現序列化。因此序列化機制是Java EE平台的基礎。通常建議:程序創建的每個JavaBean類都實現Serializable。

 

三、序列化怎麼用

 

如果一個類的對象需要序列化,那麼在Java語法層面,這個類需要:

  • 實現Serializable接口
  • 使用ObjectOutputStream將對象輸出到流,實現對象的序列化;使用ObjectInputStream從流中讀取對象,實現對象的反序列化

 

下面我們通過代碼示例來看看序列化最基本的用法。我們創建了Person類,其擁有兩個基本類型的屬性,並實現了Serializable接口。testSerialize方法用來測試序列化,testDeserialize方法用來測試反序列化。

 1 import org.junit.Test;
 2 
 3 import java.io.*;
 4 
 5 public class SerializableTest {
 6 
 7     @Test
 8     public void testSerialize() {
 9         Person one = new Person(12, 148.2);
10         Person two = new Person(35, 177.8);
11 
12         try (ObjectOutputStream output =
13                      new ObjectOutputStream(new FileOutputStream("Person.txt"))) {
14             output.writeObject(one);
15             output.writeObject(two);
16         } catch (IOException e) {
17             e.printStackTrace();
18         }
19     }
20 
21     @Test
22     public void testDeserialize() {
23 
24         try (ObjectInputStream input =
25                      new ObjectInputStream(new FileInputStream("Person.txt"))) {
26             Person one = (Person) input.readObject();
27             Person two = (Person) input.readObject();
28 
29             System.out.println(one);
30             System.out.println(two);
31         } catch (IOException e) {
32             e.printStackTrace();
33         } catch (ClassNotFoundException e) {
34             e.printStackTrace();
35         }
36     }
37 }
38 
39 class Person implements Serializable {
40     int age;
41     double height;
42 
43     public Person(int age, double height) {
44         this.age = age;
45         this.height = height;
46     }
47 
48     @Override
49     public String toString() {
50         return "Person{" +
51                 "age=" + age +
52                 ", height=" + height +
53                 '}';
54     }
55 }

 

四、序列化深度探秘

4.1 為什麼必須實現Serializable接口

如果某個類需要支持序列化功能,那麼它必須實現Serializable接口,否則會報 java.io.NotSerializableException。Serializable接口是一個標誌性接口(Marker Interface),也就是說,該接口並不包含任何具體的方法,是一個空接口,僅僅用來判斷該類是否能夠序列化。JDK8中Serializable接口的源碼如下:

1 package java.io;
2 
3 public interface Serializable {
4 }

 

在 ObjectOutputStream.java 的 writeObject0 方法中,我們確實可以看到對對象是否實現了 Serializable接口進行了驗證(第15行),否則會拋出 NotSerializableException 異常(第22行)。

 1     private void writeObject0(Object obj, boolean unshared)
 2         throws IOException
 3     {
 4         boolean oldMode = bout.setBlockDataMode(false);
 5         depth++;
 6         try {
 7             ...
 8             // remaining cases
 9             if (obj instanceof String) {
10                 writeString((String) obj, unshared);
11             } else if (cl.isArray()) {
12                 writeArray(obj, desc, unshared);
13             } else if (obj instanceof Enum) {
14                 writeEnum((Enum<?>) obj, desc, unshared);
15             } else if (obj instanceof Serializable) {
16                 writeOrdinaryObject(obj, desc, unshared);
17             } else {
18                 if (extendedDebugInfo) {
19                     throw new NotSerializableException(
20                         cl.getName() + "\n" + debugInfoStack.toString());
21                 } else {
22                     throw new NotSerializableException(cl.getName());
23                 }
24             }
25         } finally {
26             depth--;
27             bout.setBlockDataMode(oldMode);
28         }
29     }

 

4.2 被序列化對象的字段是引用時該怎麼辦

在第三部分“序列化怎麼用”部分的示例中,Person類的字段全都是基本類型,我們知道基本類型其地址中直接存放的就是它的值,那如果是引用類型呢?引用類型其地址中存放的是指向堆內存中的一個地址,難道序列化時就是將這個地址進行了保存嗎?顯然,這是說不通的,因為對象的內存地址是可變的,在同一系統的不同運行時刻或者是不同系統中,對象的地址肯定是不同的,因此,序列化內存地址沒有意義。

 

如果被序列化對象的字段是引用,那麼要求該引用的類型也是可序列化實現了Serializable接口的,否則無法序列化。當對某個對象進行序列化時,系統會自動把該對象的所有Field依次進行序列化,如果某個Field引用到另一個對象,則被引用的對象也會被序列化;如果被引用的對象的Field也引用了其他對象,則被引用的對象也會被序列化,這種情況被稱為遞歸序列化。

 

4.3 同一個對象會被序列化多次嗎

如果對象A和對象B同時引用了對象C,那麼,當序列化對象A和對象B時,對象C會被序列化兩次嗎?答案顯然是不會

 

要解釋這個問題,就不得不說一下Java序列化的基本算法了:

  • 所有序列化到二進制流的對象都有一個序列化編號
  • 當程序試圖序列化一個對象時,程序將先檢查該對象是否已經被序列化過,只有該對象從未(在本次虛擬機中)被序列化過,系統才會將該對象轉換成字節序列並賦予一個唯一的編號
  • 如果某個對象已經序列化過,程序將只是直接輸出其序列化編號,而不是再次重新序列化該對象

 

4.4 只想序列化對象的部分字段該怎麼辦

 在一些特殊的場景下,如果一個類里包含的某些Field值是敏感信息,例如銀行賬戶信息等,這時不希望系統將該Field值進行序列化;或者某個Field的類型是不可序列化的,因此不希望對該Field進行遞歸序列化,以避免引發java.io.NotSerializableException異常。

 

此時,我們就需要自定義序列化了。自定義序列化的常用方式有兩種:

  • 使用transient關鍵字
  • 重寫writeObject與readObject方法

 

我們先看第一種方式,使用transient關鍵字。transient關鍵字只能用於修飾Field,不可修飾Java程序中的其他成分。使用transient修飾的屬性,java序列化時,會忽略掉此字段,所以反序列化出的對象,被transient修飾的屬性是默認值。對於引用類型,值是null;基本類型,值是0;boolean類型,值是false。

 

下列代碼中,我們把People的height字段設置為transient,在反序列化時,可觀察到輸出為默認值0.0。

 1 import org.junit.Test;
 2 
 3 import java.io.*;
 4 
 5 public class SerializableTest {
 6 
 7     @Test
 8     public void testSerialize() {
 9         Person one = new Person(12, 156.6);
10         Person two = new Person(16, 177.7);
11 
12         try (ObjectOutputStream output =
13                      new ObjectOutputStream(new FileOutputStream("Person.txt"))) {
14             output.writeObject(one);
15             output.writeObject(two);
16         } catch (IOException e) {
17             e.printStackTrace();
18         }
19     }
20 
21     @Test
22     public void testDeserialize() {
23 
24         try (ObjectInputStream input =
25                      new ObjectInputStream(new FileInputStream("Person.txt"))) {
26             Person one = (Person) input.readObject();
27             Person two = (Person) input.readObject();
28 
29             System.out.println(one);
30             System.out.println(two);
31         } catch (IOException e) {
32             e.printStackTrace();
33         } catch (ClassNotFoundException e) {
34             e.printStackTrace();
35         }
36     }
37 }
38 
39 class Person implements Serializable{
40     protected int age;
41     protected transient double height;
42 
43     public Person() {
44     }
45 
46     public Person(int age, double height) {
47         this.age = age;
48         this.height = height;
49     }
50 
51     @Override
52     public String toString() {
53         return "Person{" +
54                 "age=" + age +
55                 ", height=" + height +
56                 '}';
57     }
58 }

 

 程序輸出:

Person{age=12, height=0.0}
Person{age=16, height=0.0}

Process finished with exit code 0

 

使用transient關鍵字修飾Field雖然簡單、方便,但被transient修飾的Field將被完全隔離在序列化機制之外,這樣導致在反序列化恢復Java對象時無法取得該Field值。Java還提供了一種自定義序列化機制,通過這種自定義序列化機制可以讓程序控制如何序列化各Field,甚至完全不序列化某些Field(與使用transient關鍵字的效果相同)。在序列化和反序列化過程中需要特殊處理的類應該提供如下特殊簽名的方法,這些特殊的方法用以實現自定義序列化。

 private void writeObject(java.io.ObjectOutputStream out)
     throws IOException
 private void readObject(java.io.ObjectInputStream in)
     throws IOException, ClassNotFoundException;
 private void readObjectNoData()
     throws ObjectStreamException;

 

  • writeObject()方法負責寫入特定類的實例狀態,以便相應的readObject()方法可以恢復它。通過重寫該方法,程序員可以完全獲得對序列化機制的控制,可以自主決定哪些Field需要序列化,需要怎樣序列化。在默認情況下,該方法會調用out.defaultWriteObject來保存Java對象的各Field,從而可以實現序列化Java對象狀態的目的。
  • readObject()方法負責從流中讀取並恢復對象Field,通過重寫該方法,程序員可以完全獲得對反序列化機制的控制,可以自主決定需要反序列化哪些Field,以及如何進行反序列化。在默認情況下,該方法會調用in.defaultReadObject來恢復Java對象的非靜態和非瞬態Field。在通常情況下,readObject()方法與writeObject()方法對應,如果writeObject()方法中對Java對象的Field進行了一些處理,則應該在readObject()方法中對其Field進行相應的反處理,以便正確恢復該對象。
  • 當序列化流不完整時,readObjectNoData()方法可以用來正確地初始化反序列化的對象。例如,接收方使用的反序列化類的版本不同於發送方,或者接收方版本擴展的類不是發送方版本擴展的類,或者序列化流被篡改時,系統都會調用readObjectNoData()方法來初始化反序列化的對象。

下面的示例代碼中,我們在writeObject方法中對Person的字段進行了簡單的加密處理,在readObject方法中對其進行了相應的解密。

 1 import org.junit.Test;
 2 
 3 import java.io.*;
 4 
 5 public class SerializableTest {
 6 
 7     @Test
 8     public void testSerialize() {
 9         Person one = new Person(12, 156.6);
10         Person two = new Person(16, 177.7);
11 
12         try (ObjectOutputStream output =
13                      new ObjectOutputStream(new FileOutputStream("Person.txt"))) {
14             output.writeObject(one);
15             output.writeObject(two);
16         } catch (IOException e) {
17             e.printStackTrace();
18         }
19     }
20 
21     @Test
22     public void testDeserialize() {
23 
24         try (ObjectInputStream input =
25                      new ObjectInputStream(new FileInputStream("Person.txt"))) {
26             Person one = (Person) input.readObject();
27             Person two = (Person) input.readObject();
28 
29             System.out.println(one);
30             System.out.println(two);
31         } catch (IOException e) {
32             e.printStackTrace();
33         } catch (ClassNotFoundException e) {
34             e.printStackTrace();
35         }
36     }
37 }
38 
39 class Person implements Serializable{
40     protected int age;
41     protected double height;
42 
43     public Person() {
44     }
45 
46     public Person(int age, double height) {
47         this.age = age;
48         this.height = height;
49     }
50 
51     private void writeObject(java.io.ObjectOutputStream out)
52             throws IOException {
53         System.out.println("Encryption!");
54         out.writeInt(age + 1);
55         out.writeDouble(height - 1);
56     }
57     private void readObject(java.io.ObjectInputStream in)
58             throws IOException, ClassNotFoundException {
59         System.out.println("Decryption!");
60         this.age = in.readInt() - 1;
61         this.height = in.readDouble() + 1;
62     }
63 
64     @Override
65     public String toString() {
66         return "Person{" +
67                 "age=" + age +
68                 ", height=" + height +
69                 '}';
70     }
71 }

 

4.5 被序列化對象具有繼承關係該怎麼辦

被序列化對象具有繼承關係時無非就兩種情況,第一,該類具有子類,第二,該類具有父類。

 

當該類實現了Serializable接口且具有子類時,根據官方文檔中的說明,其子類天然具有可被序列化的屬性,不需要顯式實現Serializable接口;。

 All subtypes of a serializable class are themselves serializable. 

 

當該類實現了Serializable接口且具有父類時,,該類的父類需要實現Serializable接口嗎?在JDK8中Serializable接口的官方文檔中有這樣一段話:

 1 /**
 2  * ......
 3  *
 4  * To allow subtypes of non-serializable classes to be serialized, the
 5  * subtype may assume responsibility for saving and restoring the
 6  * state of the supertype's public, protected, and (if accessible)
 7  * package fields.  The subtype may assume this responsibility only if
 8  * the class it extends has an accessible no-arg constructor to
 9  * initialize the class's state.  It is an error to declare a class
10  * Serializable if this is not the case.  The error will be detected at
11  * runtime. 
12  *
13  * During deserialization, the fields of non-serializable classes will
14  * be initialized using the public or protected no-arg constructor of
15  * the class.  A no-arg constructor must be accessible to the subclass
16  * that is serializable.  The fields of serializable subclasses will
17  * be restored from the stream. 
18  */

 

閱讀文檔我們得知,為了使得不可序列化類的子類能夠序列化,其子類必須擔負起保存和恢復其超類的public、protected 和 package(if accessible)實例域的責任,且要求其父類必須有一個可訪問的無參構造函數以使得在反序列化時能夠初始化實例域。

 

我們寫代碼驗證一下,如果父類中沒有可訪問的無參構造函數會發生什麼,注意Person類中沒有無參構造函數。

 1 import org.junit.Test;
 2 
 3 import java.io.*;
 4 
 5 public class SerializableTest {
 6 
 7     @Test
 8     public void testSerialize() {
 9         Student one = new Student(12, 156.6, "1234");
10         Student two = new Student(16, 177.7, "5678");
11 
12         try (ObjectOutputStream output =
13                      new ObjectOutputStream(new FileOutputStream("Student.txt"))) {
14             output.writeObject(one);
15             output.writeObject(two);
16         } catch (IOException e) {
17             e.printStackTrace();
18         }
19     }
20 
21     @Test
22     public void testDeserialize() {
23 
24         try (ObjectInputStream input =
25                      new ObjectInputStream(new FileInputStream("Student.txt"))) {
26             Student one = (Student) input.readObject();
27             Student two = (Student) input.readObject();
28 
29             System.out.println(one);
30             System.out.println(two);
31         } catch (IOException e) {
32             e.printStackTrace();
33         } catch (ClassNotFoundException e) {
34             e.printStackTrace();
35         }
36     }
37 }
38 
39 class Person{
40     protected int age;
41     protected double height;
42     
43     public Person(int age, double height) {
44         this.age = age;
45         this.height = height;
46     }
47 
48     @Override
49     public String toString() {
50         return "Person{" +
51                 "age=" + age +
52                 ", height=" + height +
53                 '}';
54     }
55 }
56 
57 class Student extends Person implements Serializable{
58     private String id;
59 
60     public Student(int age, double height, String id) {
61         super(age, height);
62         this.id = id;
63     }
64 
65     @Override
66     public String toString() {
67         return "Student{" +
68                 "age=" + age +
69                 ", height=" + height +
70                 ", id='" + id + '\'' +
71                 '}';
72     }
73 }

 

程序輸出產生異常:

java.io.InvalidClassException: Student; no valid constructor
    at java.io.ObjectStreamClass$ExceptionInfo.newInvalidClassException(ObjectStreamClass.java:150)
    at java.io.ObjectStreamClass.checkDeserialize(ObjectStreamClass.java:768)
    at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1775)
    at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1351)
    at java.io.ObjectInputStream.readObject(ObjectInputStream.java:371)
    at SerializableTest.testDeserialize(SerializableTest.java:26)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:497)
    ...

Process finished with exit code 0

 

當我們為Person類添加默認構造函數時:

 1 class Person{
 2     protected int age;
 3     protected double height;
 4 
 5     public Person() {
 6     }
 7 
 8     public Person(int age, double height) {
 9         this.age = age;
10         this.height = height;
11     }
12 
13     @Override
14     public String toString() {
15         return "Person{" +
16                 "age=" + age +
17                 ", height=" + height +
18                 '}';
19     }
20 }

 

程序輸出如下,我們可觀察到,父類中的字段都是默認值,只有子類中的字段得到了正確的序列化。出現這種情況的原因是子類並沒有擔負起序列化父類中字段的責任。

Student{age=0, height=0.0, id='1234'}
Student{age=0, height=0.0, id='5678'}

Process finished with exit code 0

 

為了解決上述問題,我們需要藉助上一節中學到的知識,使用自定義的序列化方法writeObject和readObject來主動將父類中的字段進行序列化。

 1 import org.junit.Test;
 2 
 3 import java.io.*;
 4 
 5 public class SerializableTest {
 6 
 7     @Test
 8     public void testSerialize() {
 9         Student one = new Student(12, 156.6, "1234");
10         Student two = new Student(16, 177.7, "5678");
11 
12         try (ObjectOutputStream output =
13                      new ObjectOutputStream(new FileOutputStream("Studnet.txt"))) {
14             output.writeObject(one);
15             output.writeObject(two);
16         } catch (IOException e) {
17             e.printStackTrace();
18         }
19     }
20 
21     @Test
22     public void testDeserialize() {
23 
24         try (ObjectInputStream input =
25                      new ObjectInputStream(new FileInputStream("Studnet.txt"))) {
26             Student one = (Student) input.readObject();
27             Student two = (Student) input.readObject();
28 
29             System.out.println(one);
30             System.out.println(two);
31         } catch (IOException e) {
32             e.printStackTrace();
33         } catch (ClassNotFoundException e) {
34             e.printStackTrace();
35         }
36     }
37 }
38 
39 class Person{
40     protected int age;
41     protected double height;
42 
43     public Person() {
44     }
45 
46     public Person(int age, double height) {
47         this.age = age;
48         this.height = height;
49     }
50 
51     @Override
52     public String toString() {
53         return "Person{" +
54                 "age=" + age +
55                 ", height=" + height +
56                 '}';
57     }
58 }
59 
60 class Student extends Person implements Serializable{
61     private String id;
62 
63     public Student(int age, double height, String id) {
64         super(age, height);
65         this.id = id;
66     }
67 
68     private void writeObject(java.io.ObjectOutputStream out)
69             throws IOException {
70         out.defaultWriteObject();
71         out.writeInt(age);
72         out.writeDouble(height);
73     }
74     
75     private void readObject(java.io.ObjectInputStream in)
76             throws IOException, ClassNotFoundException {
77         in.defaultReadObject();
78         this.age = in.readInt();
79         this.height = in.readDouble();
80     }
81 
82     @Override
83     public String toString() {
84         return "Student{" +
85                 "age=" + age +
86                 ", height=" + height +
87                 ", id='" + id + '\'' +
88                 '}';
89     }
90 }

 

程序輸出如下,可以看到完全正確。

Student{age=12, height=156.6, id='1234'}
Student{age=16, height=177.7, id='5678'}

Process finished with exit code 0

 

五、serialVersionUID的作用及自動生成

 

我們知道,反序列化必須擁有class文件,但隨着項目的升級,class文件也會升級,序列化怎麼保證升級前後的兼容性呢?

 

java序列化提供了一個private static final long serialVersionUID 的序列化版本號,只有版本號相同,即使更改了序列化屬性,對象也可以正確被反序列化回來。如果反序列化使用的class的版本號與序列化時使用的不一致,反序列化會報InvalidClassException異常。下面是JDK 8中ArrayList的源碼中的serialVersionUID。

 

 1 public class ArrayList<E> extends AbstractList<E>
 2         implements List<E>, RandomAccess, Cloneable, java.io.Serializable
 3 {
 4     private static final long serialVersionUID = 8683452581122892189L;
 5 
 6     /**
 7      * Default initial capacity.
 8      */
 9     private static final int DEFAULT_CAPACITY = 10;
10     ...  
11 }

 

序列化版本號可自由指定,如果不指定,JVM會根據類信息自己計算一個版本號,這樣隨着class的升級,就無法正確反序列化;不指定版本號另一個明顯隱患是,不利於jvm間的移植,可能class文件沒有更改,但不同jvm可能計算的規則不一樣,這樣也會導致無法反序列化。

 

什麼情況下需要修改serialVersionUID呢?分三種情況。

  • 如果只是修改了方法,反序列化不容影響,則無需修改版本號
  • 如果只是修改了靜態Field或瞬態Field,則反序列化不受任何影響
  •  如果修改類時修改了非靜態Field、非瞬態Field,則可能導致序列化版本不兼容。如果對象流中的對象和新類中包含同名的Field,而Field類型不同,則反序列化失敗,類定義應該更新serialVersionUID Field值。如果只是新增了實例變量,則反序列化回來新增的是默認值;如果減少了實例變量,反序列化時會忽略掉減少的實例變量。

 

我們在日常編程實踐中,一般會選擇使用IDE來自動生成serialVersionUID,這樣可以最大化地減少重複的可能性。對於IntelliJ IDEA,自動生成serialVersionUID有三步:

  • 修改IDEA配置:File->Setting->Editor->Inspections->Serialization issues->Serializable class without ’serialVersionUID’
  • 類實現Serializable接口
  • 在類名上執行Alt+Enter,然後選擇生成serialVersionUID即可

 

六、序列化的缺點

 

Java序列化存在四個致命缺點,導致其不適用於網絡傳輸:

  • 無法跨語言:在網絡傳輸中,經常會有異構語言的進程的交互,但Java序列化技術是Java語言內部的私有協議,其他語言無法進行反序列化。目前所有流行的RPC框架都沒有使用Java序列化作為編解碼框架。
  • 潛在風險高:不可信流的反序列化可能導致遠程代碼執行(RCE)、拒絕服務(DoS)和一系列其他攻擊。
  • 序列化后的碼流太大
  • 序列化的性能較低

 

在真正的生產環境中,一般會選擇其它編解碼框架,領先的跨平台結構化數據表示是 JSON 和 Protocol Buffers,也稱為 protobuf。JSON 由 Douglas Crockford 設計用於瀏覽器與服務器通信,Protocol Buffers 由谷歌設計用於在其服務器之間存儲和交換結構化數據。JSON 和 protobuf 之間最顯著的區別是 JSON 是基於文本的,並且是人類可讀的,而 protobuf 是二進制的,但效率更高。

 

 七、參考文獻

 

  1. 《瘋狂Java講義》第2版,李剛著,电子工業出版社
  2. 《Java核心技術》第10版,霍斯特曼等著,机械工業出版本
  3. 《Netty權威指南》第2版,李林鋒著,电子工業出版社
  4. 《Effective Java》第2版,Joshua Bloch著,机械工業出版社

 

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

【其他文章推薦】

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

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

※回頭車貨運收費標準

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

※超省錢租車方案

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

※推薦台中搬家公司優質服務,可到府估價

我們是如何做DevOps的?

一、DevOps的理解

DevOps的概念理解

DevOps 的概念在軟件開發行業中逐漸流行起來。越來越多的團隊希望實現產品的敏捷開發,DevOps 使一切成為可能。有了 DevOps ,團隊可以定期發布代碼、自動化部署、並將持續集成 / 持續交付作為發布過程的一部分。
一句話概括就是提高生產力,快速交付!

二、引入DevOps的背景

2.1 福祿技術棧介紹

  • 後端開發框架:基於C#的.netCore和Java的SpringCloud,少部分項目採用python和go開發

  • 前端開發框架:vue、react

  • 服務部署:前端站點基於ECS的nginx部署 ,後端服務統一部署在kubernetes上

  • 代碼倉庫:gitlab

  • 項目環境:目前有6套,開發、測試、壓測、集成、PRE和生產

2.2 後端服務的CICD現狀

                                                                                                 福祿後端CICD流程

CICD 流程說明

每一次的代碼push,根據創建的分支,根據在gitlab的CICD文件gitlab.yml定義構建步驟,觸發runner,從單元測試、通過dockerfile進行編譯和生成鏡像版本、將新鏡像部署到K8S生成pod,然後觸發接口自動化測試任務的執行

!!#00ffff 好像缺了點什麼 !!

  • 初次部署應用到kubernetes怎麼做的?

  • 服務的configmap在哪裡維護的?

  • 每個服務的gitlab.yml文件都不一樣,如何維護的?

  • 應用的域名解析怎麼做?

目前有6套環境進行管理,其中開發、測試、集成、壓測都是測試人員維護,預發布和生產運維人員維護;這也就要求每一個測試人員都必須對整個cicd流程和配置絕對掌握;所以當新人入職,需要掌握整個流程才能進入項目測試中,這是一個學習成本;

預發布和生產的kubernetes只有運維能夠操作,當有新的服務需要上線上述環境,或者configmap有變動,或者有時候排查問題需要查看容器日誌,我們只能通過運維的工單系統描述作業操作,中間文字描述可能存在理解差異,溝通成本和時間成本很大;

有的新應用我們去設置cicd的相關文件,比如dockerfile,我們發現應用的代碼目錄結構各種各樣,這樣往往就沒法套用一個模板快速配置完成

2.3 前端站點的CICD現狀

前端CICD流程說明

開發人員push代碼到gitlab,測試人員通過jenkins拉取最新的代碼到jenkins本地,然後通過jenkins與服務器之間的傳輸管道,將要部署的文件更新到目標服務器,並觸發UI自動化的job

完整的過程來看,也缺點內容

  • 一個新的站點部署,nginx需要做一些配置初始化工作,比如域名、路徑的配置
  • 前端的配置文件是如何管理的

跟後端應用一樣,前端的PRE和生產環境也是運維處理,所以當一個新的應用上線我們也需要發工單,描述具體操作,然後運維執行工單;配置文件一般不會變更,所以我們在jenkins推送更新文件到目標服務器的時候,將配置文件做了過濾處理。後續需要變更通過工單執行

2.3 痛點你看到了嗎

2.3.1 安全管控缺位
  • 代碼安全:CICD的起點在gitlab裏面,所以大家都有gitlab的賬號,代碼安全管控缺位
  • 線上安全:線上項目部署也是通過gitlab的cicd直接觸發,審批流程缺失
2.3.2 管理成本
  • 維護賬號多:gitlab賬號、jenkins賬號、kubernetes賬號(本地和阿里雲),每一個人員都需要上述賬號,運維管理麻煩,大家每個平台維護自己的賬號也麻煩
  • 工單溝通:工單編寫、溝通過程花費時間較多
  • 代碼規範:項目組多,微服務也多,代碼框架各自發揮,無論是流程維護還是問題排查都增加了難度

三、研發管理平台(RDMS)應運而生

3.1 如何理解這個平台

!!#ff0000 工具鏈到平台的轉變 !!

當前的cicd是對工具鏈進行了打通,但需要大家登錄各個工具平台操作,我們希望對工具集進行功能整合,打造一個系統平台,並且將CICD的技術細節進行屏蔽,開發人員能夠專心進行業務需求的開發,測試人員能夠專註到需求測試任務中,而運維人員能夠解放繁重的工單內容,投入到服務高可用的建設上!

3.2 業務功能設計

                                                                                                                                  福祿研發管理平台功能結構圖

3.2.1 功能說明
  • 項目管理:項目的創建和維護,默認提供了.netcore的api和控制台,java的api和前端站點的應用初始化代碼框架,開發人員開發新的應用直接根據應用類型選擇對應的模板就可以在git默認創建代碼倉庫和初始化框架代碼,並自動生成應用的http和https的域名
  • 構建記錄:獲取gitlab的pipeline,展示所有分支的構建記錄信息,可以一鍵跳轉到git倉庫
  • 部署管理:部署構建的鏡像到指定的環境,提供實時部署和定時部署功能
  • 容器管理:提供容器的查看功能,可以看到容器的存活狀態和容器實時日誌
  • 配置字段權限申請:針對PRE和生產環境查看配置,需要先走釘釘審批申請流程
  • 配置信息:進行配置的維護,包括新增、編輯、刪除,PRE和生產環境操作需要釘釘流程審批
  • 操作日誌:針對應用的操作日誌記錄
  • 用戶設置:在使用rdms前,需要先將用戶git倉庫的token設置在rdms上,這樣用戶在rdms操作與gitlab相關的業務才能正常使用
3.2.2 RDMS幾個核心頁面的展示

首頁-創建應用

構建記錄

部署管理

容器管理

3.3 技術架構

對接系統的說明

  • 通行證:RDMS的目標用戶是研發中心人員,這些人員在通行證中都有默認的賬戶信息,與通行證打通,可以直接登錄使用
  • GitlabAPI:目前RDMS的CI還是採用的gitlab的ci支撐,包括新應用在rdms的創建到git倉庫的代碼初始化等,都需要調用gitlab的api接口
  • 釘釘flow:安全管控的原因,PRE和生產的任何操作都會觸發釘釘審批流,所屬項目的項目經理審批通過後才會獲取到數據或者執行操作指令
  • 福祿開放平台:提供了網關相關的功能和菜單、角色等維護功能,公司所有後端服務都需要入駐開放平台
  • 蜂巢:公司的調度作業平台,rdms的定時部署功能依賴該服務的支撐
  • 運維工單系統:rdms的CD流程沒有直接與kubernets進行交互,而是通過運維的工單系統包裝了運維底層的shell腳本層,然後提供給rdms相關的api接口,也是基於安全控制的考慮
  • shell腳本層:shell腳本層會調用kubernetes的api進行kubernetes的相關操作(部署、配置更新、容器重啟、日誌查看等);調用阿里雲的dns解析接口,對應用的域名自動解析;調用oss的接口,進行前端站點文件目錄的維護

3.4 後端應用的devops實現詳解

舉個栗子進行介紹

根據模板,創建一個應用

根據名稱默認生成域名

初始化代碼倉庫,默認生成develop分支

在rdms第一次部署到對應環境(開發、測試、生產等)時,會默認讀取appsettings.Development.json的文件,並寫入kubernets的configmap

構建完成,進行部署

在kubernets生成pod

通過域名訪問接口文檔

3.5 前端站點的devops實現詳解

同樣的,舉個栗子介紹

首頁-創建前端站點

根據名稱生成域名

初始化代碼倉庫,默認生成develop分支

配置文件,默認生成幾套環境的配置文件,站點的配置維護就是維護這幾個文件

部署應用

kubernetes的nginx容器內可以看到部署的文件,實際就是掛載的oss到該pod上

四、展望

目前RDMS投產一個月左右,我們希望能將devops理念在這個系統上進行持續的優化和實踐,包括研發中心小夥伴也很踴躍參与共建,提出了很多好的方向和建議

  • 完善devops鏈條功能:通過字面來看有dev、ops兩部分,我們後期需要加入test的比重,比如在CI部分,引入靜態代碼掃描、單測覆蓋率;在CD部分集成我們的自動化測、性能測試
  • 工具平台:RDMS的初衷是整合,針對研發中心經常使用的工具或者有相關工具化的需求,我們可以整合到rdms或者在RDMS上進行開發

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

【其他文章推薦】

※超省錢租車方案

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

※回頭車貨運收費標準

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

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

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

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

核災後日本中斷鐵道再開 JR員工憂輻射被曝抗議不斷

文:宋瑞文(媽媽監督核電廠聯盟特約撰述)

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

【其他文章推薦】

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

※如何讓商品強力曝光呢? 網頁設計公司幫您建置最吸引人的網站,提高曝光率!

※綠能、環保無空污,成為電動車最新代名詞,目前市場使用率逐漸普及化

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

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

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