1、背景
接觸talend也挺長一段時間了,在P&G項目中天天都是使用它開發job,作ETL,也看了前輩開發的不少ETL Job,學到很多。也接觸了TAC(talend administration center),也發現了TAC的一些優勢和不足。前端
優勢:數據庫
一、TAC能夠更好的界面化管理job、部署、HA等,提高了job運行的良好環境。tomcat
二、經過plan能夠更好的將不一樣的job進行關聯成線,更好的對數據處理作到先後順序有秩。框架
三、日誌比較全,還能夠選擇不一樣的日誌級別。性能
缺點:測試
一、整個管理前端是基於tomcat的,並且整個管理網站效率不是特別好,使用起來相對沒有C/S端的流暢。網站
二、時不時會由於系統的一些穩定性問題,帶來一些job運行上的異常,並且這部分監控沒法捕獲。ui
三、整個網頁使用起來不是很是的順手,主要體如今:卡頓、時不時刷新下、偶爾出現異常等。spa
四、運行統計上面不是符合全部的場景,好比P&G,他們job是分層(STG,DWD,DWS,DM)的,即每一層的數據都是不一樣的job,這個時候的統計就不能僅僅是按照job運行來統計,有時候要幾個job合併,有時候要這個job取一點數據,另外一個job取一點數據,這種場景下就不適合了。設計
還有一點就是P&G大部分都是經過job來調用存儲過程來計算,這就帶來2個不和諧的地方,其一沒法得知那個job使用哪些存儲過程或者調用過哪些存儲過程,只能經過名稱的約定來得知,顯然這種方式對於追溯引用不是很是的方便。其次就是對於job和存儲過程的統計沒法產生關聯性,對於後期的一些性能分析沒法提供輔助數據依據。
所以,咱們有這樣子的一些需求:
一、能很方便的提供每一個job執行的記錄,好比常規的:開始時間、結束時間、耗時、屬於哪塊業務範圍等。
二、Job和存儲過程的關係,哪些job調用了哪些存儲過程,而且這些存儲過程的執行記錄等。
2、實現
一、 數據庫表設計
a) Job記錄表
該表主要是記錄每一個job的信息,在一個job上了production環境的時候,就要在此表生成相應的記錄。
IF (OBJECT_ID(N'[chk].[etl_job_list]', N'U') IS NOT NULL) BEGIN PRINT N'刪除表:[chk].[etl_job_list]'; DROP TABLE [chk].[etl_job_list]; END GO CREATE TABLE [chk].[etl_job_list] ( [scope] NVARCHAR(200) NOT NULL,--業務板塊說明,每一個job歸類於一類scope,如IDS,發貨、零售等 [etl_job_id] NVARCHAR(100) NOT NULL,--UNIQUEIDENTIFIER用NEWID生成並轉CHAR存儲 [etl_job_name] NVARCHAR(200) NOT NULL,--etl job名稱, [create_user] NVARCHAR(50) NOT NULL,--建立者 [create_date] DATETIME NOT NULL,--建立日期 [last_update_date] DATETIME NOT NULL,--最後更新日期 [remark] NVARCHAR(200) NULL--說明 ) GO |
對應的存儲過程,由於adw不支持惟一索引,可是job name須要有惟一約束的,因此經過建立存儲過程來建立能夠避免惟一衝突。
存儲過程:
IF (OBJECT_ID(N'[chk].[usp_insert_etl_job_list]', N'P') IS NOT NULL) BEGIN PRINT N'刪除存儲過程:[chk].[usp_insert_etl_job_list]'; DROP PROC [chk].[usp_insert_etl_job_list]; END GO CREATE PROC [chk].[usp_insert_etl_job_list] ( @scope NVARCHAR(200) ,@etl_job_name NVARCHAR(200) ,@create_user NVARCHAR(50) ,@remark NVARCHAR(200) ) AS --==================================================================================================================================== -- ProcedureName : chk.usp_insert_etl_job_list -- Author : john.xiong -- CreateDate : 2019-01-16 -- Description : insert data to chk.etl_job_list /*************************************Parameters參數說明******************************************************************************* -- @scope : 業務板塊說明,每一個job歸類於一類scope,如IDS,發貨、零售等 -- @etl_job_name : etl job 名稱 -- @create_user : 建立用戶 -- @remark : 說明/備註 **************************************Modfied List修改記錄***************************************************************************** -- Modified Date Modified User Version Modified Reason ************************************************************************************************************************************** -- 2019-01-16 john.xiong V01.00.00 初始化版本 **************************************************************************************************************************************/ --==================================================================================================================================== BEGIN BEGIN TRY DECLARE @begin_time DATETIME ,@end_time DATETIME ,@cost_time INT ,@create_date DATETIME ,@last_update_date DATETIME ,@etl_job_id NVARCHAR(100) ,@row_count INT SET @begin_time = DATEADD(HOUR, 8, GETDATE()); INSERT INTO [chk].[tb_proc_cost_log] ( [proc_name] ,[Object_name] ,[execute_time] ,[action] ,[remark] ,[cost_time] ) SELECT N'chk.usp_insert_etl_job_list' AS [proc_name] ,N'chk.etl_job_list' AS [Object_name] ,@begin_time AS [execute_time] ,N'start' AS [action] ,'' AS [remark] ,0 AS [cost_time] IF (LTRIM(RTRIM(ISNULL(@etl_job_name, ''))) = '') BEGIN RAISERROR(N'etl job name不能爲null或者空,請從新輸入!', 16, 1); END SET @row_count = 0; SELECT @row_count = COUNT(*) FROM [chk].[etl_job_list] WHERE LOWER([etl_job_name]) = LOWER(@etl_job_name); IF (@row_count > 0)/*etl job name惟一性校驗,保證惟一性*/ BEGIN RAISERROR(N'etl job name已經存在,請從新輸入!', 16, 1); END SET @scope = ISNULL(@scope, '缺省'); SET @create_user = ISNULL(@create_user, CONVERT(NVARCHAR(50), SUSER_SNAME())); SET @remark = ISNULL(@remark, ''); SET @create_date = DATEADD(HOUR, 8, GETDATE()); SET @last_update_date = @create_date; SET @etl_job_id = CONVERT(NVARCHAR(100), NEWID()); INSERT INTO [chk].[etl_job_list] ( [scope] ,[etl_job_id] ,[etl_job_name] ,[create_user] ,[create_date] ,[last_update_date] ,[remark] ) SELECT @scope ,@etl_job_id ,@etl_job_name ,@create_user ,@create_date ,@last_update_date ,@remark SET @end_time = DATEADD(HOUR, 8, GETDATE()); SET @cost_time = DATEDIFF(SECOND, @begin_time, @end_time); INSERT INTO [chk].[tb_proc_cost_log] ( [proc_name] ,[Object_name] ,[execute_time] ,[action] ,[remark] ,[cost_time] ) SELECT N'chk.usp_insert_etl_job_list' AS [proc_name] ,N'chk.etl_job_list' AS [Object_name] ,@end_time AS [execute_time] ,N'end' AS [action] ,CONVERT(NVARCHAR(50), @etl_job_id) AS [remark] ,@cost_time AS [cost_time] PRINT N'Exec success'; PRINT N'curr_etl_job_id=' + @etl_job_id; END TRY BEGIN CATCH INSERT INTO [chk].[log_proc_error_rec] ( [proc_name] ,[error_source] ,[error_time] ,[error_severity] ,[error_state] ,[error_msg] ,[log_user] ) SELECT N'chk.usp_insert_etl_job_list' AS [proc_name] ,ERROR_PROCEDURE() AS [error_source] ,DATEADD(HOUR, 8, GETDATE()) AS [error_time] ,ERROR_SEVERITY() AS [error_severity] ,ERROR_STATE() AS [error_state] ,ERROR_MESSAGE() AS [error_msg] ,SUSER_SNAME() AS [log_user] PRINT N'Exec failed'; END CATCH END |
b) Job運行記錄表
記錄每一個job的每次運行的狀況,如開始結束時間,用於計算耗時,運行狀態等是是否報錯,用於後期彌補tac調度的不足,例如能夠自定義循環判斷job是否成功,自定義配置job之間的運行依賴等。
IF (OBJECT_ID(N'[chk].[etl_job_excc_history]', N'U') IS NOT NULL) BEGIN PRINT N'刪除表:[chk].[etl_job_excc_history]'; DROP TABLE [chk].[etl_job_excc_history]; END GO CREATE TABLE [chk].[etl_job_excc_history] ( [exec_id] NVARCHAR(100) NOT NULL,--UNIQUEIDENTIFIER用NEWID生成並轉CHAR存儲 [etl_job_id] NVARCHAR(100) NOT NULL,--UNIQUEIDENTIFIER用NEWID生成並轉CHAR存儲 [etl_job_name] NVARCHAR(200) NOT NULL,--etl job名稱, [begin_date] DATETIME NULL, --etl job運行開始時間 [end_date] DATETIME NULL, --etl job運行結束時間,若是沒有結束時間就有多是運行報錯。 [exec_status] INT NULL, --0:運行報錯,1運行成功,2正在運行 [remark] NVARCHAR(500) NULL,--說明,若是是報錯就是錯誤信息。 [create_date] DATETIME NOT NULL, --建立日期 [create_user] NVARCHAR(50) NOT NULL--建立人 ) |
存儲過程:分爲insert和update兩個,用於建立和更新
建立的exec_id參數其實能夠在存儲過程當中生成,而且使用out屬性輸出,這樣子就不用每次都傳遞一個參數進去,可是由於P&G的adw數據倉庫不支持out參數,因此只能使用talend生成一個全局的guid傳遞進去,這樣子才能保證同一個ID的操做和更新等。
IF (OBJECT_ID(N'[chk].[usp_insert_etl_job_excc_history]', N'P') IS NOT NULL) BEGIN PRINT N'刪除存儲過程:[chk].[usp_insert_etl_job_excc_history]'; DROP PROC [chk].[usp_insert_etl_job_excc_history]; END GO CREATE PROC [chk].[usp_insert_etl_job_excc_history] ( @exec_id NVARCHAR(100) ,@etl_job_name NVARCHAR(200) ,@begin_date DATETIME ) AS --==================================================================================================================================== -- ProcedureName : chk.usp_insert_etl_job_excc_history -- Author : john.xiong -- CreateDate : 2019-01-16 -- Description : insert data to chk.etl_job_excc_history /*************************************Parameters參數說明******************************************************************************* -- @exec_id : UNIQUEIDENTIFIER用NEWID生成並轉CHAR存儲 -- @etl_job_id : etl job id UNIQUEIDENTIFIER用NEWID生成並轉CHAR存儲 -- @etl_job_name : etl job 名稱 -- @begin_date : job運行開始時間 **************************************Modfied List修改記錄***************************************************************************** -- Modified Date Modified User Version Modified Reason ************************************************************************************************************************************** -- 2019-01-16 john.xiong V01.00.00 初始化版本 **************************************************************************************************************************************/ --==================================================================================================================================== BEGIN BEGIN TRY DECLARE @begin_time DATETIME ,@end_time DATETIME ,@cost_time INT ,@row_count INT ,@etl_job_id NVARCHAR(100) ,@end_date DATETIME ,@exec_status INT ,@remark NVARCHAR(500) ,@create_date DATETIME ,@create_user NVARCHAR(50) SET @begin_time = DATEADD(HOUR, 8, GETDATE()); INSERT INTO [chk].[tb_proc_cost_log] ( [proc_name] ,[Object_name] ,[execute_time] ,[action] ,[remark] ,[cost_time] ) SELECT N'chk.usp_insert_etl_job_excc_history' AS [proc_name] ,N'chk.etl_job_excc_history' AS [Object_name] ,@begin_time AS [execute_time] ,N'start' AS [action] ,'' AS [remark] ,0 AS [cost_time] SET @row_count = 0; SELECT @row_count = COUNT(*) FROM [chk].[etl_job_list] WHERE LOWER([etl_job_name]) = LOWER(@etl_job_name); IF (@row_count = 0)/*etl job name惟一性校驗,保證惟一性*/ BEGIN RAISERROR(N'etl job name不存在,請從新輸入!', 16, 1); END SELECT TOP (1) @etl_job_id = [etl_job_id] FROM [chk].[etl_job_list] WHERE LOWER([etl_job_name]) = LOWER(@etl_job_name) ORDER BY [create_date] DESC SET @begin_date = ISNULL(@begin_date, DATEADD(HOUR, 8, GETDATE())); SET @end_date = NULL; SET @exec_status = 2; SET @remark = NULL; SET @create_date = DATEADD(HOUR, 8, GETDATE()); SET @create_user = CONVERT(NVARCHAR(50), SUSER_SNAME()); INSERT INTO [chk].[etl_job_excc_history] ( [exec_id] ,[etl_job_id] ,[etl_job_name] ,[begin_date] ,[end_date] ,[exec_status] ,[remark] ,[create_date] ,[create_user] ) SELECT @exec_id ,@etl_job_id ,@etl_job_name ,@begin_date ,@end_date ,@exec_status ,@remark ,@create_date ,@create_user SET @end_time = DATEADD(HOUR, 8, GETDATE()); SET @cost_time = DATEDIFF(SECOND, @begin_time, @end_time); INSERT INTO [chk].[tb_proc_cost_log] ( [proc_name] ,[Object_name] ,[execute_time] ,[action] ,[remark] ,[cost_time] ) SELECT N'chk.usp_insert_etl_job_excc_history' AS [proc_name] ,N'chk.etl_job_excc_history' AS [Object_name] ,@end_time AS [execute_time] ,N'end' AS [action] ,CONVERT(NVARCHAR(50), @etl_job_id) AS [remark] ,@cost_time AS [cost_time] PRINT N'Exec success'; PRINT N'curr_exec_id=' + @exec_id; END TRY BEGIN CATCH INSERT INTO [chk].[log_proc_error_rec] ( [proc_name] ,[error_source] ,[error_time] ,[error_severity] ,[error_state] ,[error_msg] ,[log_user] ) SELECT N'chk.usp_insert_etl_job_excc_history' AS [proc_name] ,ERROR_PROCEDURE() AS [error_source] ,DATEADD(HOUR, 8, GETDATE()) AS [error_time] ,ERROR_SEVERITY() AS [error_severity] ,ERROR_STATE() AS [error_state] ,ERROR_MESSAGE() AS [error_msg] ,SUSER_SNAME() AS [log_user] PRINT N'Exec failed'; END CATCH END |
IF (OBJECT_ID(N'[chk].[usp_update_etl_job_excc_history_by_exec_id]', N'P') IS NOT NULL) BEGIN PRINT N'刪除存儲過程:[chk].[usp_update_etl_job_excc_history_by_exec_id]'; DROP PROC [chk].[usp_update_etl_job_excc_history_by_exec_id]; END GO CREATE PROC [chk].[usp_update_etl_job_excc_history_by_exec_id] ( @exec_id NVARCHAR(100) ,@end_date DATETIME ,@exec_status INT ,@remark NVARCHAR(500) ) AS --==================================================================================================================================== -- ProcedureName : chk.usp_update_etl_job_excc_history_by_exec_id -- Author : john.xiong -- CreateDate : 2019-01-16 -- Description : update data to chk.etl_job_excc_history /*************************************Parameters參數說明******************************************************************************* -- @exec_id : 執行的GUID -- @end_date : etl job 名稱 -- @exec_status : job運行狀態0運行錯誤,1運行成功,2正在運行.... -- @remark : 說明,如錯誤信息等 **************************************Modfied List修改記錄***************************************************************************** -- Modified Date Modified User Version Modified Reason ************************************************************************************************************************************** -- 2019-01-16 john.xiong V01.00.00 初始化版本 **************************************************************************************************************************************/ --==================================================================================================================================== BEGIN BEGIN TRY DECLARE @begin_time DATETIME ,@end_time DATETIME ,@cost_time INT ,@row_count INT SET @begin_time = DATEADD(HOUR, 8, GETDATE()); INSERT INTO [chk].[tb_proc_cost_log] ( [proc_name] ,[Object_name] ,[execute_time] ,[action] ,[remark] ,[cost_time] ) SELECT N'chk.usp_update_etl_job_excc_history_by_exec_id' AS [proc_name] ,N'chk.etl_job_excc_history' AS [Object_name] ,@begin_time AS [execute_time] ,N'start' AS [action] ,'' AS [remark] ,0 AS [cost_time] SET @row_count = 0; SELECT @row_count = COUNT(*) FROM [chk].[etl_job_excc_history] WHERE [exec_id] = @exec_id; IF (@row_count = 0)/*檢查exec id是否存在*/ BEGIN RAISERROR(N'exec id不存在,請從新輸入!', 16, 1); END IF (ISNULL(@exec_status, -1) = -1) BEGIN RAISERROR(N'exec status不能爲空,請從新輸入!', 16, 1); END SET @end_date = ISNULL(@end_date, DATEADD(HOUR, 8, GETDATE())); SET @remark = ISNULL(@remark, ''); UPDATE [chk].[etl_job_excc_history] SET end_date = @end_date, [exec_status] = @exec_status, [remark] = @remark WHERE [exec_id] = @exec_id; SET @end_time = DATEADD(HOUR, 8, GETDATE()); SET @cost_time = DATEDIFF(SECOND, @begin_time, @end_time); INSERT INTO [chk].[tb_proc_cost_log] ( [proc_name] ,[Object_name] ,[execute_time] ,[action] ,[remark] ,[cost_time] ) SELECT N'chk.usp_update_etl_job_excc_history_by_exec_id' AS [proc_name] ,N'chk.etl_job_excc_history' AS [Object_name] ,@end_time AS [execute_time] ,N'end' AS [action] ,CONVERT(NVARCHAR(50), @exec_id) AS [remark] ,@cost_time AS [cost_time] PRINT N'Exec success'; PRINT N'curr_exec_id=' + @exec_id; END TRY BEGIN CATCH INSERT INTO [chk].[log_proc_error_rec] ( [proc_name] ,[error_source] ,[error_time] ,[error_severity] ,[error_state] ,[error_msg] ,[log_user] ) SELECT N'chk.usp_update_etl_job_excc_history_by_exec_id' AS [proc_name] ,ERROR_PROCEDURE() AS [error_source] ,DATEADD(HOUR, 8, GETDATE()) AS [error_time] ,ERROR_SEVERITY() AS [error_severity] ,ERROR_STATE() AS [error_state] ,ERROR_MESSAGE() AS [error_msg] ,SUSER_SNAME() AS [log_user] PRINT N'Exec failed'; END CATCH END |
c) Job&存儲過程運行記錄表
該表用於記錄每一個job 調用存儲過程的狀況,如開始結束時間等,也能夠記錄下哪些job調用了哪些存儲過程,方便查詢每一個job和存儲過程之間的關係。
Ø 參數中的etl_job_name須要用talend的jobName全局變量帶入。
Ø 剩餘的其它參數是該存儲過程實際須要的,用於作業務邏輯處理的參數。
IF (OBJECT_ID(N'[chk].[etl_job_proc_exec_history]', N'U') IS NOT NULL) BEGIN PRINT N'刪除表:[chk].[etl_job_proc_exec_history]'; DROP TABLE [chk].[etl_job_proc_exec_history]; END GO CREATE TABLE [chk].[etl_job_proc_exec_history] ( [exec_id] NVARCHAR(100) NOT NULL,--UNIQUEIDENTIFIER用NEWID生成並轉CHAR存儲 [proc_name] NVARCHAR(100) NOT NULL,--存儲過程名 [object_name] NVARCHAR(100) NOT NULL,--操做的對象名字,大部分是表名字、也能夠是其它的視圖等 [etl_job_id] NVARCHAR(100) NOT NULL,--UNIQUEIDENTIFIER用NEWID生成並轉CHAR存儲 [etl_job_name] NVARCHAR(200) NOT NULL,--etl job名稱, [begin_date] DATETIME NULL, --調用開始時間 [end_date] DATETIME NULL, --調用結束時間,若是沒有結束時間就有多是運行報錯。 [error_msg] NVARCHAR(4000) NULL,--錯誤信息 [remark] NVARCHAR(500) NULL,--備註說明 [create_date] DATETIME NOT NULL, --建立日期 [create_user] NVARCHAR(50) NOT NULL,--建立人 [last_update_date] DATETIME NULL ) GO |
d) 業務執行的存儲過程的改造
存儲過程須要增長一個參數@etl_job_name,這個是在talend中調用存儲過程的時候一塊兒傳遞進來的。
其它的參數都是核心業務處理須要的,和之前的同樣。
以下是一個測試的存儲過程
IF (OBJECT_ID(N'[chk].[usp_job_proc_exec_his_test]', N'P') IS NOT NULL) BEGIN PRINT N'刪除存儲過程:[chk].[usp_job_proc_exec_his_test]'; DROP PROC [chk].[usp_job_proc_exec_his_test]; END GO CREATE PROC [chk].[usp_job_proc_exec_his_test] ( @etl_job_name NVARCHAR(200) ,@currDate NVARCHAR(20)/*業務處理邏輯須要用的參數*/ ) AS BEGIN BEGIN TRY DECLARE @exec_id NVARCHAR(100) ,@proc_name NVARCHAR(100) ,@object_name NVARCHAR(100) ,@etl_job_id NVARCHAR(100) ,@begin_date DATETIME ,@end_date DATETIME ,@error_msg NVARCHAR(4000) ,@remark NVARCHAR(500) ,@create_date DATETIME ,@create_user NVARCHAR(50) ,@last_update_date DATETIME SET @etl_job_id = ''; SELECT @etl_job_id = [a].[etl_job_id] FROM [chk].[etl_job_list] AS a WHERE LOWER([a].[etl_job_name]) = LOWER(@etl_job_name) SET @etl_job_id = ISNULL(@etl_job_id, ''); SET @exec_id = CONVERT(NVARCHAR(100), NEWID());/*生成exec_id*/ SET @proc_name = 'chk.job_proc_exec_his_test'; SET @object_name = 'you_table_name';/*例如:stg.envt_ids_sales_daily*/ SET @begin_date = DATEADD(HOUR, 8, GETDATE()); SET @create_date = @begin_date; SET @last_update_date = @create_date; SET @create_user = CONVERT(NVARCHAR(50), SUSER_SNAME()); SET @end_date = NULL; SET @remark = @currDate; /*記錄開始*/ INSERT INTO [chk].[etl_job_proc_exec_history] ( [exec_id] ,[proc_name] ,[object_name] ,[etl_job_id] ,[etl_job_name] ,[begin_date] ,[end_date] ,[error_msg] ,[remark] ,[create_date] ,[create_user] ,[last_update_date] ) SELECT @exec_id ,@proc_name ,@object_name ,@etl_job_id ,@etl_job_name ,@begin_date ,@end_date ,NULL ,@remark ,@create_date ,@create_user ,@last_update_date /*其它的業務處理邏輯*/ --SELECT 1/0 AS [ret]/*樣例:有意引起錯誤*/ SELECT DATEADD(HOUR, 8, GETDATE()); /*記錄正常結束*/ SET @end_date = DATEADD(HOUR, 8, GETDATE()); SET @last_update_date = @end_date; UPDATE [chk].[etl_job_proc_exec_history] SET [end_date] = @end_date, [last_update_date] = @last_update_date WHERE [exec_id] = @exec_id; PRINT N'Exec success'; END TRY BEGIN CATCH INSERT INTO [chk].[log_proc_error_rec] ( [proc_name] ,[error_source] ,[error_time] ,[error_severity] ,[error_state] ,[error_msg] ,[log_user] ) SELECT N'chk.job_proc_exec_his_test' AS [proc_name] ,ERROR_PROCEDURE() AS [error_source] ,DATEADD(HOUR, 8, GETDATE()) AS [error_time] ,ERROR_SEVERITY() AS [error_severity] ,ERROR_STATE() AS [error_state] ,ERROR_MESSAGE() AS [error_msg] ,SUSER_SNAME() AS [log_user] /*記錄異常結束*/ SET @end_date = DATEADD(HOUR, 8, GETDATE()); SET @last_update_date = @end_date; SET @error_msg = ERROR_MESSAGE(); UPDATE [chk].[etl_job_proc_exec_history] SET [end_date] = @end_date, [error_msg] = @error_msg, [last_update_date] = @last_update_date WHERE [exec_id] = @exec_id; PRINT N'Exec failed'; END CATCH END |
二、 talend job記錄每一個job的開始和結束信息
設置exec id,經過UUID獲取,還有其它的一些變量
記錄開始
建立結束的一些參數
更新結束的記錄
3、總結
一、 Talend 的tac由於一些調度薄弱,因此咱們的job就須要記錄更多的一些信息來輔助後期的管理和追查緣由。
二、 藉助這套日誌小框架,咱們也能夠更好的管理talend經過調用存儲過程這樣子的方式的job,提高一些可管理性、錯誤識別方面的能力。
三、 Talend的job的統一錯誤處理感受仍是比較薄弱,就是能有一個統一的錯誤處理的若是,而不用依賴於每一個組件去判斷是否有發生錯誤。(可能我尚未發現吧)
若是您以爲此文章對您有幫助,請點擊右下方【推薦】讓更多人看到,thanks!