Share
Embed
Feature Flagを使った開発で高速かつストレスフリーなデリバリーを実現する / Fast and stress-free delivery with Feature Flag-based development
iOSDC Japan 2022 Day2 Track Aにて発表した「Feature Flagを使った開発で高速でストレスフリーなデリバリーを実現する」の発表資料です。
Tomohiro Imaizumi
September 12, 2022
Transcript
67 slides
-
5PNPIJSP*NBJ[VNJ w *%!JNBJ[VNF w ॴଐ3FUUZ ג ΞϓϦνʔϜ w ڵຯϚΠΫϩϋοΫ4JSJγϣʔτΧοτ w
৯ຊͷڷྉཧ͝άϧϝ͖ ൃදऀ -
νʔϜ։ൃͰͷϒϥϯνӡ༻ 'FBUVSF'MBHͱ 'FBUVSF'MBHͷར༻ํ๏ 'FBUVSF'MBHͷར ࣗલ࣮74֎෦αʔϏε
J04Ͱͷ࣮ύλʔϯ 'FBUVSF'MBHͷ5*14 'FBUVSF'MBHͷҙͱ՝ ࣍ -
νʔϜ։ൃͷಛ w ৽ػೳ։ൃ w 6*มߋ w όάमਖ਼ w ϦϑΝΫλϦϯά w
ϥΠϒϥϦߋ৽ w FUD ෳϒϥϯνͰฒߦ։ൃ -
ϒϥϯνࣜͷ՝ ϒϥϯνण໋ʹൺྫ͠ڝ߹ղফίετ͕૿͑Δ w णϒϥϯν΄ͲϝΠϯͱͷࠩڝ߹ൃੜ͕֬૿Ճ w ಛʹ9.-ϑΝΠϧ Storyboard, Xib, xcodeproj ղফ͕͍͠ͷͰආ͚͍ͨ
w සൟͳϩʔΧϧϚʔδͰղফՄೳɺ͕ͩ࡞ۀίετߴΕ͕ͪ -
ϒϥϯνࣜͷ՝ ΞδϟΠϧ։ൃͱ૬ੑ͕ѱ͍ w ಈ͘ίʔυΛҡ࣋ͭͭ͠ɺ༏ઌɾείʔϓมߋͷରԠ͕ඞཁ w ͔͠͠ w ڝ߹͕ൃੜ͢Δ⾣ͦͷ··Ͱʮਖ਼͘͠ಈ͔ͳ͍ʯ w ։ൃఀࢭɾ࠶։ͷίετ͕େ͖͍⾣։ൃ༏ઌมߋ͕ͮ͠Β͍
w ϒϥϯνͷׂϚʔδ͍͠⾣։ൃείʔϓมߋ͕ͮ͠Β͍ w ڥมԽ͕ܹ͍͠։ൃͰରԠ͕͍͠ 包括的なドキュメントよりも動くソフトウェアを、 ...(中略)... 計画に従うことよりも変化への対応を価値とする (アジャイルソフトウェア開発宣言より) -
τϥϯΫϕʔε։ൃͱ w ৗʹϝΠϯϒϥϯν͔Β։࢝Ϛʔδ͢Δ։ൃख๏ w ϝΠϯϒϥϯνʹมߋ͕ू͞Εɺϒϥϯνؒͷ͕ࠩੜ͡ʹ͍͘ w (JU)VC'MPXʹ΄΅͍ۙ トランク ベース開発とは、
開発者が細かく頻繁なアップデートをコア「トランク」 または main ブランチにマージするバージョン管理手法です。 "UMBTTJBOެࣜαΠτʮܧଓతσϦόϦʔʯΑΓ -
3FUUZͰ ϒϥϯν͔ΒτϥϯΫϕʔε w ͔ͭͯେ͖ͳࠩΛҰʹϦϦʔε͢Δɺ ௨শʮϏοάόϯϦϦʔεʯ͕ओྲྀͩͬͨ w ͔͠͠ϨϏϡʔσϦόϦʔ͕Լ w ϦϦʔεޙʹ͕ଟ͘ൃੜ w
ʮখ͘͞։ൃɾখ͘͞ϦϦʔεʯ͢ΔͨΊɺ શࣾͰτϥϯΫϕʔε։ൃΛਪਐ -
"UMBTTJBOެࣜαΠτʮ'FBUVSF'MBHTʯΑΓ 'FBUVSFGMBHT BMTPDPNNPOMZLOPXOBTGFBUVSFUPHHMFT JT BTPGUXBSFFOHJOFFSJOHUFDIOJRVF UIBUUVSOTTFMFDUGVODUJPOBMJUZPOBOEPGGEVSJOHSVOUJNF XJUIPVUEFQMPZJOHOFXDPEF 'FBUVSF'MBHͱ
'FBUVSF'MBH Ұൠʹ'FBUVSF5PHHMFͱ ɺ ৽͍͠ίʔυΛσϓϩΠ͢Δ͜ͱͳ͘ɺ ࣮ߦ࣌ʹબͨ͠ػೳΛΦϯ·ͨΦϑʹ͢Δ ιϑτΣΞΤϯδχΞϦϯάख๏Ͱ͋Δɻ -
'FBUVSF'MBHͷ֓ཁ ϑϥάͱͳΔมΛఆٛ ରͷػೳදग़Λ͙Α͏ʹذΛ࡞ ؔ࿈ࠩϝΠϯϒϥϯνϚʔδ ஈ֊Ͱసͤ͞ϦϦʔε
͕ͳ͚ΕϑϥάͱذΛআ // 1. リリースまではfalseにしておく let isNewFeatureAvailable = false // 2. 分岐で機能表出を防ぐ if isNewFeatureAvailable { // 3. 本番では呼ばれない 本番では showNewFeature() } // 4. 反転してリリース let isNewFeatureAvailable = true if isNewFeatureAvailable { showNewFeature() } // 5. フラグと分岐を削除 showNewFeature() -
ࢀߟ'FBUVSF'MBHͷྨ NBSUJOGPXMFSDPNΑΓ w 3FMFBTF5PHHMF։ൃதͷػೳΛϝΠϯϒϥϯνͰར༻Մೳʹɺ͍ͭͰ ຊ൪σϓϩΠՄೳʹ͢ΔͨΊͷϑϥάɻ w &YQFSJNFOU5PHHMF"#ςετͰৼΓ͚ΔͨΊͷϑϥάɻ w 0QT5PHHMFγεςϜͷಈ࡞Λ੍ޚ͢ΔͨΊͷϑϥάɻஈ֊తϦϦʔε w
1FSNJTTJPO5PHHMFಛఆϢʔβʔʹઌߦͯ͠ར༻Մೳʹ͢ΔͨΊͷϑϥάɻ Ћ൛ͷػೳϓϨϛΞϜձһ͚ػೳ ຊൃදͰ3FMFBTF5PHHMFʹߜͬͯղઆ &YQFSJNFOUBM5PHHMFʹ͍ͭͯJ04%$+BQBO 5BLFTIJ*IBSB͞Μͷʮ'FBUVSF'MBHΛదʹྨ͢Δ͜ͱͰ"#ςετͷӡ༻ίετΛԼ͛ΔʯͰղઆ͋Γ -
'FBUVSF'MBHͷར ΞδϟΠϧ։ൃͱ૬ੑ͕ྑ͍ w ڝ߹͕ൃੜ͠ʹ͍͘ਖ਼͘͠ಈ͘ίʔυ͕ҡ࣋͞ΕΔ w ։ൃ్தͰϑϥά0''ͷ··ϦϦʔεՄೳ w ༏ઌมߋͷӨڹΛड͚ʹ͍͘ w Ԙ௮͚ظ͕ؒͯ͘ैɾڝ߹ղফίετ্͕͕Βͳ͍
w ϚʔδͱϦϦʔε͕͔ΕΔϦϦʔεൣғɾλΠϛϯάΛॊೈʹมߋՄೳ ࠩ͠ࠐΈͰผͷ։ൃϦϦʔε ϝΠϯϒϥϯν͔Β࠶։ ։ൃ్தͰϚʔδ -
'FBUVSF'MBHͷར NFSHFSFWFSU͕͕ࠩ͘͢͠খ͍͞ w ϑϥάͷ࠶సͰSFWFSUՄɾࠩখ͍͞ w ࢀরՕॴΛݟΕӨڹൣғΛѲ͍͢͠ w ෆ҆ͳΒҰఆظؒϑϥάΛ͢ͷՄ //
再反転すればrevert完了 let isNewFeatureAvailable = false if isNewFeatureAvailable { showNewFeature() } -
ϒϥϯν74τϥϯΫϕʔε 'FBUVSF'MBH ϒϥϯν ൺֱ߲ τϥϯΫϕʔε 'FBUVSF'MBH ଟ͍ ϩʔΧϧϚʔδڝ߹ൃੜ গͳ͍
ߴ͍ தஅɾ࠶։ίετ ͍ 13ͱಉ͡ߦ SFWFSU࣌ͷࠩ dߦ ͍͠ɾίετ͕ߴ͍ είʔϓɾλΠϛϯάมߋ ༰қ -
if enabledNewFeature { present(viewController: NewViewController()) } else { present(viewController: OldViewController())
} w QSFTFOUɾQVTIՕॴͰذ w ذָ͕Ͱཧతͳύλʔϯ 7JFX$POUSPMMFS if enabledNewFeature { navigationController?.pushViewController( NewViewController(), animated: true ) } else { navigationController?.pushViewController( OldViewController(), animated: true ) } -
HStack(spacing: 0) { if enabledNewFeature { NewView(newViewModel: .init()) } else
{ OldView(oldViewModel: .init()) }.padding(.bottom, 16) w දࣔՕॴʹذΛ࣮Մೳ w ذָ͕Ͱཧతͳύλʔϯ 4XJGU6* -
// XibやStoryboardを使ったレイアウト @IBOutlet private var oldViewHeight: NSLayoutConstraint! @IBOutlet private var
newViewHeight: NSLayoutConstraint! if enabledNewFeature { newViewHeight.priority = .defaultHigh oldViewHeight.priority = .defaultLow } else { newViewHeight.priority = .defaultLow oldViewHeight.priority = .defaultHigh } // コードのみでのレイアウト let viewToAdd: UIView = enabledNewFeature ? newView : oldView view.addSubview(viewToAdd) w /4$POTUSBJOU-BZPVUͰͷذ ʹ'FBUVSF'MBHΛ͚Δ w ίʔυͰͷϨΠΞτͳΒ BEE4VC7JFXΛ'FBUVSF'MBHͰ ذͯ͠0, w ϨΠΞτ݅ʹԠͨ͡બΛ "VUP-BZPVU -
func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) ->
UITableViewCell { if familiarCategoryFeatureEnabled { return tableView.dequeueReusableCell( withIdentifier: "NewCell", for: indexPath ) as! NewCell } else { return tableView.dequeueReusableCell( withIdentifier: "OldCell", for: indexPath ) as? OldCell } w EFRVFVF3FVTBCMF$FMMͷ JEFOUJ fi FSΛม͑Εྑ͍ w ྆ํͷDFMMΛEFRVFVFͭͭ͠ IFJHIUΛʹઃఆ͢Δͷ͋Γ 6*5BCMF7JFX$FMM -
func setupViews() { if isNewFeatureEnabled { let response = fetchNewResponse()
titleView.text = response.title newView.value = response.newField newView.isHidden = false oldView.isHidden = true } else { let response = fetchOldResponse() titleView.text = response.title oldView.isHidden = response.oldField newView.isHidden = true oldView.isHidden = false } } func fetchNewResponse() -> NewResponse { return NewResponse(title: ..., newField: ...) } func fetchOldResponse() -> OldResponse { .init(title: ..., oldField: ...) } 6*,JU w ৽چͷܕʹରԠ͢ΔϑΟʔϧυ Λఆٛ w ӨڹΛड͚Δ7JFXΛ w දࣔঢ়ଶɾ7JFXͷΈཱͯΛ ذͤͤ͞Δ σʔλܕ͕มΘΔ߹ -
class ViewModel: ObservableObject { @Published var newItem: NewItem @Published var
oldItem: OldItem } 4XJGU6* w ৽چͷܕʹରԠ͢ΔϑΟʔϧυ Λఆٛ w ӨڹΛड͚Δ7JFXΛ w දࣔঢ়ଶɾ7JFXͷΈཱͯΛ ذͤͤ͞Δ σʔλܕ͕มΘΔ߹ if isNewFeatureEnabled { Text(viewModel.newItem.value) } else { Text(viewModel.oldItem.value) } -
/* OTHER_LDFLAGSにコンパイルフラグを渡すことで ビルド時にフラグの値を指定可能 */ #if FEATURE_FLAG_NEW_FEATURE let isNewFeatureAvailable = true
#else let isNewFeatureAvailable = false #endif ϑϥάఆͷఆٛͱࢀর w άϩʔόϧͳఆΛ༻ҙ w Ϗϧυ࣌ͷมͰ֎෦Γସ͑ Ͱ͖ΔΑ͏ʹ͓ͯ͘͠ w ͋ͱදग़ՕॴͰࢀর͢Δ͚ͩ ࣗલ࣮Ͱͷ 'FBUVSF'MBHTTXJGU // 使用箇所での分岐に使用するだけ if isNewFeatureAvailable { present(newViewController, animated: true) } else { present(viewController, animated: true) } 7JFX$POUSPMMFSTXJGU -
# FeatureFlags = HONYA,MORAKE と入ってくるので # それぞれプレフィックスに FEATURE_FLAG_ を付与して渡す。 activate_feature_flags
= (options[:activate_feature_flags] || '').split(",") xcodebuild_args = activate_feature_flags .map { |k| "-D FEATURE_FLAG_#{k.shellescape}" }.join(' ') build_app( workspace: "MyApp.xcworkspace", scheme: "MyApp", export_options: { xcargs: xcodebuild_args.length > 0 ? "OTHER_SWIFT_FLAGS='$(inherited) #{xcodebuild_args}'" : "" ) 'BTUMBOFͷઃఆ w ҾͰ'FBUVSF'MBH༻ͷίϯύ ΠϧϑϥάΛड͚औΔ w ଞͷҾͱ۠ผͰ͖ΔΑ͏ʹɺ ϓϨϑΟοΫεΛ͚Δͷ ࣗલ࣮Ͱͷ 'BTU fi MF $ fastlane build_my_app activate_feature_flags:HONYA,MORAKE > FEATURE_FLAG_HONYAとFEATURE_FLAG_MORAKEがtrueになる -
֎෦αʔϏεͷൺֱද جຊతػೳੑʹେࠩͳ͘'JSFCBTF͕͓खࠒ ൺֱ߲ 'JSFCBTF 3FNPUF$PO fi H -BVODI%BSLMZ TQMJUJP
'MBHTIJQ ಛ ଞͷ'JSFCBTF αʔϏεͱͷ࿈ܞ ϦΞϧλΠϜੑ NTҎͰ৴ ܭଌࢹͷ ػೳ͖ ࣗಈϩʔϧόοΫ ஈ֊తϦϦʔε ྉۚ ແྉ NPOUId ໊·Ͱແྉ NPOUId GFUDIճ੍ݶ ࣌ؒҎճ·Ͱ ެࣜͰ໌ݴͤͣ ͳ͠ ͳ͠ -
w σϑΥϧτͰλʔήοτΞϓ Ϧɾ04ɾࠃͳͲΛࢦఆՄೳ w ҙϢʔβʔϓϩύςΟʹՃ͑ɺ ϥϯμϜͳׂΓৼΓϕʔε ͷذՄ w औಘ੍ݶͱΩϟογϡͷͨΊɺ සൟͳมߋʹҙ
'JSFCBTF3FNPUF$POGJHͷ݅ࢦఆ -
w ύϥϝʔλ໊ɾσʔλܕɾσϑ ΥϧτΛࢦఆ w ذ݅Λࢦఆ͢Δ w "QQ%FMFHBUFͰύϥϝʔλΛ GFUDIͯ͠ར༻͢Δ 3FNPUF$POGJHͰͷϑϥάఆٛ
import Firebase let remoteConfig = RemoteConfig.remoteConfig() let isNewFeatureEnabled = remoteConfig["newFeatureEnabled"].boolValue 'FBUVSF'MBHTTXJGU -
͍͚ ϦϦʔε SFWFSU λΠϛϯάͱذ݅࣍ୈ ࣗલ࣮ w ΞϓϦϦϦʔεػೳϦϦʔε w ذ͕݅੩త
֎෦αʔϏε αʔόʔ੍ޚ w ΞϓϦϦϦʔεͱػೳϦϦʔεͷ λΠϛϯάΛ͚͍ͨ w ذ݅Λಈతɾৄࡉʹม͍͑ͨ -
ࣗલ࣮74֎෦αʔϏε ϦϦʔεɾλʔήςΟϯάͰ੍͕͋ΔͳΒ֎෦αʔϏεΛݕ౼ ൺֱ߲ ࣗલ࣮ ΫϥΠΞϯτͷΈ ֎෦αʔϏε ϑϥάసλΠϛϯά Ϗϧυ࣌ ҙλΠϛϯά
ϑϥάཧมߋॴ ΫϥΠΞϯτͷίʔυ αʔόʔ(6* λʔήςΟϯάɾ࣌ݶࣜ ͍͠ Մೳ ґଘ ͳ͠ 4%,͕ඞཁ Ձ֨ ແྉ ༗ྉͷ߹͋Γ -
'FBUVSF'MBH࠷ऴखஈ ৗʹখ͘͞Ϛʔδ͢ΔઃܭɾྗΛ w ಈ࡞ʹӨڹ͠ͳ͍ࠩૣ͘Ϛʔδ͢Δ͖Ͱ'FBUVSF'MBHෆཁ w ྫίϯϙʔωϯτఆٛͷΈͷࠩ w ྫϑϩϯτ͔Β౸ୡෆՄೳͳൣғͰͷ݁߹ w Ϣʔβʔ͕ࢀরՄೳʹͳΔಋઢͰॳΊͯ'FBUVSF'MBHΛݕ౼͢Δ
> Only if you can't do small releases or UI last should you employ release toggles. (小さなリリースや UI を最後に実行できない場合にのみ、リリース トグルを使用するべきである。) martinfowler.com より -
w 'FBUVSF'MBHͷࢀরɺ ʮҙࢥܾఆϩδοΫϙΠϯτʯ w ϦϦʔεͷείʔϓ͕݅ม͑ ͮΒ͍ w ؒϨΠϠʔΛڬΉ͜ͱͰґଘ ͕ബ͘ͳΓ྆ऀΛՄೳʹ w
ґଘؔٯసͰ%*Մೳʹ ҙࢥܾఆϙΠϯτͱϩδοΫͷ // Feature Flagが意思決定ロジック=意思決定ポイントに let isNewFeatureEnabled: Bool = true if isNewFeatureEnabled { showView() editView() } // 意思決定ロジック(Feature Flagを入力にする) func canShowView(featureFlags: [String: Bool]) -> Bool { featureFlags["newFeature"] == true && conditionForShowView } func canEditView(featureFlags: [String: Bool]) -> Bool { featureFlags["newFeature"] == true && conditionForEditView } // 意思決定ポイント let isNewFeatureEnabled: Bool = true if canShowView(["newFeature": isNewFeatureEnabled]) { showView() } if canEditView(["newFeature": isNewFeatureEnabled]) { editView() } -
আ࣌ͷϛεࢭ؍͔Β w ϒϩοΫͰ·ͱ·͍ͬͯΔ߹ɺ ফ͠࿙Εޡআࢭ͍͢͠ w %3:͕ྑ͍ͱݶΒͳ͍ ذۃྗϒϩοΫʹ·ͱΊΔ // 各行で削除が必要なため誤削除・漏れに注意が必要
view.addSubview(isNewFeatureEnabled ? newView : oldView) newView.isHidden = isNewFeatureEnabled oldView.isHidden = !isNewFeatureEnabled // 削除対象がブロックなので範囲が明確 if isNewFeatureEnabled { view.addSubview(newView) newView.isHidden = false oldView.isHidden = true } else { view.addSubview(oldView) newView.isHidden = true oldView.isHidden = false } -
'FBUVSF'MBHͷҙ ར༻͕͍͠έʔε w ෳࡶͳ4UPSZCPBSE9JCϨΠΞτ w ϒϥϯνͰਐΊΔ͔࡞Γ͠Λݕ౼͢Δ w ৽چͷܕͷ͕ࠩେ͖͍ w ৽چܕͷϑΟʔϧυΛ༻ҙͯ͠ذࠔͳ߹
w நԽϨΠϠʔΛઃ͚Δํ๏͋Γ w มߋՕॴ͕ΫϥΠΞϯτଆʹͳ͍ w "1*όʔδϣϯΛม͑ΔPSαʔόʔʹϑϥάΛಋೖ -
ࢀߟจݙ63- wΞδϟΠϧιϑτΣΞ։ൃએݴ 🔗IUUQTBHJMFNBOJGFTUPPSHJTPKBNBOJGFTUPIUNM wτϥϯΫϕʔε։ൃc"UMBTTJBO 🔗IUUQTXXXBUMBTTJBODPNKBDPOUJOVPVTEFMJWFSZDPOUJOVPVTJOUFHSBUJPOUSVOLCBTFEEFWFMPQNFOU w'FBUVSF5PHHMFT BLB'FBUVSF'MBHT 🔗IUUQTNBSUJOGPXMFSDPNBSUJDMFTGFBUVSFUPHHMFTIUNM w'FBUVSF'MBHΛదʹྨ͢Δ͜ͱͰ"#ςετͷӡ༻ίετΛԼ͛ΔJ04%$5BLFTIJ*IBSB
"#&." 🔗IUUQTTQFBLFSEFDLDPNOPODIBMBOUCUFTVUPGBMTFZVOZPOHLPTVUPXPYJBHFSV w'FBUVSF'MBHT 5PHHMFT $POUSPMTr5IF)VCGPS'FBUVSF'MBH%SJWFO%FWFMPQNFOUc'FBUVSF'MBHT 🔗IUUQTGFBUVSF fl BHTJP