[{"data":1,"prerenderedAt":687},["ShallowReactive",2],{"portfolio-\u002Fportfolio\u002Ftraining-hangfire":3},{"id":4,"title":5,"award":6,"body":7,"cover":667,"description":69,"extension":668,"featured":669,"link":6,"meta":670,"navigation":185,"order":176,"path":671,"pinned":669,"repo":672,"role":673,"seo":674,"stack":675,"stem":683,"summary":684,"year":685,"__hash__":686},"portfolio\u002Fportfolio\u002Ftraining-hangfire.md","氣象開放資料排程系統",null,{"type":8,"value":9,"toc":659},"minimark",[10,14,18,21,37,40,63,370,374,426,430,445,505,531,622,625,646,655],[11,12,13],"h2",{"id":13},"專案概述",[15,16,17],"p",{},"剛入行時寫的練習專案，目的不是做出產品，而是紮實地把「分層架構＋背景排程」\n這套組合走過一遍：串接台灣中央氣象局開放資料 API，抓取各地觀測站的氣象資\n料，用 Hangfire 排程定期同步進資料庫。因為是練習專案，程式庫是私有的，這裡\n把設計上的重點整理出來。",[11,19,20],{"id":20},"分層架構",[15,22,23,24,28,29,32,33,36],{},"Controller → Service → Repository → UnitOfWork 四層，Repository 用泛型\n",[25,26,27],"code",{},"IRepository\u003CTEntity>"," 統一封裝 CRUD，UnitOfWork 再統一管理 ",[25,30,31],{},"SaveChanges","\n與交易邊界，Service 層只依賴介面，不直接碰 ",[25,34,35],{},"DbContext","。",[11,38,39],{"id":39},"排程流程",[15,41,42,43,46,47,50,51,54,55,58,59,62],{},"伺服器啟動時，",[25,44,45],{},"HangfireJobTrigger.OnStart()"," 會先清掉既有的\nRecurring Job 再重新註冊一次，避免重新部署造成同一個排程被註冊兩次；接著用\n兩段式的 Job 串接：先由排程觸發的 ",[25,48,49],{},"CreateJob"," 建立一筆工作紀錄，再用\n",[25,52,53],{},"BackgroundJob.Enqueue"," 呼叫實際抓資料的 ",[25,56,57],{},"UpdateMeteorologicalDataAsync","，\n並把前一個 Job 的 Id 當作 ",[25,60,61],{},"sourceJobId"," 傳進去，方便事後追查某次同步是哪個\n排程觸發的。",[64,65,70],"pre",{"className":66,"code":67,"language":68,"meta":69,"style":69},"language-csharp shiki shiki-themes github-dark","public void OnStart()\n{\n    \u002F\u002F 清除已有的排程工作，避免重新部署造成排程重複註冊\n    using (var connection = JobStorage.Current.GetConnection())\n    {\n        foreach (var recurringJob in connection.GetRecurringJobs())\n            RecurringJob.RemoveIfExists(recurringJob.Id);\n    }\n\n    \u002F\u002F 每 10 分鐘觸發一次\n    RecurringJob.AddOrUpdate\u003CIHangfireJobs>(\n        x => x.CreateJob(null),\n        cronExpression: \"0\u002F10 * * * *\",\n        timeZone: TimeZoneInfo.Local);\n}\n\npublic async Task UpdateMeteorologicalDataAsync(PerformContext context, string sourceJobId)\n{\n    \u002F\u002F 呼叫氣象局 API 取得原始資料\n    var sourceData = await _meteorologicalOpenDataService.GetSourceDataAsync();\n\n    \u002F\u002F 清洗、轉換後寫入資料庫（同觀測站的舊資料先刪除再整批寫入）\n    await _meteorologicalService.UpdateAsync(sourceData);\n}\n","csharp","",[25,71,72,92,98,105,132,138,162,174,180,187,193,211,235,251,260,266,271,305,310,316,339,344,350,365],{"__ignoreMap":69},[73,74,77,81,84,88],"span",{"class":75,"line":76},"line",1,[73,78,80],{"class":79},"snl16","public",[73,82,83],{"class":79}," void",[73,85,87],{"class":86},"svObZ"," OnStart",[73,89,91],{"class":90},"s95oV","()\n",[73,93,95],{"class":75,"line":94},2,[73,96,97],{"class":90},"{\n",[73,99,101],{"class":75,"line":100},3,[73,102,104],{"class":103},"sAwPA","    \u002F\u002F 清除已有的排程工作，避免重新部署造成排程重複註冊\n",[73,106,108,111,114,117,120,123,126,129],{"class":75,"line":107},4,[73,109,110],{"class":79},"    using",[73,112,113],{"class":90}," (",[73,115,116],{"class":79},"var",[73,118,119],{"class":86}," connection",[73,121,122],{"class":79}," =",[73,124,125],{"class":90}," JobStorage.Current.",[73,127,128],{"class":86},"GetConnection",[73,130,131],{"class":90},"())\n",[73,133,135],{"class":75,"line":134},5,[73,136,137],{"class":90},"    {\n",[73,139,141,144,146,148,151,154,157,160],{"class":75,"line":140},6,[73,142,143],{"class":79},"        foreach",[73,145,113],{"class":90},[73,147,116],{"class":79},[73,149,150],{"class":86}," recurringJob",[73,152,153],{"class":79}," in",[73,155,156],{"class":90}," connection.",[73,158,159],{"class":86},"GetRecurringJobs",[73,161,131],{"class":90},[73,163,165,168,171],{"class":75,"line":164},7,[73,166,167],{"class":90},"            RecurringJob.",[73,169,170],{"class":86},"RemoveIfExists",[73,172,173],{"class":90},"(recurringJob.Id);\n",[73,175,177],{"class":75,"line":176},8,[73,178,179],{"class":90},"    }\n",[73,181,183],{"class":75,"line":182},9,[73,184,186],{"emptyLinePlaceholder":185},true,"\n",[73,188,190],{"class":75,"line":189},10,[73,191,192],{"class":103},"    \u002F\u002F 每 10 分鐘觸發一次\n",[73,194,196,199,202,205,208],{"class":75,"line":195},11,[73,197,198],{"class":90},"    RecurringJob.",[73,200,201],{"class":86},"AddOrUpdate",[73,203,204],{"class":90},"\u003C",[73,206,207],{"class":86},"IHangfireJobs",[73,209,210],{"class":90},">(\n",[73,212,214,217,220,223,225,228,232],{"class":75,"line":213},12,[73,215,216],{"class":86},"        x",[73,218,219],{"class":79}," =>",[73,221,222],{"class":90}," x.",[73,224,49],{"class":86},[73,226,227],{"class":90},"(",[73,229,231],{"class":230},"sDLfK","null",[73,233,234],{"class":90},"),\n",[73,236,238,241,244,248],{"class":75,"line":237},13,[73,239,240],{"class":86},"        cronExpression",[73,242,243],{"class":90},": ",[73,245,247],{"class":246},"sU2Wk","\"0\u002F10 * * * *\"",[73,249,250],{"class":90},",\n",[73,252,254,257],{"class":75,"line":253},14,[73,255,256],{"class":86},"        timeZone",[73,258,259],{"class":90},": TimeZoneInfo.Local);\n",[73,261,263],{"class":75,"line":262},15,[73,264,265],{"class":90},"}\n",[73,267,269],{"class":75,"line":268},16,[73,270,186],{"emptyLinePlaceholder":185},[73,272,274,276,279,282,285,287,290,293,296,299,302],{"class":75,"line":273},17,[73,275,80],{"class":79},[73,277,278],{"class":79}," async",[73,280,281],{"class":86}," Task",[73,283,284],{"class":86}," UpdateMeteorologicalDataAsync",[73,286,227],{"class":90},[73,288,289],{"class":86},"PerformContext",[73,291,292],{"class":86}," context",[73,294,295],{"class":90},", ",[73,297,298],{"class":79},"string",[73,300,301],{"class":86}," sourceJobId",[73,303,304],{"class":90},")\n",[73,306,308],{"class":75,"line":307},18,[73,309,97],{"class":90},[73,311,313],{"class":75,"line":312},19,[73,314,315],{"class":103},"    \u002F\u002F 呼叫氣象局 API 取得原始資料\n",[73,317,319,322,325,327,330,333,336],{"class":75,"line":318},20,[73,320,321],{"class":79},"    var",[73,323,324],{"class":86}," sourceData",[73,326,122],{"class":79},[73,328,329],{"class":79}," await",[73,331,332],{"class":90}," _meteorologicalOpenDataService.",[73,334,335],{"class":86},"GetSourceDataAsync",[73,337,338],{"class":90},"();\n",[73,340,342],{"class":75,"line":341},21,[73,343,186],{"emptyLinePlaceholder":185},[73,345,347],{"class":75,"line":346},22,[73,348,349],{"class":103},"    \u002F\u002F 清洗、轉換後寫入資料庫（同觀測站的舊資料先刪除再整批寫入）\n",[73,351,353,356,359,362],{"class":75,"line":352},23,[73,354,355],{"class":79},"    await",[73,357,358],{"class":90}," _meteorologicalService.",[73,360,361],{"class":86},"UpdateAsync",[73,363,364],{"class":90},"(sourceData);\n",[73,366,368],{"class":75,"line":367},24,[73,369,265],{"class":90},[11,371,373],{"id":372},"資料轉換automapper","資料轉換（AutoMapper）",[15,375,376,377,380,381,380,384,387,388,391,392,395,396,399,400,403,404,399,407,410,411,414,415,418,419,418,422,425],{},"氣象局回傳的原始 JSON（",[25,378,379],{},"Records"," → ",[25,382,383],{},"Location",[25,385,386],{},"WeatherElement"," \u002F\n",[25,389,390],{},"Parameter","）跟系統內部要用的模型長得不一樣，中間用四個 AutoMapper\n",[25,393,394],{},"Profile","（",[25,397,398],{},"ObservatoryInfoDtoProfile","、",[25,401,402],{},"WeatherInfoDtoProfile","、\n",[25,405,406],{},"RegionInfoDtoProfile",[25,408,409],{},"MeteorologicalDtoProfile","）把觀測站資訊、天氣資\n訊、地區資訊分開轉換，再由 Service 層組成一個完整的 ",[25,412,413],{},"MeteorologicalDto","，\n最後才落地成 ",[25,416,417],{},"ObservatoryInfoModel"," \u002F ",[25,420,421],{},"WeatherInfoModel",[25,423,424],{},"RegionInfoModel","\n三張表。同一個觀測站再次同步時，是先刪除舊的關聯資料再整批寫入新的，而不是\n逐欄位比對更新。",[11,427,429],{"id":428},"測試策略mstest","測試策略（MSTest）",[15,431,432,433,395,437,440,441,444],{},"專案拆成三個測試專案，",[434,435,436],"strong",{},"全部使用 MSTest",[25,438,439],{},"MSTest.TestFramework"," +\n",[25,442,443],{},"MSTest.TestAdapter","），搭配 FluentAssertions 斷言、NSubstitute 做替身、\nAutoFixture 產生測試資料：",[446,447,448,479,487],"ul",{},[449,450,451,456,457,460,461,464,465,403,468,399,471,474,475,478],"li",{},[434,452,453],{},[25,454,455],{},"Training.Hangfire.RepositoryTests","：用 ",[25,458,459],{},"RichardSzalay.MockHttp","\n假造 ",[25,462,463],{},"HttpClient"," 的回應，搭配固定的假 JSON（",[25,466,467],{},"SuccessFalse.json",[25,469,470],{},"locationNull.json",[25,472,473],{},"TwoLocation.json","）驗證 Repository 在「空白回\n應」「",[25,476,477],{},"success"," 為 false」「地點筆數為 0」「正常回傳兩筆」等情境下的解析\n邏輯，完全不需要真的打氣象局 API",[449,480,481,486],{},[434,482,483],{},[25,484,485],{},"Training.Hangfire.ServiceTests","：針對 DTO 轉換與商業邏輯做單元測試",[449,488,489,456,494,497,498,387,501,504],{},[434,490,491],{},[25,492,493],{},"Training.Hangfire.IntegrationTest",[25,495,496],{},"TestEnvironment.Docker"," 在\n測試時起一個真的 MSSQL 容器，每個測試用 ",[25,499,500],{},"BeginTransaction",[25,502,503],{},"RollbackTransaction"," 包起來，測完自動回滾，驗證「同觀測站舊資料存在時\n會先刪除再更新」「不存在時直接新增」這類跟真實資料庫行為有關的邏輯",[15,506,507,508,387,511,418,514,418,517,520,521,399,524,399,527,530],{},"測試方法命名走 Given-When-Then 的中文敘述，搭配 ",[25,509,510],{},"[TestClass]",[25,512,513],{},"[TestMethod]",[25,515,516],{},"[TestInitialize]",[25,518,519],{},"[TestCleanup]"," 以及\n",[25,522,523],{},"[Owner]",[25,525,526],{},"[TestCategory]",[25,528,529],{},"[TestProperty]"," 這些 MSTest 的屬性標記測試\n的負責人與被測方法：",[64,532,534],{"className":66,"code":533,"language":68,"meta":69,"style":69},"[TestMethod()]\n[Owner(\"G8672\")]\n[TestCategory(nameof(ObservatoryInfoService))]\n[TestProperty(nameof(ObservatoryInfoService), nameof(ObservatoryInfoService.UpdateAsync))]\npublic async Task UpdateAsync_輸入StationId為10點371770並且資料庫存在此筆資料_觸發刪除並完成更新()\n{\n    \u002F\u002F arrange \u002F act \u002F assert\n}\n",[25,535,536,547,562,577,596,609,613,618],{"__ignoreMap":69},[73,537,538,541,544],{"class":75,"line":76},[73,539,540],{"class":90},"[",[73,542,543],{"class":86},"TestMethod",[73,545,546],{"class":90},"()]\n",[73,548,549,551,554,556,559],{"class":75,"line":94},[73,550,540],{"class":90},[73,552,553],{"class":86},"Owner",[73,555,227],{"class":90},[73,557,558],{"class":246},"\"G8672\"",[73,560,561],{"class":90},")]\n",[73,563,564,566,569,571,574],{"class":75,"line":100},[73,565,540],{"class":90},[73,567,568],{"class":86},"TestCategory",[73,570,227],{"class":90},[73,572,573],{"class":79},"nameof",[73,575,576],{"class":90},"(ObservatoryInfoService))]\n",[73,578,579,581,584,586,588,591,593],{"class":75,"line":107},[73,580,540],{"class":90},[73,582,583],{"class":86},"TestProperty",[73,585,227],{"class":90},[73,587,573],{"class":79},[73,589,590],{"class":90},"(ObservatoryInfoService), ",[73,592,573],{"class":79},[73,594,595],{"class":90},"(ObservatoryInfoService.UpdateAsync))]\n",[73,597,598,600,602,604,607],{"class":75,"line":134},[73,599,80],{"class":79},[73,601,278],{"class":79},[73,603,281],{"class":86},[73,605,606],{"class":86}," UpdateAsync_輸入StationId為10點371770並且資料庫存在此筆資料_觸發刪除並完成更新",[73,608,91],{"class":90},[73,610,611],{"class":75,"line":140},[73,612,97],{"class":90},[73,614,615],{"class":75,"line":164},[73,616,617],{"class":103},"    \u002F\u002F arrange \u002F act \u002F assert\n",[73,619,620],{"class":75,"line":176},[73,621,265],{"class":90},[11,623,624],{"id":624},"學到的東西",[446,626,627,630,636,643],{},[449,628,629],{},"第一次完整體會「為什麼要把資料存取邏輯抽到 Repository／UnitOfWork」，而不\n是把 EF Core 直接寫在 Service 裡",[449,631,632,633,635],{},"Hangfire 排程工作的基本用法與常見陷阱（部署時如何避免排程重複註冊、用\n",[25,634,61],{}," 串起因果關係）",[449,637,638,639,642],{},"用假的 HTTP 回應（",[25,640,641],{},"MockHttp"," + fixture JSON）驗證會呼叫外部 API 的\nRepository，讓測試穩定、不依賴氣象局 API 是否可用",[449,644,645],{},"用 Docker 容器起一個真的 MSSQL 做整合測試，比起完全 Mock 資料庫，更能驗\n證交易、關聯資料刪除等真實資料庫才會出現的行為，也是後來重視整合測試的\n起點",[15,647,648,649,654],{},"這個專案後來也成了 ",[650,651,653],"a",{"href":652},"\u002Fportfolio\u002Finvoice-system","訂單開立發票系統"," 的架構起\n點——同一套分層模式，套用到不同的業務情境。",[656,657,658],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":69,"searchDepth":94,"depth":94,"links":660},[661,662,663,664,665,666],{"id":13,"depth":94,"text":13},{"id":20,"depth":94,"text":20},{"id":39,"depth":94,"text":39},{"id":372,"depth":94,"text":373},{"id":428,"depth":94,"text":429},{"id":624,"depth":94,"text":624},"https:\u002F\u002Fpicsum.photos\u002Fseed\u002Ftraining-hangfire\u002F1200\u002F800","md",false,{},"\u002Fportfolio\u002Ftraining-hangfire","https:\u002F\u002Fgithub.com\u002Ftom3019\u002FTraining.Hangfire","個人練習專案 ‧ 剛入行",{"title":5,"description":69},[676,677,678,679,680,681,682],"ASP.NET Core","C#","Hangfire","EF Core","AutoMapper","MSTest","MSSQL","portfolio\u002Ftraining-hangfire","剛入行時的練習專案：串接中央氣象局開放資料，用 Hangfire 定期抓取、清洗並寫入資料庫，第一次完整走過分層架構、AutoMapper 與三層測試（Repository／Service／整合測試）。",2021,"Fy0ZXkxWAariezmBzXlj0O7Q_XbB76N1K97vwp17-k0",1792959149969]