TECH PLAY

株式会社RevComm

株式会社RevComm の技術ブログ

171

こんにちは。レブコムのコーポレートエンジニアリングチームの @ken-1200 です。 この記事は、 RevComm Advent Calendar 2024 の 20 日目の記事です。 1. はじめに 2. 開発の背景・モチベーション 3. 前提条件 4. Salesforce CPQ APIの概要 5. 商談(Opportunity)の作成 6. 見積(Quote)の作成 7. 見積品目(Quote Line Items)の登録 8. ポイントの振り返り 9. 自動化により得られた効果 10. 苦労した点・ハマりどころ 11. まとめ 12. 参考文献 1. はじめに 記事の目的 本記事では、Salesforce CPQ APIを活用して、商談から見積作成、見積品目の登録までのプロセスを自動化する手順をご紹介します 対象読者 Salesforce CPQを利用するエンジニアの方を主な読者と想定しています 2. 開発の背景・モチベーション なぜ開発に至ったのか 営業プロセスでは、商談・見積・見積品目などの情報入力や修正が手動で行われ、工数がかかっていました。さらに、オンライン申込対応時には、商品の追加・削除・価格調整を都度行う必要があり、手作業によるミスや作業遅延が発生しがちでした これらの課題解決のため、Salesforce CPQ APIを用いた自動化によって、業務フローを効率化し、正確性とスピードの向上を目指しました 3. 前提条件 Salesforce環境の準備 Salesforce CPQが有効化されていること 必要なAPIアクセス権限(ユーザー権限設定)が設定されていること 開発環境のセットアップ Salesforce Sandbox環境 プログラミング言語:Python 3.10以上を推奨します 基本的な知識 Salesforce CPQの基本概念 REST APIの基礎知識 4. Salesforce CPQ APIの概要 APIの種類 REST APIとSOAP APIの2種類が存在しますが、軽量で汎用性が高く、JSON形式のデータ交換が容易なREST APIを選択します 認証方法 一般的にはOAuth 2.0を使用することで、トークンベースの認証・認可が可能です エンドポイントとリソース Salesforce CPQには特定のエンドポイントを介して見積や商品情報へアクセスできます。以下は主な例です QuoteReader : /services/apexrest/SBQQ/ServiceRouter?reader=SBQQ.QuoteAPI.QuoteReader&uid={quote_id} 指定したQuote IDの詳細情報を取得します ProductLoader : /services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.ProductAPI.ProductLoader&uid={product_id} 指定した商品IDに対する商品情報やオプションを取得します QuoteProductAdder : /services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.QuoteAPI.QuoteProductAdder 見積に商品を追加するために使用します QuoteCalculator : /services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.QuoteAPI.QuoteCalculator 見積品目を追加後に、見積全体の価格計算を行います QuoteSave : /services/apexrest/SBQQ/ServiceRouter 設定した見積や見積品目を保存および確定します これらのエンドポイントを組み合わせることで、商談作成→見積生成→見積品目追加→価格再計算→保存という一連の流れを自動化できます SalesforceRestApiClientクラスについて Salesforce CPQ APIやSalesforce標準APIへのアクセスを簡潔にするために、本記事では共通的に利用できる SalesforceRestApiClient クラスを用いています from collections.abc import Mapping from typing import Any import httpx class SalesforceRestApiClient : """Salesforce REST APIクライアントクラス Salesforce APIを呼び出すための基本クラスです """ def __init__ (self, path: str , additional_headers: dict | None = None ): self.base_url = "https://your-instance.salesforce.com" # SalesforceインスタンスURLを指定してください self.path = path # ここでは例としてAuthorizationヘッダを省略していますが、 # 実際にはOAuth2トークンや有効な認証ヘッダを設定してください self.headers = { "Authorization" : "Bearer your_access_token" , "Content-Type" : "application/json" } if additional_headers: self.headers.update(additional_headers) async def get (self) -> httpx.Response: """GETリクエストを送信します""" async with httpx.AsyncClient() as client: return await client.get(f "{self.base_url}{self.path}" , headers=self.headers, timeout= 30 ) async def patch (self, json: Mapping[ str , Any]) -> httpx.Response: """PATCHリクエストを送信します""" async with httpx.AsyncClient() as client: return await client.patch(f "{self.base_url}{self.path}" , headers=self.headers, json=json, timeout= 30 ) async def post (self, json: Mapping[ str , Any]) -> httpx.Response: """POSTリクエストを送信します""" async with httpx.AsyncClient() as client: return await client.post(f "{self.base_url}{self.path}" , headers=self.headers, json=json, timeout= 30 ) 5. 商談(Opportunity)の作成 必要なデータ 商談名、ステージ、取引先情報など。必要に応じて追加してください APIリクエストの構築 POST メソッドを用いて、指定のエンドポイントへJSON形式でデータを送信します。Salesforce APIは Content-Length ヘッダが必要となる場合があるため、事前にJSON文字列の長さを計算して設定します エンドポイント例 path="/services/data/vXX.X/sobjects/Opportunity" ヘッダー例 headers={"Content-Length": str(len(json.dumps(data)))} サンプルコード 以下はPythonによる実装例です。非同期HTTPクライアント(httpx)を用いてSalesforce APIにPOSTリクエストを送信し、商談を作成します import asyncio class SalesforceOpportunity : async def create_opportunity (self, data: Mapping[ str , Any]) -> httpx.Response: """Salesforceの商談をAPIで作成します Args: data (Mapping[str, Any]): 作成する商談の情報を含んだ辞書型データ Returns: httpx.Response: Salesforce APIからのレスポンス """ # APIクライアントを初期化(必要なヘッダを設定) sf_api_client = SalesforceRestApiClient( path= "/services/data/vXX.X/sobjects/Opportunity" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.post(json=data) if __name__ == "__main__" : # 実行例:商談を作成します async def main (): sf = SalesforceOpportunity() response = await sf.create_opportunity( { "Name" : "Test Opportunity" , "StageName" : "Prospecting" , "CloseDate" : "2024-12-31" , "AccountId" : "0015g00000A3X7dAAF" , # 有効なAccountIdを設定してください } ) print (f "{response.status_code=}" ) print (f "{response.json()=}" ) asyncio.run(main()) エラーハンドリング Salesforce APIへのPOST時には、 201 Created が成功時の典型的なステータスコードです。エラー時には 400 や 404 などが返り、レスポンスボディ内に errorCode や fields などの詳細が含まれます 以下はエラーが発生した場合の例です [ { "message": "不正な種別の ID 値: 0015g00000A3X7dAAF", "errorCode": "MALFORMED_ID", "fields": ["AccountId"] } ] このようなエラーに対しては、ログ出力やリトライ、適切なエラーメッセージのユーザー通知などを行います。 6. 見積(Quote)の作成 商談との関連付け 見積と商談は、 SBQQ__Opportunity2__c フィールドで関連付けます 必要なフィールド 商談ID、価格表ID、期限日など。要件に応じて追加フィールドやカスタムフィールドを設定します APIリクエストの詳細 POST リクエストを使用して、 SBQQ__Quote__c オブジェクトにデータを送信します エンドポイント例 path="/services/data/vXX.X/sobjects/SBQQ__Quote__c" ヘッダー例 headers={"Content-Length": str(len(json.dumps(data)))} サンプルコード 以下は、Pythonを使用して見積を作成するサンプルコードです import asyncio class SalesforceQuote : async def create_quote (self, data: Mapping[ str , Any]) -> httpx.Response: """Salesforceの見積をAPIで作成します Args: data (Mapping[str, Any]): 作成する見積の情報を含んだ辞書型データ Returns: httpx.Response: Salesforce APIからのレスポンス """ # APIクライアントを初期化(必要なヘッダを設定) sf_api_client = SalesforceRestApiClient( path= "/services/data/vXX.X/sobjects/SBQQ__Quote__c" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.post(json=data) if __name__ == "__main__" : # 実行例:見積を作成します async def main (): sf = SalesforceQuote() # 以下は例としてのフィールド設定です。実際のIDや値は有効なものを指定してください。 response = await sf.create_quote( { "SBQQ__BillingCity__c" : "Chiyoda" , "SBQQ__BillingPostalCode__c" : "100-0000" , "SBQQ__BillingState__c" : "Tokyo" , "SBQQ__BillingStreet__c" : "1-1-1" , "SBQQ__EndDate__c" : "2024-12-31" , "SBQQ__Opportunity2__c" : "0065g00000B3X7dAAF" , # 商談ID "SBQQ__PricebookId__c" : "01s5g00000A3X7dAAF" , # 価格表ID "SBQQ__PrimaryContact__c" : None , "SBQQ__Primary__c" : True , "SBQQ__QuoteTemplateId__c" : "a1s5g0000003X7dAAF" , # テンプレートID "SBQQ__StartDate__c" : "2024-01-01" , "SBQQ__SubscriptionTerm__c" : 12 , } ) print (f "{response.status_code=}" ) print (f "{response.json()=}" ) asyncio.run(main()) エラーハンドリング エラーが発生した場合、 400 Bad Request などのステータスコードとともに、 errorCode や message が返されます。以下は一般的なエラー応答例です [ { "message": "invalid cross reference id", "errorCode": "INVALID_CROSS_REFERENCE_KEY", "fields": [] } ] このようなエラーに対しては、ログ出力やIDの再確認、必要なデータフィールドの修正を行います 7. 見積品目(Quote Line Items)の登録 手順 見積の読み取り 商品の読み取り 商品の追加 見積の計算 見積の保存 これらのステップを通じて、見積品目を自動的に追加できます 製品情報の準備 製品ID、数量、価格などのデータ。必要に応じて追加フィールドを設定できます APIリクエストの構築 見積IDを基に見積品目を追加するには、CPQ固有のエンドポイントを使用します。 QuoteReader 、 ProductLoader 、 QuoteProductAdder 、 QuoteCalculator 、 QuoteSaver といったCPQ APIエンドポイントを順番に呼び出すことで、一連の処理を自動化できます バルク操作の考慮 複数の見積品目を一度に登録する場合、一括処理用のコンテキストをまとめて送信することで、パフォーマンスを最適化できます 商品を一括追加した後、見積を再計算し、最後に保存する流れで処理を完結させます サンプルコード 以下のサンプルコードは、見積に商品を追加する一連の流れをCPQ APIで実現します import asyncio class SalesforceCpqQuote : async def get_quote (self, quote_id: str ) -> httpx.Response: """Salesforceの見積をCPQ APIで取得します""" sf_api_client = SalesforceRestApiClient( path=f "/services/apexrest/SBQQ/ServiceRouter?reader=SBQQ.QuoteAPI.QuoteReader&uid={quote_id}" , ) return await sf_api_client.get() async def get_product (self, product_id: str , data: Mapping[ str , str ]) -> httpx.Response: """Salesforceの商品をCPQ APIで取得します""" sf_api_client = SalesforceRestApiClient( path=f "/services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.ProductAPI.ProductLoader&uid={product_id}" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.patch(json=data) async def add_product (self, data: Mapping[ str , str ]) -> httpx.Response: """Salesforceの見積品目をCPQ APIで作成します""" sf_api_client = SalesforceRestApiClient( path= "/services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.QuoteAPI.QuoteProductAdder" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.patch(json=data) async def calculate_quote (self, data: Mapping[ str , str ]) -> httpx.Response: """Salesforceの見積をCPQ APIで計算します""" sf_api_client = SalesforceRestApiClient( path= "/services/apexrest/SBQQ/ServiceRouter?loader=SBQQ.QuoteAPI.QuoteCalculator" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.patch(json=data) async def save_quote (self, data: Mapping[ str , str ]) -> httpx.Response: """Salesforceの見積をCPQ APIで保存します""" sf_api_client = SalesforceRestApiClient( path= "/services/apexrest/SBQQ/ServiceRouter" , additional_headers={ "Content-Length" : str ( len (json.dumps(data))), }, ) return await sf_api_client.post(json=data) class CreateQuoteLineItem : def __init__ (self) -> None : self.salesforce_cpq_quote = SalesforceCpqQuote() async def execute ( self, quote_id: str , product_id: str , pricebook_id: str , currency_code: str , product_counts: dict , ): """処理の流れ: 1. 見積の読み取り 2. 商品の読み込み 3. 商品の追加 4. 見積の計算 5. 見積の保存 """ # 見積の読み取り cpq_quote = await self.get_quote(quote_id) print (f "Successfully get quote: {cpq_quote}" ) # 商品の読み込み product = await self.get_product(product_id, pricebook_id, currency_code) print (f "Successfully get product: {product}" ) # 商品の追加 add_product_to_quote = await self.add_product_to_quote(product_counts, cpq_quote, product) print (f "Successfully add product: {add_product_to_quote}" ) # 見積の計算 calculate_quote = await self.calculate_quote(add_product_to_quote) print (f "Successfully calculate quote: {calculate_quote}" ) # 見積の保存 save_quote = await self.save_quote(calculate_quote) print (f "Successfully save quote: {save_quote}" ) async def get_quote (self, quote_id: str ) -> dict : """Salesforceの見積を取得""" quote_response = await self.salesforce_cpq_quote.get_quote(quote_id) return json.loads(quote_response.json()) async def get_product (self, product_id: str , pricebook_id: str , currency_code: str ) -> dict : """Salesforceの商品を取得""" product_data = { "context" : json.dumps(ProductGetContext(pricebookId=pricebook_id, currencyCode=currency_code).dict()) } product_response = await self.salesforce_cpq_quote.get_product(product_id, product_data) return json.loads(product_response.json()) async def add_product_to_quote (self, product_counts: dict , quote: dict , product: dict ) -> dict : """Salesforceの商品を見積に追加""" # ProductModelやConfigurationModelなど product_model = ProductModel(**product) list_of_product_model = [] list_of_configuration_model = [] # バンドル商品の子商品を追加 for mb_op in product_model.options: product_id = mb_op.record[ "SBQQ__OptionalSKU__c" ] quantity = product_counts.get(product_id) if quantity: mb_op.record[ "SBQQ__Quantity__c" ] = quantity cf_model = ConfigurationModel( configuredProductId=product_id, optionId=mb_op.record[ "Id" ], optionData=mb_op.record, configurationData=mb_op.record, inheritedConfigurationData= None , optionConfigurations=[], configured= False , changedByProductActions= False , isDynamicOption= False , isUpgrade= False , disabledOptionIds=[], hiddenOptionIds=[], listPrice= None , priceEditable= False , validationMessages=[], dynamicOptionKey= None , ) list_of_configuration_model.append(cf_model.dict()) # バンドル商品本体へのオプション追加 if product_model.configuration is not None : if product_model.configuration.optionConfigurations is not None : product_model.configuration.optionConfigurations.extend(list_of_configuration_model) product_model.configuration.configured = True list_of_product_model.append(product_model.dict()) # 見積と商品モデルをcontextにセット context = ProductAddContext( quote={k: v for k, v in quote.items() if k != "ui_original_record" }, products=list_of_product_model, ) add_product_response = await self.salesforce_cpq_quote.add_product({ "context" : json.dumps(context.dict())}) return json.loads(add_product_response.json()) async def calculate_quote (self, quote: dict ) -> dict : """Salesforceの見積を計算""" calculate_quote_data = { "context" : json.dumps({ "quote" : {k: v for k, v in quote.items() if k != "ui_original_record" }}) } calculate_quote_response = await self.salesforce_cpq_quote.calculate_quote(calculate_quote_data) return json.loads(calculate_quote_response.json()) async def save_quote (self, quote: dict ) -> dict : """Salesforceの見積を保存""" save_quote_data = { "saver" : "SBQQ.QuoteAPI.QuoteSaver" , "model" : json.dumps({k: v for k, v in quote.items() if k != "ui_original_record" }), } save_quote_response = await self.salesforce_cpq_quote.save_quote(save_quote_data) return json.loads(save_quote_response.json()) if __name__ == "__main__" : """見積品目の追加を実行する例です。実際には有効なIDと通貨コードを設定してください""" async def main (): quote_id = "a0B5g00000DwJtEEAV" # 見積ID product_id = "01t5g00000B1Q0PAK" # 商品バンドルID pricebook_id = "01s5g0000008Q5eAAE" # 価格表ID currency_code = "JPY" # 通貨コード product_counts = { "01t5g00000B1Q0MKA" : 1 , # 見積商品IDと数量 "01t5g00000B1Q0NAAV" : 2 , } await CreateQuoteLineItem().execute( quote_id, product_id, pricebook_id, currency_code, product_counts, ) asyncio.run(main()) CPQ API のリクエストモデル定義 以下は、CPQ APIとのやりとりで使用するデータモデルの例です。 pydantic を用いてスキーマを定義し、バリデーションやコメントを明確にしています。これらのモデルは、受け取ったJSONデータを明確な型情報のあるPythonオブジェクトとして扱うことで、可読性を向上させます from pydantic import BaseModel, Field class ConfigurationModel (BaseModel): configuredProductId: str = Field(..., title= "商品ID" , description= "The Product2.Id" , example= "01t6F00000B8XZTAA3" ) optionId: str | None = Field( default= None , title= "オプションID" , description= "The SBQQ__ProductOption__c.Id" , example= "01t6F00000B8XZTAA3" ) optionData: dict = Field( ..., title= "オプションデータ" , description= "Editable data about the option, such as quantity or discount" , example={ "Id" : "01t6F00000B8XZTAA3" }, ) configurationData: dict = Field( ..., title= "構成データ" , description= "Stores the values of the configuration attributes." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) inheritedConfigurationData: dict | None = Field( default= None , title= "継承された構成データ" , description= "Stores the values of the inherited configuration attributes." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) optionConfigurations: list = Field( ..., title= "オプション構成" , description= "Stores the options selected on this product." , example=[{ "Id" : "01t6F00000B8XZTAA3" }], ) configured: bool = Field( ..., title= "構成済み" , description= "Indicates whether the product has been configured." , example= False ) changedByProductActions: bool = Field( ..., title= "商品アクションによる変更" , description= "Indicates whether a product action changed the configuration of this bundle." , example= False , ) isDynamicOption: bool = Field( ..., title= "動的オプション" , description= "Indicates whether the product was configured using a dynamic lookup." , example= False , ) isUpgrade: bool = Field( ..., title= "アップグレード" , description= "Queries whether this product is an upgrade." , example= False ) disabledOptionIds: list = Field( default= None , title= "無効なオプションID" , description= "The option IDs that are disabled." , example=[ "01t6F00000B8XZTAA3" ], ) hiddenOptionIds: list = Field( default= None , title= "非表示オプションID" , description= "The option IDs that are hidden." , example=[ "01t6F00000B8XZTAA3" ], ) listPrice: float | None = Field(default= None , title= "定価" , description= "The list price." , example= 0.0 ) priceEditable: bool = Field( ..., title= "価格編集可能" , description= "Indicates whether the price is editable." , example= False ) validationMessages: list = Field( ..., title= "検証メッセージ" , description= "Validation messages." , example=[ "Error message" ] ) dynamicOptionKey: str | None = Field( default= None , title= "動的オプションキー" , description= "Internal property for dynamic options." , example= "01t6F00000B8XZTAA3" , ) class OptionModel (BaseModel): record: dict = Field( ..., title= "オプション" , description= "The record that this model represents." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) externalConfigurationData: dict | None = Field( default= None , title= "外部構成データ" , description= "Internal property for the external configurator feature." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) configurable: bool = Field( ..., title= "構成可能" , description= "Indicates whether the option is configurable." , example= False ) configurationRequired: bool = Field( ..., title= "構成必須" , description= "Indicates whether the configuration of the option is required." , example= False , ) quantityEditable: bool = Field( ..., title= "数量編集可能" , description= "Indicates whether the quantity is editable." , example= False ) priceEditable: bool = Field( ..., title= "価格編集可能" , description= "Indicates whether the price is editable." , example= False ) productQuantityScale: float | None = Field( default= None , title= "商品数量スケール" , description= "Returns the value of the quantity scale field for the product being configured." , example= 0.0 , ) priorOptionExists: bool | None = Field( default= None , title= "前のオプションが存在する" , description= "Checks if this option is an asset on the account that the quote is associated with." , example= False , ) dependentIds: list = Field( ..., title= "依存するオプションID" , description= "The option IDs that depend on this option." , example=[ "01t6F00000B8XZTAA3" ], ) controllingGroups: dict = Field( ..., title= "制御グループ" , description= "The option IDs that this option depends on." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) exclusionGroups: dict = Field( ..., title= "排他グループ" , description= "The option IDs that this option is exclusive with." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) reconfigureDimensionWarning: str = Field( ..., title= "再構成次元警告" , description= "Reconfigures the warning label for an option with segments." , example= "01t6F00000B8XZTAA3" , ) hasDimension: bool = Field( ..., title= "次元がある" , description= "Indicates whether this option has dimensions or segments." , example= False ) isUpgrade: bool = Field( ..., title= "アップグレード" , description= "Indicates whether the product option is related to an upgrade product." , example= False , ) dynamicOptionKey: str | None = Field( default= None , title= "動的オプションキー" , description= "Internal property for dynamic options." , example= "01t6F00000B8XZTAA3" , ) class FeatureModel (BaseModel): record: dict = Field( ..., title= "機能" , description= "The record that this model represents." , example={ "Id" : "01t6F00000B8XZTAA3" } ) instructionsText: str | None = Field( default= None , title= "指示テキスト" , description= "Instruction label for the feature." , example= "01t6F00000B8XZTAA3" , ) containsUpgrades: bool = Field( ..., title= "アップグレードが含まれている" , description= "This feature is related to an upgrade product." , example= False , ) class ConfigAttributeModel (BaseModel): name: str | None = Field( default= None , title= "名前" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.Name." , example= "01t6F00000B8XZTAA3" , ) targetFieldName: str = Field( ..., title= "ターゲットフィールド名" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__TargetField__c." , example= "01t6F00000B8XZTAA3" , ) displayOrder: float | None = Field( default= None , title= "表示順" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__DisplayOrder__c." , example= 0.0 , ) columnOrder: str = Field( ..., title= "カラム順" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__ColumnOrder__c." , example= "01t6F00000B8XZTAA3" , ) required: bool = Field( ..., title= "必須" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Required__c." , example= False , ) featureId: str = Field( ..., title= "機能ID" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Feature__c." , example= "01t6F00000B8XZTAA3" , ) position: str = Field( ..., title= "位置" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Position__c." , example= "01t6F00000B8XZTAA3" , ) appliedImmediately: bool = Field( ..., title= "直ちに適用" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__AppliedImmediately__c." , example= False , ) applyToProductOptions: bool = Field( ..., title= "商品オプションに適用" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__ApplyToProductOptions__c." , example= False , ) autoSelect: bool = Field( ..., title= "自動選択" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__AutoSelect__c." , example= False , ) shownValues: list | None = Field( default= None , title= "表示値" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__ShownValues__c." , example=[ "01t6F00000B8XZTAA3" ], ) hiddenValues: list | None = Field( default= None , title= "非表示値" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__HiddenValues__c." , example=[ "01t6F00000B8XZTAA3" ], ) hidden: bool = Field( ..., title= "非表示" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.SBQQ__Hidden__c." , example= False , ) noSuchFieldName: str | None = Field( default= None , title= "存在しないフィールド名" , description= "If no field with the target name exists, the target name is stored here." , example= "01t6F00000B8XZTAA3" , ) myId: str = Field( ..., title= "ID" , description= "Corresponds directly to SBQQ__ConfigurationAttribute__c.Id." , example= "01t6F00000B8XZTAA3" , ) class ConstraintModel (BaseModel): record: dict = Field( ..., title= "制約" , description= "The record that this model represents." , example={ "Id" : "01t6F00000B8XZTAA3" } ) priorOptionExists: bool = Field( ..., title= "前のオプションが存在する" , description= "Checks if this option is an asset on the account that the quote is associated with." , example= False , ) class ProductModel (BaseModel): record: dict = Field( ..., title= "商品" , description= "The record that this model represents." , example={ "Id" : "01t6F00000B8XZTAA3" } ) upgradedAssetId: str | None = Field( default= None , title= "SBQQ__QuoteLine__c.SBQQ__UpgradedAsset__c.Id" , description= "Provides a source for SBQQ__QuoteLine__c.SBQQ__UpgradedAsset__c." , example= "01t6F00000B8XZTAA3" , ) currencySymbol: str = Field( ..., title= "通貨シンボル" , description= "The symbol for the currency in use." , example= "¥" ) currencyCode: str = Field( ..., title= "通貨コード" , description= "The ISO code for the currency in use." , example= "JPY" ) featureCategories: list = Field( ..., title= "機能カテゴリ" , description= "Allows users to sort product features by category." , example=[ "01t6F00000B8XZTAA3" ], ) options: list [OptionModel] = Field( ..., title= "オプション" , description= "A list of all available options for this product." , example=[ "01t6F00000B8XZTAA3" ], ) features: list [FeatureModel] = Field( ..., title= "機能" , description= "All features available for this product" , example=[ "01t6F00000B8XZTAA3" ] ) configuration: ConfigurationModel = Field( ..., title= "構成" , description= "An object representing this product’s current configuration." , example={ "Id" : "01t6F00000B8XZTAA3" }, ) configurationAttributes: list [ConfigAttributeModel] = Field( ..., title= "構成属性" , description= "All configuration attributes available for this product." , example=[ "01t6F00000B8XZTAA3" ], ) inheritedConfigurationAttributes: list [ConfigAttributeModel] | None = Field( default= None , title= "継承された構成属性" , description= "All configuration attributes that this product inherits from ancestor products." , example=[ "01t6F00000B8XZTAA3" ], ) constraints: list [ConstraintModel] = Field( ..., title= "制約" , description= "Option constraints on this product." , example=[ "01t6F00000B8XZTAA3" ] ) class ProductGetContext (BaseModel): pricebookId: str = Field( ..., title= "価格表ID" , description= "The ID of the price book to use." , example= "01s6F00000CneeJQAR" ) currencyCode: str = Field( ..., title= "通貨コード" , description= "The ISO code for the currency in use." , example= "JPY" ) class ProductAddContext (BaseModel): ignoreCalculate: bool = Field(default= True , title= "計算無視" , description= "計算無視" , example= True ) quote: dict = Field(..., title= "見積" , description= "見積モデル" , example={}) products: list = Field(..., title= "商品リスト" , description= "商品モデル" , example=[]) groupKey: int = Field(default= 0 , title= "グループキー" , description= "グループキー" , example= 0 ) エラーハンドリング エラーが発生した場合はHTTPステータスコードとエラーメッセージが返されます。たとえば、 500 Internal Server Error などが返された場合、レスポンス本文には errorCode や message フィールドが含まれます。 [ { "errorCode": "APEX_ERROR", "message": "System.AssertException: Assertion Failed: Unsupported quote object: a0B5g00000DwJtEEAV\n\n(System Code)", } ] 8. ポイントの振り返り 1. 商談(Opportunity)の作成 必要なデータ(商談名、ステージ、取引先ID、CloseDateなど)を準備します POST /services/data/vXX.X/sobjects/Opportunity エンドポイントを用い、JSON形式でデータを送信します 2. 見積(Quote)の作成 商談ID、価格表ID、期間や開始日などの必須項目を指定します POST /services/data/vXX.X/sobjects/SBQQ__Quote__c を使用し、JSON形式でデータを送信します 3. 見積品目(Quote Line Items)の登録 CPQ API固有のフロー( QuoteReader , ProductLoader , QuoteProductAdder , QuoteCalculator , QuoteSaver )を順序立てて実行します 製品IDや数量、オプション構成などを事前に用意し、バンドル構成商品に対応します 複数商品の一括追加時は、リクエストをまとめて送信し、パフォーマンスを最適化します 9. 自動化により得られた効果 自動化により、以下のような効果を得られました。 手動作業が減少し、ヒューマンエラーが抑制されました 営業担当者がコア業務に集中できる環境が整い、業務効率が向上しました 見積作成から承認までのリードタイムが短縮され、顧客対応スピードと満足度が向上しました 10. 苦労した点・ハマりどころ 開発過程では、以下のような課題に直面しました。 Salesforce CPQ APIに関するドキュメントや事例が少なく、適切なエンドポイント選定やデータモデル理解までに試行錯誤が必要でした 関連IDや依存関係の正確な把握が難しく、エラーの解消に時間がかかりました 適切なエラーハンドリングやPydanticでのデータモデル定義など、Python実装上のベストプラクティスを探りながら開発を進める必要がありました 11. まとめ 本記事では、Salesforce CPQ APIを用いて、商談から見積作成、見積品目の登録までを自動化する具体的な手順とポイントをご紹介しました。これにより、手作業を減らしてヒューマンエラーを抑え、業務スピードを向上させることで、顧客満足度を高められる可能性が見えてきたかと思います。 この記事が少しでも参考になり、読者の方々の開発や業務改善にお役立ていただければ幸いです。 12. 参考文献 Salesforce公式ドキュメント Salesforce CPQ APIガイド 参考例コード(GitHub Gist) https://gist.github.com/paustint/40b602503b6cd6ae879af7b85d910da8
アバター
はじめに Phone Div Backend チームの西園です。 私たちのチームでは、システムの性能を向上させるために k6 と Datadog を利用した負荷テストを実施しました。本記事では、その際に利用したツールや実施方法について共有します。 想定読者 負荷テストをやったことがない方 k6 を利用したことがない方 負荷テストをやってみたいが方法がわからない方 k6 の結果を Datadog と連携したい方 負荷テストとは 負荷テストは、システムに負荷を与えて挙動を観測するテストです。これにより以下のような情報を得られます: システムのボトルネックの特定。 システムが耐えられる最大負荷の確認。 システムのパフォーマンス向上に必要な改善点の発見。 負荷テストの種類 負荷テストには目的に応じていくつかの種類があります: Smoke Test : 最小負荷で基本動作を確認するテスト。 Average Load Test : 運用時の平均負荷を再現するテスト。 Stress Test : システムの限界を探るための負荷をかけるテスト。 Soak Test : 長時間負荷をかけてシステムの安定性を確認するテスト。 Spike Test : 短時間で急激に負荷を増加させた際の挙動を確認するテスト。 Breakpoint Test : 負荷を徐々に増やし、システムが壊れるポイントを特定するテスト。 注意点 負荷テストを行う際は、できるだけ本番環境と近いテスト環境を用意することが重要です。特に以下の点に注意してください: インフラリソース : テスト環境と本番環境でのCPU、メモリ、ネットワーク条件を可能な限り一致させる。 データ量 : データベース内のデータ量を本番に近い状態にすることで、より正確な結果を得られる。 k6 は Grafana Labs が提供するオープンソースの負荷テストツールです。以下の特徴があります: k6とは 幅広いプロトコル対応 : HTTP、WebSocket、gRPC などに対応。 柔軟なスクリプト作成 : JavaScript を用いて、シナリオに応じたスクリプトを作成可能。 多様な負荷パターン : Executor を利用して、一定負荷や段階的負荷増加などのシナリオを設定可能。 k6スクリプトの実装方法 では実際にどのようにスクリプトを作成するかを見てみましょう。 以下は Read 系のエンドポイントに負荷を掛けるスクリプトの例です。 import http from "k6/http" ; import { check } from "k6" ; export const options = { scenarios : { test1 : { executor : "ramping-arrival-rate" , exec : "testRequests" , startRate : 1 , timeUnit : "1s" , preAllocatedVUs : 25 , maxVUs : 50 , stages : [ { target : 25 , duration : "10s" } , { target : 25 , duration : "50s" } , { target : 0 , duration : "10s" } , ] , } , } , } ; export const setup = () => { // リクエストを送る前の事前処理を記載 } // もしsetup関数で取得したトークンなどを利用する場合は引数を設定する export const testRequests = () => { const requestParams = { headers : { // 必要なヘッダー情報を追加 } , } ; const url = "" const body = { // bodyが必要な場合は記載 } ; const res = http . post ( ` ${ url } ` , JSON . stringify ( body ) , requestParams ) ; // リクエストが想定通りかを検証 check ( res , { "status is 200" : ( r ) => r . status === 200 , }) ; } ; 上記の scenario は ramping-arraival-rate を設定しています。これは負荷を段階的に上げたり下げたりすることができます。 各パラメーターの説明は以下です。 executor : どのような負荷を与えていくか(負荷を徐々に上げていくなど) exec : 実行する関数名(testRequestsという関数を実行する) startRate : testRequests関数を実行する単位 timeUnit : どのくらいの間隔でtestRequests関数を実行するか( startRate / timeUnit でRPSを表現) preAllocatedVUs : あらかじめ用意するバーチャルユーザー数 maxVUs : 負荷テストで利用する最大のバーチャルユーザー数 stages : どのような間隔で負荷を増減させるか つまり以下の内容の負荷をかけることになります。 1RPS から負荷を与えていき、10s かけて 25RPS まで負荷を上げる。 そして 50s 間 25RPS を維持し、最終的に 10s かけて 0RPS まで負荷を落とす。 他にも選択できる Executor はありますので詳細を知りたい方は以下の公式ドキュメントを参考にしてください。 参考: https://grafana.com/docs/k6/latest/using-k6/scenarios/executors/ k6スクリプトの実行とDatadogとの連携 Datadog にダッシュボードを作成 Datadog を使用して負荷テスト結果を可視化するには、以下の手順を実施します: k6 のインテグレーションを有効化 サイドメニューIntegrationから k6 を検索して有効化してください。 必要なメトリクスを表示するダッシュボードを作成 以下は、実際に利用したダッシュボードの例です: 各メトリクスは一例ですが、以下のような設定をします。 ここで後ほど Docker コンテナで設定する DD_HOSTNAME と同じ値を host:<設定値> に設定しておきます。 後ほど説明しますがこの設定をすることで特定の負荷テストにターゲットを絞ってメトリクスを表示できます。 負荷を与える側のマシンを用意 まず前提として負荷を与える側にも負荷がかかるのである程度のスペックを用意したマシンが必要になります。 弊社では主に AWS を利用しているので、負荷テスト時はインスタンスタイプが m4.4xlarge の EC2 を用意して負荷テストを行いました。 インスタンスタイプは負荷に耐えうる、かつ少し余裕を持ったスペックを選定するのをお勧めします。 コンテナの用意 負荷をかける上で今回チームでは Docker を利用して EC2 内にコンテナを立てて負荷テストを実行しました。 まず以下のような docker compose 用のファイルを用意します。 services : k6 : container_name : k6 image : grafana/k6:latest networks : - k6 ports : - '6565:6565' environment : - K6_STATSD_ENABLE_TAGS= true - K6_STATSD_ADDR=datadog:8125 volumes : - # スクリプトのパスをマウントする depends_on : - datadog datadog : container_name : datadog-agent image : datadog/agent:latest networks : - k6 ports : - '8125:8125/udp' environment : - DD_SITE=datadoghq.com - DD_API_KEY=<YOUR_DD_API_KEY> - DD_DOGSTATSD_NON_LOCAL_TRAFFIC=1 - DD_HOSTNAME=<YOUR_HOSTNAME> volumes : - /var/run/docker.sock:/var/run/docker.sock:ro - /proc/:/host/proc/:ro - /sys/fs/cgroup/:/host/sys/fs/cgroup:ro Datadog エージェント用のコンテナの環境変数の DD_API_KEY には Datadog で利用している API key を利用してください。 また DD_HOSTNAME には特に指定はないですが、チーム名などわかりやすい名前を設定することをお勧めします。この値は Datadog のダッシュボードでメトリクスを指定する際に特定のテスト結果だけをメトリクス上に反映するために利用します。 参考: https://docs.datadoghq.com/ja/integrations/k6/ コンテナの準備ができたら以下のコマンドを実行して負荷をかけます。 docker compose run k6 run --out statsd <コンテナのスクリプトパス> ただし、現在 k6 のバージョン v0.55.0 で statsd のオプションは廃止されてしまったので xk6-output-statsd extension を利用して実行する必要があります。 詳しくは公式ドキュメントを参照ください。 参考: https://grafana.com/docs/k6/latest/results-output/real-time/datadog/ 負荷テストの実施 k6 の実行が完了すると以下のような結果の指標が表示されます。 以下の指標は今回実施した際の指標です。 k6 result /\ Grafana /‾‾/ /\ / \ |\ __ / / / \/ \ | |/ / / ‾‾\ / \ | ( | (‾) | / __________ \ |_|\_\ \_____/ INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0133] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0134] Failed on <executed api path>: expected 200 but got 500 source=console INFO[0734] Failed on <executed api path>: expected 200 but got 500 source=console ✗ status is 200 ↳ 99% — ✓ 366602 / ✗ 8 █ setup ✓ authenticated in successfully checks.........................: 99.99% 366605 out of 366613 data_received..................: 3.1 GB 1.5 MB/s data_sent......................: 54 MB 26 kB/s dropped_iterations.............: 16827 8.005593/s http_req_blocked...............: avg=3.57µs min=1.76µs med=1.93µs max=14.21ms p(90)=2.12µs p(95)=2.34µs http_req_connecting............: avg=417ns min=0s med=0s max=8.2ms p(90)=0s p(95)=0s http_req_duration..............: avg=288.92ms min=62.39ms med=239.73ms max=2.57s p(90)=491.68ms p(95)=621.08ms { expected_response:true }...: avg=288.89ms min=62.39ms med=239.73ms max=2.57s p(90)=491.64ms p(95)=621.01ms http_req_failed................: 0.00% 8 out of 366613 http_req_receiving.............: avg=199.78µs min=18.52µs med=169.85µs max=208.51ms p(90)=304.83µs p(95)=513.89µs http_req_sending...............: avg=77.22µs min=28.42µs med=72.18µs max=1.16ms p(90)=91.57µs p(95)=100.69µs http_req_tls_handshaking.......: avg=935ns min=0s med=0s max=9.5ms p(90)=0s p(95)=0s http_req_waiting...............: avg=288.65ms min=62.24ms med=239.44ms max=2.56s p(90)=491.35ms p(95)=620.75ms http_reqs......................: 366613 174.419363/s iteration_duration.............: avg=1.44s min=643.55ms med=1.34s max=5.7s p(90)=1.96s p(95)=2.42s iterations.....................: 73322 34.883587/s vus............................: 0 min=0 max=60 vus_max........................: 60 min=50 max=60 running (35m01.9s), 00/60 VUs, 73322 complete and 0 interrupted iterations ※ executed api path の部分にはリクエストを送信したパスが表示されます。 上記の結果を見ると平均で約 174 RPS の負荷をかけた結果 99% のリクエストは成功しているが、8回リクエストが失敗 していることがわかります。 Datadog Datadog の指標も一部見てみましょう。 HTTP リクエストの動作を見ると、理想的な台形型の負荷曲線にはなっておらず、負荷上昇フェーズでリクエストが失敗する、または適切に送信されないケースが見られました。(通常、問題がなければ負荷曲線は綺麗な台形になります) さらに詳細な指標を確認した結果、データベースとのコネクションタイムアウトが発生していることが判明しました。 チームで議論した結果、以下の仮説を立てました: 「確保しているコネクションプールの上限を超えるリクエストが発生し、API と RDS Proxy 間の TLS 3way ハンドシェイクに時間がかかりタイムアウトした可能性がある。」 この問題は本番環境でも時折発生していたため、仮説に基づきプールサイズの調整を行い監視を続けた結果、これまでに出ていた 500 エラーを減らすことができました。 この負荷テストを通してエラーの原因に対して仮説を立て、システムの改善に至ることができたのでやって良かったと思っています。 その他利用できるツール 普段、Kubernetes や RDS のメトリクスを Datadog で取得しています。 なので負荷テスト時にそれぞれの指標が一緒に見れるように Datadog を利用して k6 の指標を確認しました。しかし他のツールを利用して結果を観測することもできます。 たとえば Grafana dashboards を利用すると以下の画像のようなリッチな感じで結果を見ることもできます。 引用: https://grafana.com/docs/k6/latest/results-output/grafana-dashboards/ まとめと今後の展望 k6 と Datadog を利用することで、簡単かつ効果的に負荷テストを実施できます。今後は、Write系エンドポイントへの負荷テストや、RDS Proxy間のパフォーマンス改善に取り組んでいく予定です。 もし負荷テストを検討している方がいれば、ぜひ試してみてください!
アバター
はじめに Corporate Engineering という部署で社内営業組織が業務で使用するSalesforceの運用や社内システム開発を担当している瀧山です。 RevCommではコミュニケーションツールとしてSlack、ドキュメント管理ツールとしてNotionを使用しています。 今回は、Slackで投稿された有益なスレッドにリアクション(以下、「スタンプ」と記載)をつけた際にNotion連携するアプリケーションを社内営業組織向けに作成したので概要や仕組みなどを説明したいと思います。 本ブログ内で書かないこと 処理のコーディング 使用技術の詳細な説明 想定読者 Slack Boltを使用したSlack App開発に興味がある方 Slackに投稿されたスレッドをナレッジ化したいと思っている方 Notion連携に興味がある方 開発に至る背景 依頼元の営業チームでは、不明点などがあった際にSlackやNotionにて関連のスレッドやページを検索するが情報量が多くて答えに行きつきにくく、検索に時間がかかってしまうという課題がありました。またドキュメント化を行う時間も中々取ることができなくて、以前誰かが経験したナレッジを共有することが上手くできないことから知識が属人化してしまうという課題もありました。 上記2つの課題から、テキストとして記録されているSlackのスレッドにスタンプをつけるだけで、Notionの指定のページに情報が集約される仕組みを作るのと定期的に集約された情報をチームで共有し合う時間を設けることにより知識の平準化を図ることを目的として、今回の開発に至りました。 開発したアプリケーションについて 使用した技術 Slack Bolt ユーザーのSlack内のアクションをトリガーにしてバックエンドで処理を行うために使用 Python Slack からのイベントをハンドリングして処理するバックエンドロジックを実装 Google Cloud Cloud Run を用いてサーバーレスな環境でアプリケーションをホストするために使用 Notion API Notionの指定のページに情報を登録するために使用 処理概要 全体の処理の内容は以下のシーケンスになります。 処理フロー 処理の流れだけだとイメージしづらいと思うので、キャプチャを元に補足したいと思います。 【シーケンス①〜②の処理】 連携したいSlackの投稿にスタンプを付与します。 【シーケンス③〜④の処理】 Notion に新規ページが作成されます。ページはViewとして管理されており、データテーブルとして一覧化しています。デフォルト作成時にはタイトルとタグ(情報を仕分ける際に使用)が適切に設定されていない状態になります。 補足なのですが、自動連携する情報として下記のような情報があります。 投稿者:スレッドを投稿したユーザー 推薦者:スタンプを付与したユーザー URL:スレッドのURL AI要約:Notion側で自動でページ情報の要約を生成してくれる 内容:スレッドのテキスト 【シーケンス⑤〜⑦の処理】 作成したSlack Appからスタンプを付与したユーザーに対してDMが届きます。 DMの内容には、「作成したNotionページのURL」、「作成したNotionページを更新するためのフォームボタン」があります。 「タイトルとタグを設定する」ボタンを押すと下のキャプチャの様なフォームが開きます。 適宜必要な情報を入力して送信を押下します。 【シーケンス⑧の処理】 フォームで入力した内容でタイトルとタグを更新します。 工夫したところ 営業チームの担当者から「スタンプを押してSlackからNotionへ連携する際に、タイトルとタグは手動で入力する必要があるがNotionページを開いてタイトルとタグを入力することは若干手間のため運用が回らない可能性がある。なんとかSlack内で完結できないか?」という要望をいただきました。 「Slack内で完結すること」と「なるべく手間となる作業がないこと」の二つを意識して仕組みを検討しました。 結果、スタンプを押したユーザーにDMを送信、フォームで必要事項の入力だけであれば運用が回りそうという回答をいただき実装しました。(シーケンス⑤〜⑧の内容) 詳細は割愛しますが、送信されたフォームイベントを受信するために Interactive messages を使用しました。 導入後 現在は営業チームだけでなく、Customer Successのチームでも活用されており、日々Slack内の投稿がナレッジとしてNotionへ集約されており、各チームでナレッジの共有時間を設けることで知識の平準化が行われています。 今後 アプリケーションをさらに進化させるために、Vertex AIを活用した自動化を検討しています。具体的には、自然言語処理モデルを用いてSlackの投稿内容からタイトルを自動生成し、分類モデルを用いて適切なタグを自動付与する機能の実装を目指しています。これにより、ユーザーはタイトルやタグを入力する手間が省け、より迅速かつ気軽にナレッジを集約できるようになります。また、要約モデルを活用することで、長文の投稿内容を簡潔に要約し、Notionページに表示することで、ユーザーがより効率的に情報を把握できるようにしていく予定です。
アバター
この記事は RevComm Advent Calendar 2024 の 12日目の記事です。 はじめに こんにちは、バックエンドエンジニアの矢島です。 普段は主にバックエンド領域の開発・保守・運用を行っていますが、チームのサブマネージャーとして組織の運用改善なども行っています。 多くのソフトウェア開発チームが1度は直面する課題の一つに「属人化」があります。特定の機能や領域の知識が特定のメンバーに集中してしまい、その人が不在の際に対応できない、あるいは新機能の開発スピードが落ちてしまうといった問題です。 この記事では、弊社のプロダクトMiiTel の開発チームで実施した、属人化の解消への取り組みについて紹介します。 属人化の解消の背景 RevCommでは、電話・Web会議・対面での全ての会話を最適化する音声解析AIのMiiTelを開発しています。 中でも私が所属してる電話のデータを解析・可視化するMiiTel Phone Analytics は、RevComm創業当初からあるプロダクトで様々な機能拡張が行われてきました。 製品の成長に伴い多くの機能を保守・運用することに加え、プロダクトの改善も行う必要があります。 そこで半年のうちに3名のメンバー増員があり、以下のような課題に直面しました。 各機能の仕様や実装の複雑化による新メンバーの学習コストの増加 特定のメンバーしか対応できないことによる開発や問い合わせ対応のボトルネックの発生 一部メンバーへの負担の集中や、チーム全体の生産性の低下の恐れ これらの課題に対し、「チームの全メンバーが全ての機能の調査・開発を行えるようにすることで中長期視点でのチームの生産性を向上させる」というミッションを設定し、約半年間かけて属人化の解消の取り組みを実施しました。 属人化の解消への取り組み 現状の把握と効果測定の方法を決定 属人化効果を測定するため、以下のようなアンケートを属人化の解消の取り組み前と、1ヶ月単位のスプリント終了時に実施することにしました。 各機能への経験を選択してもらう 「該当の機能の存在を知っているか」、「機能を使ったことがあるか」、「ドキュメントを読んだことがあるか」、「レビューをしたことがあるか」、「問い合わせ対応をしたことがあるか」、「実装したことがあるか」という複数回答可の選択項目を用意しました。 各機能への対応における精神的な負担度を5段階で評価してもらう 取り組み始めた当初は、精神的な負担が属人化に繋がるメインファクターであると考えており、各機能の理解が進むことで、機能対応時の精神的負担が減り、属人化解消に繋がると考えていました。この考えは後で更新されることになります。 機能ごとの理解度や不安要素を自由記述してもらう 施策1:機能共有会の実施 施策としてまず取り組んだのが、各機能の詳細な共有会の実施です。タイムパフォーマンスを考えた時に全ての機能の共有会を実施することは好ましくないため、アンケートから特に属人化している機能や新機能についての共有会を実施しました。 特に複雑な機能については、機能の概要・設計思想とシステムアーキテクチャ・コードリーディングのように複数回に分けて共有を行いました。 施策2:意図的なタスクのアサイン 学習のレベルにおいても、知っていることよりも行うことのほうがより高いレベルに位置付けられるのと同じで、ドキュメントを読むよりも実際に手を動かす方が理解が進むということはよくあると思います。そこで実際に普段触れる機会の少ない機能について、本来は優先度が高くないものも含めた以下のようなタスクを作成し、意図的にアサインしました。 既存機能の単体テスト追加:コードリーディングができることに加え、テストケース作成を通じて機能の仕様理解 ・コードの品質向上と保守性の改善を図れます。 エラーハンドリング改善タスク:コードリーディングができることに加え、保守・運用の改善を図れます。 音声ファイル処理バッチの改善:データフローの全体像の把握ができ、パフォーマンスチューニングも実現できます。 なかでも単体テストの追加は、実際のコードを動作させながらテストを書くことで、自然と機能への理解が深まるという効果がありました。顧客影響がないコードを追加できるという点でも、属人化の解消を推進するタスクとしてはおすすめです。 アンケートの結果からチームに施策の相談 施策1, 2 を実施したところで2回のスプリントが終わり、アンケートも2回実施しました。 当初の取り組みでは、精神的負担の軽減という指標で効果を測定していましたが、アンケート結果に改善が見られなかったため、チーム全体で相談の機会を設けました。 そこから新たな施策やこれまでの取り組みの改善の発案がされ、実際に以降の取り組みに活かすこととなりました。 アンケートの改善 精神的な負担が属人化に繋がるメインファクターであると考えてアンケートを作成していましたが、「精神的負担」という指標よりも「各機能に対応できるか」という具体的な指標の方が、属人化解消の目的に適していることが明確になりました。この気づきを基に、アンケートの設計を以下のように見直しました。 各機能への対応における精神的な負担度を5段階で評価してもらう → 問い合わせ調査やバグフィックスを依頼されたときに対応できるかを判断してもらう 「わからない」「対応できない」「内容の指示を貰えば対応できる」「基本1人で進め」「必要に応じて質問しながら対応できる」「1人で対応できる」という選択項目を用意しました。 これにより、対応できはするけど精神的に負担はあるという状態があっても、より属人化の解消という目的に即した効果測定が可能になりました。 施策3: オンコール制の導入 アンケート項目に問い合わせ対応経験を聞く項目があったことから、一次対応を当番制にしてよいのではないかという意見があがりました。 そこで、業務時間中に発生する問い合わせに対して迅速な一次対応と、問い合わせ対応を通じたキャッチアップの促進を行うための仕組みとして、オンコール制を導入しました。週替わりで2名のエンジニアがメインとサブに分かれてオンコールの担当となり、問い合わせ内容の初期調査や必要に応じて適切なチームメンバーへのエスカレーションを行うようになりました。 この制度により、問い合わせ対応の効率化だけでなく、各メンバーが様々な機能についてキャッチアップする機会が増えました。「問い合わせ対応を通じて自然と機能理解が深まった」という声がメンバーからも出るようになり、結果として属人化の解消にも貢献しました。 施策4:カオスエンジニアリングの実践 最後の比較的大きな施策として、意図的に障害を発生させ、その調査や対応を通じて理解を深める「カオスエンジニアリング」を実施しました。 カオスエンジニアリングとは、本番環境で起こりうる障害や異常な状態を、制御された環境で意図的に再現し、システムの回復性や耐障害性を検証する手法です。Netflixが先駆けとなり、現在では多くの組織で採用されています。 RevComm でも各開発チームでカオスエンジニアリングの取り組みが定期的に行われており、私も別チームで行ったことがありました。 属人化解消の取り組みを行うチームでは行ったことはありませんでしたが、この手法を知識共有の手段として活用することにしました。 通常のカオスエンジニアリングは本番環境の堅牢性や可用性を確認する目的で行われますが、私たちの場合は開発環境で実施し、障害対応を通じた学習に重点を置きました。これにより、メンバーは実際の障害対応に近い形で、システムの動作原理や障害発生時の調査方法を学ぶことができました。 具体的良かった点は以下の通りです。 チーム全体での障害調査と解決プロセスの実践 ・調査手法やログの見方を実践的にキャッチアップできる。 チームメンバー間での知識共有が促進される。 システムの挙動確認と知識の共有 ・システムの依存関係や連携フローを具体的に把握できる 。 トラブルシューティングのノウハウを蓄積できる。 この取り組みは特に効果的で、振り返りでは「新鮮な体験で機能理解が大きく進んだ」という評価を得ました。 結果と考察 約半年間の取り組みによって、以下のような結果が得られました。 アンケートでは4つの施策を通して精神的負担が下がることはなかった。 一方で、メンバーが対応できると言える機能が増加し続けた。 対象としていた機能については「わからない」「対応できない」と回答するメンバーはゼロとなった。 当初設定していた「精神的負担」という指標では改善が見られませんでしたが、業務の属人化の解消は十分実現できていると考えられる結果となりました。 まとめ この取り組みを通じて、以下のような重要な学びが得られました。 目的に合った適切な指標を設定することが重要。一方でOKRの Key Result のように途中で指標を変えることも必要に応じて検討することで進みながらの改善が可能。 ドキュメントやコードリーディングよりも、実際の体験を通じた学習の方が効果的。 個人の努力だけでなく、チームで会話し継続的に実施・改善していくことが重要。 今後は、これらの学びを活かしながら、新しい機能や技術が導入された際にも、スムーズに知識を展開できる体制を維持していきたいと考えています。また、この経験を他のチームとも共有し、組織全体としての改善にも繋げていければと思います。 属人化の解消は、一朝一夕には実現できない課題です。しかし、明確な目標設定と複数の施策の組み合わせ、そして適切な指標による効果測定を行うことで、着実に改善を進めることができます。皆様の組織でも、本記事で紹介した取り組みが何かしらの参考になれば幸いです。
アバター
KubernetesではAPIサーバーやバッチ処理、イベント駆動型のタスクなど、さまざまなケースに合わせた「ワークロードリソース」の種類を選択し、柔軟に運用できます。 本記事では、弊チームのシステム設計の例をもとに、ワークロードリソースの中でも、ScaledJobとScaledObject + Deploymentの違いに注目して、使い分けにおける学びを共有できればと思います。 結論 設計の背景 初期選択:ScaledJobの活用と課題 方向転換:ScaledObject + Deploymentの選択 manifestの例 学びと今後の展望 結論 イベント駆動型タスクであっても、実行間隔がPod起動にかかる時間より短いのであればScaledJobではなくScaledObject + Deploymentを選定するのが良いです。 設計の背景 ユーザーが行った通話の情報(通話時間、対応者、発信か着信か、不在かなど)を整えてデータベースに保存する仕組みを新たに構築することになりました。このデータは、サービスのさらなる利便性向上のために活用されるものです。 しかし、既存のシステムから最新の情報をスピーディーに連携することが難しく、既存の仕組みのみでは「最新情報を遅延2分以内に取得したい」という計画当初の要件を満たすことができませんでした。また、長期間のデータ集計の際に、レスポンス遅延が発生する懸念がありました。そこで、新たな仕組みを導入することになりました。 この通話情報は1000件/分以上の頻度で送られてきても処理できる必要があります。 ただし遅延は減らしたいものの、リアルタイム反映が求められるシーンとは利用シーンの切り分けができました。そこで将来的に通話情報を複数形式で保存する可能性を見越し、AWSのSNS+SQSのファンアウト形式を採用しました。この形式を取ることで、情報を柔軟に拡張し、新たな処理を追加することになっても影響を最小限に抑えることができます。 また、これにより通話情報を送信する側の通話履歴管理システムと、通話情報を利用する側の連絡先システムとで、関心ごとの分離を明確にできました。それぞれのシステムを運用している別々のチームが独立してシステムの改善や保守を進めやすい構成にできた点も大きなメリットです。 構成図 初期選択:ScaledJobの活用と課題 「イベント駆動型タスクの処理になる」と考えたことから、最初にScaledJobを選択しました。ScaledJobは、SQSをトリガーにしてジョブをスケーリングできることから最適に思えたためです。さらに、処理時間が短いタスクなので、Podの台数を増やすことで大量のSQSであってもさばけると考えました。 しかしScaledJobではトリガーごとに新しいPodを立ち上げる必要があります。つまりPod起動時間がかかります。メインとなる処理時間は短時間だったとしても、Pod起動の時間に数分かかるとすると、起動中にも次々やってくる1000件/分のSQSを処理するためには結果的に同時に何千ものPodを立ち上げていないといけないことになります。これでは、求めるスピードや効率に達することが難しいと判断し、別のアプローチを検討することになりました。 方向転換:ScaledObject + Deploymentの選択 次に選んだのは、ScaledObject + Deploymentを用いる方法です。ScaledObjectでスケーリングをして、DeploymentのPod内で、SQSのメッセージを定期的にポーリングし続け、ScaledObjectのスケーリングとは別に内部でも並列で処理する形で実装しました。これにより、Podの立ち上げ時間の無駄を削減し、大幅に効率を改善することができました。 Rolloutを選ばずDeploymentを採用した理由は、ジョブ処理ではサービスを維持しながら安全にデプロイする必要がないためです。APIのように即時性を求められるものではなく、メッセージがSQSに溜まる設計であるため、サービスを一時停止しても問題ないからです。 また、Rollout は ArgoCD によるワークロードリソースなので、特に理由がなければKubernetesの標準機能であるDeploymentを選定し、シンプルに扱えるようにしたいと思いました。 ScaledObject + Deploymentを選択したことで、最新情報の取得速度は大幅に改善され、当初目指していた以上に遅延縮小することができています。また、Deploymentの利用により、必要なPodの台数を削減し、リソース効率も向上しました。 manifestの例 今回の構成のmanifestでの定義の例と、チューニングのポイントです。 apiVersion : apps/v1 kind : Deployment metadata : name : sample-deployment spec : selector : matchLabels : app : sample-app template : metadata : labels : app : sample-app spec : terminationGracePeriodSeconds : 300 containers : - name : sample image : DOCKER_IMAGE command : [ "echo hoge" ] --- apiVersion : keda.sh/v1alpha1 kind : ScaledObject metadata : name : sample-scaledobject spec : scaleTargetRef : name : sample-deployment pollingInterval : 60 minReplicaCount : 1 maxReplicaCount : 16 triggers : - type : aws-sqs-queue Deployment terminationGracePeriodSeconds scale in発生時に処理途中で終わったSQS messageに関してはpopしたmessageをdeleteせずに終わる可能性がある メイン処理に必要な時間を考慮した上で適切に設定する必要あり 余裕をもたせると良い ScaledObject maxReplicaCount 一番SQSが多い時間帯に十分なPodが起動できるように設定 pollingInterval scale in/outが発生する頻度の調整をする 急激なSQS量の増減に備えるため 学びと今後の展望 Kubernetesは学習コストが高いと言われますが、実際に使う中で得た知識や工夫を共有し合うことで理解を深めることが重要だと感じています。また、今回のように要件に応じて最適なアプローチを模索するプロセスは非常に有意義でした。 今後は、さらにKubernetesの知識を深め、最新バージョンの機能も活用することで、よりシンプルで効率的な設計・運用を目指していきたいです。 この記事が、Kubernetesの運用に興味を持っている方々の参考になれば幸いです。
アバター
はじめまして。 RevComm でフロントエンドエンジニアをしている大石と申します。 私の所属しているチームでは、Lean スタートアップの考えを基にプロダクト開発に取り組んでおり、エンジニアが機能を実装してリリースして終わりではなく、そのリリースした機能の利用実態を定量データとして収集し、プロダクト成長の方針などの検討に活かしています。 その定量データを収集する方法として Google Analytics を利用しており、今回の記事では Google Analytics で詳細なデータを収集するための実装方法を紹介します。 ユーザープロパティを設定してユーザーをセグメントで分ける Google Analytics では、 ユーザープロパティ を使用してユーザーに関する任意の情報を付与することができます。 これによりユーザーを任意条件のセグメントとして分けることができ、特定のイベントのユーザーごとの実行割合をセグメント単位で算出したり、リリースした機能を使ってくれているかどうかを分析したりできます。 ユーザープロパティの設定 Google Analyticsの管理画面で ユーザープロパティ を設定します。 例えば、ユーザーのステータスやWebサービスにおける権限情報などを追加します。 公式ドキュメント にはいくつか方法がありますが、ここでは Google タグでの方法を紹介します。 ユーザープロパティの送信 ユーザーがサイトにアクセスした際にユーザープロパティをGoogle Analyticsに送信します。 例: gtag('set', 'user_properties', {'user_id': 'USER_ID', 'role': 'admin'}); ユーザースコープのカスタムディメンションを作成 Google Analyticsの管理画面で ユーザースコープのカスタムディメンションを作成 します。 カスタムディメンションの範囲を「ユーザー」に設定し、先ほど送信したユーザープロパティに対応するディメンションを作成します。 例: user_id や role などのカスタムディメンションを作成します。 ユーザープロパティの活用例 既存UIに対する大規模な変更を加える場合、いきなり新UIを適用するのではなく新旧両方のUIを切り替えられるようにして提供することでユーザーの混乱を抑えることができます。 この場合に、新UIがどれだけユーザーに受け入れられているのかをGoogle Analyticsのユーザープロパティを利用して調査することができます。 例として、サービス全体のリデザインに伴う新UIを提供する場合に切り替えボタンを用意しておき、そのボタンをクリックしたら新旧UIを切り替えられるような機能を想定します。 その際にブラウザリロードしても新旧どちらを選択しているのかを保持しておきたいため、ローカルストレージなどに {is_new_ui: true || false} のような形で保存しておきます。 前述したユーザープロパティとしてこのローカルストレージの値を送ることで、どれくらいのユーザーが新UIを使っているのかをGoogle Analytics上で調査することができ、これにより新UIが受け入れられているのかどうかが判断できます。 一例ではありますが、こういった活用を通じてユーザーの行動やニーズをより深く理解し、プロダクトの改善戦略の最適化に役立てることができます。 カスタムプロパティを追加しイベント実行時の詳細なデータを送る Google Analytics ではイベントをトラッキングする際にカスタムプロパティを追加することができます。 これにより、特定のイベントに関連する詳細な情報を収集することが可能です。 公式ドキュメント カスタムプロパティを使ってイベントの詳細を確認する方法 カスタムイベントの設定 フロントエンドのコードで、イベントが発生した際にカスタムプロパティを含むイベントをGoogle Analyticsに送信します。 例: gtag('event', 'action_name', {'custom_property': 'xxx'}); イベントスコープのカスタムディメンションを作成 Google Analyticsの管理画面で、 イベントスコープのカスタムディメンションを作成 します カスタムディメンションの範囲を「イベント」に設定し、先ほど送信したカスタムプロパティに対応するディメンションを作成します カスタムプロパティの活用例 大量のデータを分析するための機能としてダッシュボードがあります。 一般的にダッシュボードにはデータを絞り込むためのフォームが用意されていますが、そのフォームをサブミットした際に「サブミットした」ことだけをイベントとして収集していても絞り込み機能が使われたことしかわかりません。 ユーザーがダッシュボードで何を見ているのかを知るためには、どういう条件で絞り込みをしているのかの詳細情報まで知る必要があり、その詳細情報はカスタムプロパティとして収集することができます。 例えば、複数の選択肢から期間を選択するセレクトボックスがあり、その選択肢は以下のように定義されているとします。 const periods = [ { value : 1 , label : '今日' , } , { value : 2 , label : '先週' , } , { value : 3 , label : '先月' , } , { value : 4 , label : '過去30日' , } , ] フォームがサブミットされた時点でユーザーがどの期間を選択していたのかを把握するために、以下のような形で選択された期間の値をカスタムプロパティとして収集することができます。 gtag( 'event' , 'form_submit' , { 'selected_period' : ` ${ periods[].value } ` } ); これにより、ユーザーはどの期間のデータをよく見ているのかを分析することができます。 また、ユーザーで絞り込んでデータを見ることができる場合に、「自分自身」を絞り込み条件に含めているかどうかを収集するような使い方もできます。 const own = { id : 1 , label : '山田 太郎' , } const users = [ { id : 1 , label : '山田 太郎' , } , { id : 2 , label : '鈴木 一郎' , } , { id : 3 , label : '佐藤 花子' , } , ] const selectedUsers: typeof users = [] ユーザーの絞り込みにユニークなid情報を利用している場合、そのユーザー自身のidが絞り込み条件の中に含まれているかどうかを判定し、以下のような形でカスタムプロパティを送ることで、ユーザーが自分自身のデータを見る目的でダッシュボードを利用しているのかどうか、という分析をすることができます。 gtag( 'event' , 'form_submit' , { 'is_selected_own' : selectedUsers. some ( u => u. id === own. id ) } ); エンジニアならではの提案でプロダクトを改善していきましょう ユーザーにとって価値あるプロダクトを作るためにはユーザーのことをより深く知る必要があり、そのための方法は実際のコードレベルで実装ができるエンジニアだからこそ提案できることがあります。 この記事で紹介した Google Analytics の活用方法が、実際のプロダクト改善のための分析のお役に立てれば幸いです。
アバター
Hi, Jose here! I recently began developing a private git package to be used by many services from our organization. While the basic setup was relatively straightforward, I quickly realized how scaling it encompassed many concepts. Factors like integrating with various repositories and adapting your CI/CD pipelines can significantly raise the bar. In this blog post, I will walk you through the process of installing a private git dependency and demonstrate how to use Poetry effectively to manage packages from multiple code repositories. Requirements Python 3.10 Poetry version 1.8.4 Working with public git dependencies Installing a dependency in Poetry is simple enough. Just run poetry add package_name . This will add the respective package to the pyproject.toml file. For git dependencies, we must specify the location of the repository with the git key. Let’s install the requests library from Github. poetry add git + https: //github.com/psf/requests.git@v2.32.2 Now your pyproject.toml will look as follows requests = { git = "https://github.com/psf/requests.git" , rev = "v2.32.2" } If you don’ specify the rev property, Poetry will take up the latest commit of the main branch. Check the official docs for more information. Installing a private git dependency Poetry needs to authenticate to your git provider to install private dependencies. In the case of Github, we create a Personal Access Token (PAT). A Personal Access Token provides a secure way to authenticate to GitHub without the need of a password. Generate a PAT, set up authentication and install the package. $ poetry config repositories . git - org - project https : //github.com/org/private_lib.git $ poetry config http - basic . git - org - project username $PAT_TOKEN $ poetry add git + https: //github.com/org/private_lib.git Notice that the pyproject.toml looks almost the same as the public repo. Poetry ensures that your private credentials aren’t reflected. [ tool . poetry . dependencies ] python = ">=3.10, <3.13" requests = { git = "https://github.com/psf/requests.git" , rev = "v2.32.2" } pydantic = "^2.10.3" private_lib = { git = "https://github.com/org/private_lib.git" } Managing multiple private repositories. Now imagine your project needs your sales team’ internal libraries hosted in a private GitHub repository, but your research team maintains their libraries in an AWS CodeArtifact repository. How would you seamlessly integrate both? Enter sources . Poetry uses sources to discover and install packages in your project. The default one is PyPI. Sources enable seamless integration of internal, third-party libraries without disrupting the main dependency flow. In the example above, we would run $ poetry source add -- priority = supplemental research https : //domain.d.codeartifact.ap-northeast-1.amazonaws.com/pypi/research/ $ poetry source add -- priority = supplemental sales https : //github.com/org/sales.git Poetry will add the following to your pyproject.toml . [[ tool . poetry . source ]] name = "research" url = "https://domain.d.codeartifact.ap-northeast-1.amazonaws.com/pypi/research/" priority = "supplemental" [[ tool . poetry . source ]] name = "sales" url = "https://github.com/org/sales.git" priority = "supplemental" We use priority supplemental to tell Poetry that PyPI should still be the main code repository. You can tweak the priorities to your needs, for instance, you can disable PyPI completely . Remember that we still need to setup authentication for each source. $ poetry config repositories . research https : //domain.d.codeartifact.ap-northeast-1.amazonaws.com/pypi/research/ $ poetry config http - basic . research username ca_token $ poetry config repositories . sales https : //github.com/org/sales.git $ poetry config http - basic . sales username token Finally we install our libraries $ poetry add sales - lib -- source sales $ poetry add research - lib -- source research Conclusion In this blog post we reviewed the steps to add private git repositories into Poetry. We also looked at how to manage multiple code repositories with Poetry. In a production environment, we would containerize the application and integrate it to a CI/CD pipeline. Those steps, although similar in nature, require extra care specially when using secret tokens. References https://python-poetry.org/docs/dependency-specification/#git-dependencies https://python-poetry.org/docs/repositories/#package-sources https://medium.com/@irac.grgic/poetry-automatically-configure-credentials-for-all-private-repositories-541ce3b78759
アバター
はじめに RevComm のフロントエンドエンジニアの上川です。 MiiTel Call Center というプロダクトの開発を担当しています。 これまで、ロードマップ機能の開発では、バックエンドとフロントエンドの担当者が完全に分かれていました。 今回は、フロントエンドを担当してきた自分が、バックエンド開発に挑戦してみた経験と、そこから得た学びについて共有したいと思います。 バックエンド開発に挑戦した背景 バックエンドチームが他のタスクに注力している状況を受け、コールセンターの「ロードマップ機能」をフロントエンドチームだけで実装する提案がありました。 以前からバックエンド開発に興味があったため、この機会に挑戦することを決めました。 実装内容 コールセンターに表示する新しい項目の実装を担当しました。 具体的には、新しい項目の値をバックエンドで計算・取得し、フロントエンド側のテーブルに表示するまでの一連の実装を行いました。 この経験により、これまでのフロントエンド中心の視点から、データ取得からUI表示までの全体的なフローを把握できるようになりました。 開発の進め方 PMの主導のもと、以下のように段階的に進めていきました。余裕を持った計画のおかげで、不安なく円滑に進行することができました。 最初の1ヶ月は、Design Docsを活用しながら週1回の短いミーティングで要件を詰めていきました。 Design Docsには以下の内容を記載していきました。 項目の値の計算式 既存の類似項目の実装調査と修正箇所の特定 Redashを活用したSQL文の作成 次に、他のフロントエンドメンバーとペアで1つの機能を実装し、それぞれの担当部分をミーティングで共有しました。 最後に、1つの機能を単独で実装しました。 チームサポートのありがたさ ENUMを扱うマイグレーションやAWS Athenaでのテーブル再構成など、初めて触れる領域で苦労することもありました。 ただし、バックエンドチームのメンバーにSlackやMeetで気軽に質問できたおかげで、エラーや不明点をスムーズに解決できました。 バックエンドとフロントエンドが同じスクラムチームで活動していることや、チームオフサイトで直接交流する機会があったことで、質問しやすい環境が自然とできていたと感じています。 新たな学び Python 以前は基礎的な経験しかなかったPythonですが、今回文法を体系的に学び直し、Pydanticなどのライブラリの役割についても深く理解することができました。 GraphQL QueryやSubscriptionの仕組みについて、データベースからデータを取得し、レスポンスとして返すまでの一連の流れを理解することができました。 DB データベースマイグレーションの実践的な手法と重要な注意点について学びました。 AWS AthenaのテーブルとECSのAuto Scalingの基本的な操作を経験しました。 エラー対応 バックエンドの仕組みを理解したことで、Datadogから通知されるエラーの内容や影響範囲を正確に把握できるようになりました。 レポートのバッチ処理でエラーが発生した際も、どのデータが欠損する可能性があるのか、そしてそれがフロントエンドの表示にどう影響するのかを的確に判断できるようになりました。 ロードマップ機能を一人で実装できるようになった 今回の最大の成果は、 フロントエンドからバックエンドまで、ロードマップ機能を一人で実装できるようになった点 です。 これまで他のメンバーに頼っていた領域も自力で対応できるようになり、開発の効率性と柔軟性が大幅に向上しました。 この経験を糧に、今後はより大規模な機能開発にも挑戦していきたいです。 技術的な視野が広がったことで、複雑な課題にも自信を持って取り組めるようになりました。 おわりに 今回、バックエンド開発に挑戦したことで、新たなスキルを身につけることができました。これまでブラックボックスだった部分が明確になり、フロントエンドとバックエンドの仕組みを深く理解できるようになりました。 バックエンドメンバーやチームのサポートのおかげで、未知の領域や初歩的な課題を着実に乗り越え、自走できる範囲が広がりました。その結果、ロードマップ機能をフロントエンドからバックエンドまで一人で実装できるようになり、開発の効率性と柔軟性が向上し、自身の成長も実感できました。 今後は、この経験で得た知見とスキルを活かし、より大規模で複雑な機能開発にも積極的に挑戦していきます。さらなる学びを重ね、チームとプロダクトに貢献できるエンジニアとして成長を続けていきたいと思います。
アバター
はじめに vinxi はフルスタックアプリケーションやメタフレームワークの構築が可能なパッケージです。 開発サーバーとバンドラーに Vite を、本番サーバーには Nitro を使用しています。 SolidStart や TanstackStart で採用されています。 今回は vinxi を使ってメタフレームワークを作ってみます。 進めるにあたり、公式のサンプルや以下の記事を参考にさせていただきました。 Bullding a React Metaframework with Vinxi Simple RSC With Vinxi セットアップ package.json を作成し、 npm init -y 以下の内容を追加します。 { " name ": " try-vinxi ", " version ": " 1.0.0 ", " description ": "", " type ": " module ", " scripts ": { " dev ": " vinxi dev ", " build ": " vinxi build ", " preview ": " vinxi preview " } , " keywords ": [] , " author ": "", " license ": " ISC ", " dependencies ": { " @vinxi/react ": " ^0.2.5 ", " @vitejs/plugin-react ": " ^4.3.4 ", " react ": " ^18.3.1 ", " react-dom ": " ^18.3.1 ", " vinxi ": " ^0.5.0 " } , " devDependencies ": { " @types/react ": " ^18.3.12 ", " @types/react-dom ": " ^18.3.1 ", " typescript ": " ^5.7.2 " } } tsconfig.json を作成します。 { " compilerOptions ": { " module ": " ESNext ", " moduleResolution ": " bundler ", " jsx ": " react-jsx ", " esModuleInterop ": true , " strict ": true } } 依存関係をインストールします。 npm install SPA まずはシンプルな SPA モードを作成します。 index.ts を作成 ルートに index.ts を作成します。空の HTML を返すだけのハンドラーです。 import { eventHandler } from 'vinxi/http' ; export default eventHandler(() => { return new Response ( `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Vinxi</title> </head> <body> <div id="root"></div> <script src="./src/entry-client.tsx" type="module"></script> </body> </html>` , { status : 200 , headers : { 'Content-Type' : 'text/html' , } , } ); } ); entry-client.tsx を作成 src ディレクトリを作成し、その中に entry-client.tsx を作成します。 import { createRoot } from 'react-dom/client' ; import Counter from './counter' ; createRoot( document . getElementById ( 'root' )!).render( < Counter /> ); counter.tsx を作成します。 import { useState } from 'react' ; export default function Counter () { const [ count , setCount ] = useState( 0 ); return ( < div > < h1 > Counter </ h1 > < p > { count } </ p > < button onClick = { () => setCount(count + 1 ) } > Increment </ button > </ div > ); } app.config.ts を作成 ルートに app.config.ts を作成し、 createApp 内に vinxi の設定を記述します。 @vitejs/plugin-react を追加します。vinxi は Vite を使用しているため、Vite のプラグインをそのまま使用できます。 npm install @vitejs/plugin-react spa ルーターを追加します。handler には先ほど作成した index.ts を指定します。 import { createApp } from 'vinxi' ; import pluginReact from '@vitejs/plugin-react' ; export default createApp( { routers : [ { name : 'spa' , type : 'spa' , handler : './index.ts' , target : 'browser' , plugins : () => [ pluginReact() ] , } , ] , } ); 開発サーバーを起動します。 npm run dev カウンターが表示されることを確認します 🎉 SSR 次に SSR モードを作成します。 app.config.ts を編集 まずは app.config.ts を編集します。 spa ルーターを削除し、 client と ssr ルーターを追加します。 import { createApp } from 'vinxi' ; import pluginReact from '@vitejs/plugin-react' ; export default createApp( { routers : [ { name : 'client' , type : 'client' , handler : './src/entry-client.tsx' , target : 'browser' , plugins : () => [ pluginReact() ] , base : '/_build' , } , { name : 'ssr' , type : 'http' , handler : './src/entry-server.tsx' , target : 'server' , plugins : () => [ pluginReact() ] , } , ] , } ); 各ファイルを作成していきます。 app.tsx を作成 src ディレクトリに app.tsx を作成します。 createAssets は各アセットを注入するコンポーネントを作成します。遅延コンポーネントになっているため、 Suspense でラップします。 import { getManifest } from 'vinxi/manifest' ; import { createAssets } from '@vinxi/react' ; import { Suspense } from 'react' ; import Counter from './counter' ; const Assets = createAssets( getManifest( 'client' ).handler, getManifest( 'client' ) ); export default function App () { return ( < html > < head > < Suspense > < Assets /> </ Suspense > </ head > < body > < div id = "root" > < Counter /> </ div > </ body > </ html > ); } entry-client.tsx を編集 createRoot を hydrateRoot に変更します。 クライアントのランタイムを読み込むために vinxi/client をインポートします。 import { hydrateRoot } from 'react-dom/client' ; import App from './app' ; import 'vinxi/client' ; hydrateRoot( document , < App /> ); entry-server.tsx を作成 src ディレクトリに entry-server.tsx を作成します。 import { getManifest } from 'vinxi/manifest' ; import { eventHandler } from 'vinxi/http' ; import { renderToPipeableStream } from 'react-dom/server' ; import App from './app' ; export default eventHandler( { handler : async ( event ) => { const clientManifest = getManifest( 'client' ); const stream = await new Promise ( async ( resolve ) => { const stream = renderToPipeableStream( < App /> , { onShellReady () { resolve(stream); } , bootstrapModules : [ clientManifest.inputs[clientManifest.handler ] .output.path, ], bootstrapScriptContent : `window.manifest = ${ JSON . stringify ( await clientManifest.json() ) } ` , } ); } ); event.node.res.setHeader( 'Content-Type' , 'text/html' ); return stream; } , } ); 開発サーバーを起動します。 npm run dev SSR された HTML が返ってきました 🎉 File system routing vinxi はファイルシステムルーティングを作成するための機能を提供しています。 FileSystemRouter を作成 vinxi の BaseFileSystemRouter を継承した FileSystemRouter を作成します。今回は app.config.ts の中に書いていきます。 toPath メソッドは引数にファイルのパスを受け取り、ルートのパスを返します。 cleanPath はディレクトリ名と拡張子を取り除く vinxi のユーティリティ関数です。 toRoute メソッドは引数にファイルのパスを受け取り、ルートオブジェクトを返します。このルートオブジェクトは vinxi/routes モジュールによってアプリケーションに提供されます。 class FileSystemRouter extends BaseFileSystemRouter { toPath ( src : string ) { const routePath = cleanPath(src, this .config) . slice ( 1 ) . replace ( /index$/ , '' ); return routePath?. length > 0 ? `/ ${ routePath } ` : '/' ; } toRoute ( filePath : string ) { return { path : this .toPath(filePath), $component : { src : filePath, pick : [ 'default' ] , } , } ; } } 各ルーターの routes プロパティに FileSystemRouter を追加します。最終的に app.config.ts は以下のようになります。 import { createApp } from 'vinxi' ; import pluginReact from '@vitejs/plugin-react' ; import { BaseFileSystemRouter, cleanPath } from 'vinxi/fs-router' ; import path from 'node:path' ; class FileSystemRouter extends BaseFileSystemRouter { toPath ( src : string ) { const routePath = cleanPath(src, this .config) . slice ( 1 ) . replace ( /index$/ , '' ); return routePath?. length > 0 ? `/ ${ routePath } ` : '/' ; } toRoute ( filePath : string ) { return { path : this .toPath(filePath), $component : { src : filePath, pick : [ 'default' ] , } , } ; } } export default createApp( { routers : [ { name : 'client' , type : 'client' , handler : './src/entry-client.tsx' , target : 'browser' , plugins : () => [ pluginReact() ] , base : '/_build' , routes : ( router , app ) => { return new FileSystemRouter( { dir : path. join (__dirname, 'src/routes' ), extensions : [ 'tsx' , 'ts' ] , } , router, app ); } , } , { name : 'ssr' , type : 'http' , handler : './src/entry-server.tsx' , target : 'server' , plugins : () => [ pluginReact() ] , routes : ( router , app ) => { return new FileSystemRouter( { dir : path. join (__dirname, 'src/routes' ), extensions : [ 'tsx' , 'ts' ] , } , router, app ); } , } , ] , } ); 各ルートファイルを作成 先ほど指定した src/routes ディレクトリに、いくつかルートファイルを作成します。 src/routes/index.tsx import Counter from '../counter' ; export default function Index () { return ( < div > < h1 > Index </ h1 > < Counter /> </ div > ); } src/routes/about.tsx export default function About () { return ( < div > < h1 > About </ h1 > < p > This is the about page. </ p > </ div > ); } src/routes/docs/guide.tsx export default function Guide () { return ( < div > < h1 > Guide </ h1 > < p > This is the guide page. </ p > </ div > ); } React Router をインストール 今回はルーターライブラリに React Router v6 を使用します。 npm install react-router-dom@6 app.tsx を編集 import { getManifest } from 'vinxi/manifest' ; import { createAssets, lazyRoute } from '@vinxi/react' ; import { Suspense } from 'react' ; import fileRoutes from 'vinxi/routes' ; import { Route, Routes } from 'react-router-dom' ; const clientManifest = getManifest( 'client' ); const ssrManifest = getManifest( 'ssr' ); const routes = fileRoutes. map (( route ) => ( { ...route, component : lazyRoute(route.$component, clientManifest, ssrManifest), } )); const Assets = createAssets(clientManifest.handler, clientManifest); export default function App () { return ( < html > < head > < Suspense > < Assets /> </ Suspense > </ head > < body > < div id = "root" > < Suspense > < Routes > { routes. map (( route ) => ( < Route key = { route.path } path = { route.path } element = { < route . component /> } /> )) } </ Routes > </ Suspense > </ div > </ body > </ html > ); } entry-server.tsx を編集 App コンポーネントを StaticRouter でラップし、 event.path を location に渡します。 <StaticRouter location = { event . path } > < App /> </StaticRouter> entry-client.tsx を編集 App コンポーネントを BrowserRouter でラップします。 hydrateRoot( document , < BrowserRouter > < App /> </ BrowserRouter > ); index.tsx にリンクを追加 export default function Index () { return ( < div > < h1 > Index </ h1 > < Counter /> < Link to = "/about" > About </ Link > < Link to = "/docs/guide" > Docs </ Link > </ div > ); } 開発サーバーを起動します。 npm run dev リンクから各ページに遷移できることを確認します 🎉 おわりに 今回は vinxi を使って SSR とファイルシステムルーティングを備えたメタフレームワークを作成しました。 他にもミドルウェアや Server functions など、vinxi には様々な機能があります。 自分だけのメタフレームワークを作ってみるのも楽しそうですね。
アバター
この記事は RevComm Advent Calendar 2024 の 9 日目の記事です はじめに RevComm では Github を使用して開発を行っており、コードレビューの依頼も PR で行われます。Github の PR の画面では変更差分の表示ができるのでレビューにも使えるのですが、個人的にはエディタを使用してレビューするのがお勧めです。 この記事では、エディタを使用してレビューすることのメリットをご紹介します。 エディタでのレビューとは この記事におけるエディタでのレビューとは、エディタで変更差分を表示することを意味します。VS Code など最近のエディタでは拡張機能をインストールすることで、エディタ上で変更差分を表示することが可能です。 ちなみに私は Neovim を使っており、octo.nvim という拡張 (を 自分で改造したもの ) でレビューを行っています。Neovim 上でレビューコメントも追加・編集ができてとても便利です。 エディタでレビューすることのメリット Github 上でのレビューに対して、エディタでレビューすることのメリットをいくつか挙げます。 普段使い慣れているエディタを使用することで様々な操作をスムーズに行える (Vim を使っている人は特に) ブラウザ内検索に比べて、よりリッチな検索を行える 静的解析がおかしな点 (不要なライブラリのインポートや、スペルミスなど) を見つけてくれる これらのメリットのほかに、レビュー観点から見たメリットもあります。 以降ではレビュー観点から見たエディタを使用するメリットについて説明します。 レビューの観点から見たエディタを使用するメリット レビュー観点は様々あると思うのですが、私はレビューを行う際、コードを縦と横に見ることを意識しています。 縦に見る 縦に見るとは、変更箇所の前後を見ることです。 個人的な感想ですが、レビューで変更箇所単体に大きな問題が見つかることはほとんどないです。問題がある場合の大半は、変更箇所の前後の処理との組み合わせにあります。 説明のために以下の例を見てみます。 x: str = "Nanika no atai" # 既に定義されている変数 use_x1(x) # X を使用している箇所 + #### ここから変更箇所 ################## + # x を特定のフォーマットの文字列に変換 + x = format_to_xxx(x) + #### ここまで変更箇所 ################## use_x2(x) # X を使用している箇所 この例では、変数 x の文字列を特定のフォーマットに変換する変更を入れており、フォーマット単体ではおかしな点はありません。 しかし、この変更箇所の前後では変数 x を使用しています ( use_x1 と use_x2 の関数)。もし、 use_x2 がフォーマット前の値を期待していたらどうなるでしょうか?逆に use_x1 もフォーマット後の値を期待していた場合はどうでしょうか?このような場合、変更したコードをそれぞれの関数の呼び出し前後に移動する必要があるでしょう。 この例の場合、縦に見るとは x を変更した箇所の前後で x を使用している部分に注目するということです。 このように縦にコードを見るには、Github の PR 画面では少し大変です。Github の画面では変更箇所の前後の行はデフォルトで非表示になっているからです。これらを表示するには画面を何度もクリックする必要があります。エディタを使う場合、前後の処理を確認するのに特別な手順を必要ありません。この観点からすると、縦に見るにはエディタを使うと非常に効率的です。 横に見る 縦の次は横ですが、これは簡単に言うと変更したファイル以外のファイルも見ることです。 具体的な例をいくつか挙げます。 変更した関数等を参照している全ての箇所で、変更内容がどのような影響を与えているか確認する 変更対象と類似したコードが存在していないか確認し、存在している場合はそちらにも同様の変更が必要か確認する エディタを使用してこれらの観点でレビューを行う場合、LSP を使用して参照箇所にコードジャンプしたり、grep 相当の検索でリポジトリ内を検索すると効率的に行えます。 Github でも参照している箇所を一覧化してくれたりはしますが、エディタを使用した方が少ない手間でコードジャンプが可能です。 まとめ コードレビューにエディタを使用するメリットをレビューの観点から紹介しました。 ブラウザ上でも Github Codespaces を使うとブラウザ上で VS Code を開き、レビューをすることもできます。しかし、普段開発で使用している設定・拡張を用意する手間を考えると、手元のエディタでレビューをすることが個人的にはおすすめです。 エディタを利用することでより効率的にレビューすることが可能になると思いますので、ぜひ一度試してみてください。
アバター
はじめに Phone Divという部署でBackendを担当している中島です。 RevCommではMiiTelをはじめ、複数のマイクロサービスの外形監視をチームごとに行う必要があります。 今回は DataDog Synthetic Testing を利用したE2Eテスト・外形監視の実装、その運用について、知っていることをまとめました。テストの作り方から、Autifyとの違いなどにも触れてみたいと思います。 想定読者 DataDogによるE2Eテスト・外形監視に興味のある方 フロントエンド・バックエンド開発者 システムの保守担当者 Quality Assurance(QA)チームの方 DataDog Synthetic Testingを使ってみる 表題のE2Eテストや外形監視の作成は、DataDogの中の「 Synthetic Monitoring & Testing 」というサービスで利用可能です。 API Test を作る 「 API Test 」または「 Multistep API Test 」で作成できます。 API Testの方は一つのAPIに対するリクエストをテストする機能で、Multistep API Testの方は複数のAPIを連続的にテストする機能です。 それぞれ、「HTTP」だけでなく「gRPC」「SSL」「DNS」「WebSocket」「TCP」「UDP」「ICMP」などのプロトコルにも対応しているため、非常に幅広い用途で利用できることが分かります。 Multistep API Testは、例えば、最初のステップでHTTPリクエストを使って認証を通し、BearerなりJWTなりを取得して次のステップ以降でそれを用いてリクエストするなど、複数のAPIを利用するテストシナリオを検証するケースに対応しています。 「 Locations 」というタブからサーバの実行環境は世界中のクラウドのうち、どこから実行できるか選択できます。世界的にサービスを展開している場合に、海外からだと挙動が変わる機能があっても、しっかりテストできることが分かります。 ただ、複数選択した場合、それらのテストは並列で実行され、それぞれのテスト実行がコストに繋がりますので、様々な地理的拠点から見え方が同じであるなら、どれか一つお好きな実行環境を選ぶことになると思います。 「 Assertions 」というタブから豊富なAssertion機能を設定できます。status codeの検証はもちろん、response body中の要素をjson pathを指定して取得・確認したり、最近実装されたJavaScript Assertion機能ではChaiを用いたAssertionを埋め込むこともできたりするようです。 Browser Test を作る ブラウザのE2Eテストを作成する場合、「 Browser Test 」を選択します。E2Eテスト作成機能は「 Edit Test 」「 Edit Recording 」「 View Results 」の3つのタブから構成されます。 Edit Test 「 Edit Test 」ではE2Eテストの実行環境や実行頻度などを設定できます。 「 Starting URL 」にテストシナリオ開始時に利用するURLを指定します。この「Starting URL」を設定できる点が非常に便利で、一度テストを作ってしまえば、環境(開発環境、ステージング環境)ごとにこのURLを差し替えるだけでその環境でも同様のテストも動くようになります。これによって環境ごとにテストを用意する手間が省け、効率的にテストを作ることができます。 「 Browser & Devices 」でブラウザについてはChrome、Edge、Firefoxの3つから、デバイスについてはLaptop、Tablet、Mobileから実行環境を選べます。 「 Scheduling & Alert Conditions 」では実行間隔を設定でき、最短 5分・最長 1週間で選択することになります。 Edit Recording 「 Edit Recording 」では実際にテストする画面を触りながらテストを組み立てていきます。CSVやPDFなどファイルをフォームに設定するテストなども作成できます。 ただ、ブラウザのマイクやカメラに許可を与えるようにテストは正常に動作しなかったので、この点が少し残念な点でした。(2024年12月現在) ブラウザテストのAssertionsではHTML要素を指定した結構細かいAssertや、特定の文字列がページに含まれるか、ファイルがダウンロードされるか、なども利用できます。 例えば、以下のようなAssertができることは確認しています。 指定したHTML要素が存在する 指定したHTML要素の状態(〜が含まれる、〜から始まる、など細かく指定可能)が正しい 画面上に「XXX」という文字列が含まれる 指定したラジオボタン、チェックボックスがchecked / uncheckedになっている View Results 「 View Results 」では作成したテストの結果を見ることができます。 過去の実行結果やFailした場合はどこのAssertionがコケたのかを確認できる 各ステップごとの実行時間(Duration)、CLS(Cumulative Layout Shift)やLCP(Largest Contentful Paint)も確認することができる 結果を見る システムの状態を一目で確認できるようにチームに関係するE2Eテストをダッシュボードにして、運用することにしました。 Widgetを自分で作成することで好きな統計が載せられる 私のチームではCLS、LCPを自作して追加しました。 ブラウザ・環境・タグごとのテスト成功 / 失敗回数を一目で確認できる 実行時間、CLS、LCPに時系列で変化があるかどうか確認できる 通知をする 基本的にDataDogのMonitorと同じ機能でチャットサービスへと通知を飛ばすことができます。 今回はE2Eテストが失敗したらチームのSlack Channelに通知に飛ばすようにしました。 料金について 料金はAPIテストとブラウザテストによって異なります。以下のページを参考にしながら見積もりできるはずです。 www.datadoghq.com 注意点として、Synthetic Testingは25ステップ以内のシナリオを1回と見做し、25ステップを超えると2回テスト実行したと見做されるようです。 これを踏まえた上で、25ステップ以内テストシナリオ数を n 、1日何回実行するかを t とし、1ヶ月30日とすると以下の計算式でコストを見積もると以下のようになります。 monthly_cost =(n * t / 1000)* c * 30日 n: テストシナリオ数 t: 1日何回実行するか c: 1000回あたりの価格。オンデマンド使用料が目安 例えば、1日1回 5シナリオ実行した場合はひと月あたりカフェのコーヒーくらいの料金になるでしょう。 Datadog Synthetic Testingの強み・弱み E2Eテストといえば Autify が思い当たる方が多いと思いますが、2024年12月現在の調査結果に基づいてDataDog Synthetic Testingとの違いを紹介してみようと思います。 DataDogでは以下のことができます。 APIテストができる あくまで私が触ってみた感触にはなりますが、DataDog Synthetic TestingはHTMLの要素やAPIまで検証したい開発者向き、Autifyは外部品質を担保したい組織向きの機能が充実しているものと思っています。DataDogのAssert機能の豊富さもそれゆえなのかもしれません。 毎回のテスト実行で LCP、CLS など Core Web Vital が算出できる 実行環境を細かく指定できる 本記事の「Locations」画面を参照 Autifyでは以下のことができます。 E2Eテスト時のブラウザにデバイス使用許可を与えることができる ブラウザにデバイス使用許可(マイク、カメラなど)を与えられるかどうか大きな違いでした。MiiTelのIP電話はマイクの使用が不可欠なサービスなので、DataDogで架電のE2Eテストが作れなかったのが残念でした。ただ、Autifyでも音声を入れたり動画を映したりすることはできないのでその点は注意してください。 作成したテストシナリオを編集する際に、途中の画面から編集できる DataDogでブラウザテストのシナリオを修正したい場合、操作を一からやり直すことになります。一方Autifyでは、シナリオの途中から編集することができます。 テストシナリオを複数組み合わせで実行できる DataDogでは作ったテストシナリオのCloneはできるんですが、作ったシナリオを柔軟に組み合わせて使うことができないようです。 この辺りはDataDog、Autifyの強み・弱みがあるので、やりたいことに応じてツールを使い分けるのが良いと思いました。 感想 私個人としてはE2Eテスト実行のついでにCore Web Vitalが算出できるのが優れていると感じました。Webサービスはユーザやデータが増加するにつれて動作が遅くなることがあり、気がついた頃には原因が根深くなってしまいがちです。 DataDog Synthetic Testingでは作成したE2Eテストのダッシュボードを通して時系列でCore Web Vitalが確認できるので、外形監視には持ってこいのサービスだと思いました。 導入を検討される場合、本記事が参考になれば幸いです。
アバター
はじめに 最近、Reactに useEffectEvent という実験的APIが存在することを知りました。 弊社で提供しているMiiTel Phoneにおいては、WebSocketやWebRTCなどによってさまざまなタイミングや箇所で非同期的にイベントが発生します。 その関係もあって useEffect を広く活用しているのですが、そういった処理をこの useEffectEvent を使うことによって単純化できるのではないかと思い、調べてみることにしました。 注意 useEffectEvent は現状では実験的APIです。安定版のReactでは利用することができません。もし試してみたい場合は、下記パッケージの experimental バージョンが必要です: react@experimental react-dom@experimental eslint-plugin-react-hooks@experimental 活用例 まず useEffectEvent の活用例として、React Routerが Location の変更を検知するたびに特定のコールバック関数を実行するようなケースを元に考えてみます。 useEffectEvent を使わない場合 useEffectEvent を使わない場合、意図通りに動作させるためには、以下のように useRef や useInsertionEffect などを使って対応する必要がありそうです: import { useEffect, useInsertionEffect, useRef } from 'react' ; import { useLocation } from 'react-router-dom' ; export function useOnLocationChanged ( callback ) { const refCallback = useRef(); const location = useLocation(); useInsertionEffect(() => { refCallback. current = callback; } , [ callback ] ); useEffect(() => { if (refCallback. current ) { refCallback. current ( location ); } } , [ location ] ); } なぜこのように useRef などを使う必要があるのでしょうか?例えば、 useOnLocationChanged が以下のように実装されていたとします: import { useEffect } from 'react' ; import { useLocation } from 'react-router-dom' ; export function useOnLocationChanged ( callback ) { const location = useLocation(); useEffect(() => { callback( location ); } , [ callback, location ] ); } この実装の問題点は useEffect の dependencies として指定されている callback 関数の参照が変わるたびに、 Location が変更されていないにも関わらず意図せず callback が呼ばれてしまう点です: function SomeComponent ( props ) { useOnLocationChanged(() => { // SomeComponentが再レンダリングされるたびに、意図せずこのcallbackが呼ばれてしまいます // ... } ); // ... } この問題を防ぐためには、現状では useRef を活用するなどの工夫が必要です。 useEffectEvent を使う場合 先ほどの処理は useEffectEvent を使うと簡略化することができます。 import { experimental_useEffectEvent as useEffectEvent } from 'react' ; function useOnLocationChanged ( callback ) { const location = useLocation(); const onLocationChanged = useEffectEvent(callback); useEffect(() => { onLocationChanged( location ); } , [ location ] ); } useEffectEvent は引数として関数を受け取り、関数を返却します ( onLocationChanged ) この useEffectEvent から返却された関数には以下のようなルールがあります: Effect ( useEffect )の外で呼んではいけません (例: レンダリングフェーズなどにおいて呼ぶことはできません) useEffect の dependencies からは省略する必要があります 他のコンポーネントのpropsなどに指定することはできません useEffectEvent を使うことで意図せず何度も callback が実行されてしまうことを防止できます。上記のコードの場合、 callback はReact Routerの Location オブジェクトが変更されたタイミングでのみ呼ばれます。 先ほどの useRef などを使った方法と比較して、 useEffectEvent を使うことによって、より直感的にやりたいことが実現できます。 useEffectEvent とは 先ほど使い方を紹介した useEffectEvent について、公式ドキュメントを参考により詳しく見ていきます。 react.dev github.com React の公式ドキュメントでは以下の3つを reactiveな値 と定義しています: Props State コンポーネント関数直下で定義された変数 そして、Reactには副作用を取り扱う方法として以下の手段があります: Effect - reactiveな値の変更時に実行されるreactiveな処理 イベントハンドラー - ユーザーの操作によって実行される非reactiveな処理 useEffectEvent はこれら2つの中間に相当するもので、Effect内で非reactiveな処理を実行したい場合に使用することが想定されます。 WebSocket を使ってリアルタイムにメッセージのやり取りを行う際のケースを例に見ていきます。 function ChannelView ( { channelID } ) { const logger = useLogger(); const dispatch = useDispatch(); const handleMessage = useCallback(( message ) => { logger. info ( `received: ${ message } ` ); dispatch(appendMessage(channelID, message)); } , [ channelID, dispatch, logger ] ); useEffect(() => { const ws = new WebSocket ( `/channels/ ${ channelID } ` ); ws. addEventListener ( 'message' , ( e ) => handleMessage(e.data)); return () => ws. close (); } , [ handleMessage, channelID ] ); // 省略... } props.channelID の変更時に新しい WebSocket 接続を張る処理は、ユーザーの操作ではなく reactiveな値の変更に基づいて実行する処理であるためEffectで処理しています。 この実装には一つ問題があります。 handleMessage は useEffect の dependencies に指定されているため、この handleMessage 変数が参照する関数が変更されるたびにEffectが再実行され、 WebSocket のコネクションが一から貼り直されてしまいます。これは意図せぬ挙動です。 handleMessage 内の処理は、Effectが依存しているreactiveな値 ( props.channelID )が変更されたタイミングで実行されるものではなく、 WebSocket から message を受信したタイミングで実行される処理です(非reactiveな処理) useEffectEvent によってこういった非reactiveな処理をEffectから抽出することができ、意図せぬタイミングで何度もEffectが実行されてしまう事態を防止できます。 function ChannelView ( { url } ) { const logger = useLogger(); const dispatch = useDispatch(); const handleMessage = useEffectEvent(( message ) => { logger. info ( `received: ${ message } ` ); dispatch(appendMessage(channelID, message)); } ); useEffect(() => { const ws = new WebSocket ( `/channels/ ${ channelID } ` ); ws. addEventListener ( 'message' , ( e ) => handleMessage(e.data)); return () => ws. close (); } , [ url ] ); // ... } 補足ですが、RFCによると useEffectEvent から返却される関数は on または handle から始まる名前の変数に格納されることが想定されているようです (元々、RFCにおいては useEvent という名前で提案されていたようですが、後ほど現在の useEffectEvent という名前へリネームされたようです) github.com github.com 現状ではどうしたらいいか? useEffectEvent は便利ではありますが、現在はまだ実験的APIのため、Reactの安定バージョンにおいては利用することができません。 調べてみたところ、いくつかのOSSにおいて自前でpolyfillを実装しているようです: Superset useEffectEvent が安定化された際の移行を容易にするために、 use-event-callback パッケージをベースに useEffectEvent を実装しているようです ( apache/superset#23871 ) Bluesky useInsertionEffect + useRef + useCallback をベースに useNonReactiveCallback というカスタムフックを実装しているようです ( src/lib/hooks/useNonReactiveCallback.ts ) Radix UIのWebサイト Blueskyとほぼ同様ですが、こちらはレンダリングフェースで実行された際に例外が発生する対応が行われています ( utils/use-effect-event.ts ) これらを参照することで、 useEffectEvent を使うべき場面の参考にもなりそうです。 ただ、紹介をしておいてアレですが、現状ではpolyfillなどは用意せずに、 useRef を使った解決策で対処しておくのが無難ではないかと個人的には思っています。 便利なAPIではあるものの、現状ではまだ useEffectEvent は実験的APIであり、正式に導入されるかどうかはわかりません。今後、引数や戻り値の形式などが変更される可能性も考えられます。 また、もし useEffectEvent が正式に導入された場合、公式でマイグレーションガイドの公開や eslint-plugin-react-compiler や eslint-plugin-react-hooks から移行のためのルールの提供なども考えられるため、それらの提供を待った方がスムーズに移行できる可能性も考えられそうです。現状では、まだ useRef を使った解決策で様子をみておいたほうが安全ではないかと考えています。 おわりに 以上、 useEffectEvent の紹介でした。とても便利なAPIなので、今後のバージョンで導入されることを楽しみにしています!
アバター
はじめに Atlantis とは 背景 ディレクトリ構成 モジュールの運用 AWS IAM role の設定 おすすめの Atlantis の機能 特定の条件がパスされないと atlantis apply を実行できないようにしたい atlantis apply が実行されていない Pull Request がマージされないようにしたい まとめ はじめに platform チームの渡部です。 RevComm の platform チームでは、チームトポロジーのプラットフォームチームのように、ストリームアラインドチームの開発における負担を軽減するために、サービスやプラットフォームの提供を行っています。 今回は、その取り組みの1つである Atlantis を使用した Terraform リポジトリの提供についてご紹介します。組織の拡大とともに Terraform の管理に課題を感じている方や Atlantis の導入を検討している方の判断材料になれば幸いです。 Atlantis とは Atlantis  は Terraform のデプロイを行う Issue Ops ツールの OSS です。 GitHub の Pull Request の issue comment にコマンドを入力して、 terraform plan や terraform apply を実行し、Pull Request 上でリソースのリリースまで行います。 我々のチームでは、Terraform リポジトリへの permission を持つ Github App が、ECS Fargate にホストされた Atlantis アプリケーションと通信して terraform コマンドを実行します。 この記事では、v0.30.0 の Atlantis を使用しています。 背景 我々が管理する Terraform リポジトリは、Atlantis を導入する以前から、複数のストリームアラインドチームが開発を行うモノリシックなリポジトリでした。 それ故に、Terraform Configurations が肥大化したり、リリースが滞留したりするなどの問題を抱えるようになりました。 そのような課題を解決するために、platform チームは Atlantis を導入しました。 これによって、CI/CD ツールのコードが複雑になることなく、以下のメリットを享受できると想定しています。 変更と関係のないリソースが壊れるリスクを背負う必要がなくなる 権限や責任を分離したい粒度や並行に開発を行える粒度で柔軟に HCP Terraform workspace を作成することができる 以降、モノリシックな Terraform リポジトリに Atlantis を導入するにあたり、工夫した点を紹介します。 ディレクトリ構成 Atlantis 導入後も、我々が管理する Terraform リポジトリは、複数のストリームアラインドチームが開発を行うモノリシックなリポジトリです。 そのため、ストリームアラインドチームごとにディレクトリを用意して、各チームがその配下に自由にディレクトリを作成することができるディレクトリ構成にしました。 各チームは、権限や責任を分離したい粒度や並行に開発を行える粒度に Terraform configurations を分割して(以下、コンポーネントと表現)、コンポーネントごとにディレクトリを作成します。 コンポーネントディレクトリ配下には、そのコンポーネントのモジュールや、そのコンポーネントのリソースがリリースされる AWS アカウントごとにディレクトリが分かれて管理されています。 下記はディレクトリ構成のイメージです。 . ├── stream_aligned_team_1 │ ├── component_1 │ │ ├── aws_account_1 │ │ │ ├── main.tf │ │ │ ├── providers.tf │ │ │ [omitted] │ │ │ │ │ ├── aws_account_2 │ │ │ ├── main.tf │ │ │ [omitted] │ │ │ │ │ └── modules │ │ ├── main.tf │ │ ├── variables.tf │ │ [omitted] │ │ │ └── component_2 │ ├── aws_account_1 │ [omitted] │ ├── stream_aligned_team_2 │ │ │ [omitted] │ [omited] このようなディレクトリ構成にすることで、コンポーネントのデプロイ時に関係のないリソースが壊れるリスクを回避することができます。 また、後述のモジュールのバージョニングの際に、このディレクトリ構成をtag の命名規則に反映しています。 モジュールの運用 Terraform module は source に Github リポジトリを指定して使用する ことができます。 我々が管理する Terraform リポジトリでは、この機能を使用して、自リポジトリを参照することで、バージョン管理した module を使用しています。 具体的には、下記のフローでモジュールのバージョニングとその使用を行っています。 ./stream_aligned_team_1/component_1/modules  配下に  moduleA  を作成して main ブランチにマージします。 Github の Releases 機能を使用して  stream_aligned_team_1-component_1-v1.0.0  という tag を作成してリリースします。 ./stream_aligned_team_1/component_1/aws_account_1/main.tf では、以下のように  moduleA  を指定して使用します。 module "this" { source = "git::ssh://git@github.com/{org_name}/{repo_name}//stream_aligned_team_1/component_1/modules/moduleA?ref=stream_aligned_team_1-component_1-v1.0.0" } このとき、tag 名は Terraform リポジトリ内で一意になるように {stream_aligned_team_name}-{component_name}-{semantic_versioning}  の命名規則に沿うように行います。 これにより、チームごとにモジュールのバージョニングが可能になります。 また、開発環境用の AWS アカウントでは、module を Github リポジトリ参照ではなく、相対パスによる参照を行なっています。 これにより、わざわざ main ブランチへのマージと Release を行うことなく、module の動作確認やデバッグを行いながら開発を行うことができます。 AWS IAM role の設定 Atlantis は 1 つの AWS アカウントにのみホストされています。 したがって、各環境へ  terraform apply  するためには、各環境の terraform コマンドを実行するための IAM Role を assume role しなければなりません。構成は下記のようになります。 また、我々が管理する Terraform リポジトリでは、ファイルレイアウトによってステートファイルを分離する方針をとっており、各 AWS アカウントのリソースの tfstate は、その AWS アカウントの remote backend(S3 bucket と DynamoDB table) に配置されています。 各 AWS アカウントの remote backend にアクセスするときにも、下記のように IAM role を assume role して tfstate を更新しています。 これにより、各環境は完全に分離されます。 backend "s3" { region = "ap-northeast-1" bucket = "account1-tfstate" key = "stream_aligned_team_1/component_1/terraform.tfstate" dynamodb_table = "account1-locks" encrypt = true assume_role = { role_arn = "arn:aws:iam::account1:role/account1-tf-exec-role" external_id = "account1-external-id" } } おすすめの Atlantis の機能 Atlantis の機能は多岐に渡ります。 基本的な機能としては、 atlantis plan の実行前に該当のディレクトリをロックする Looking 機能や、Pull Request 作成時に atlantis plan を自動的に実行する Autoplanning 機能などがあります。 最後に、以下のようなことを実現したいときに、Atlantis のどの機能を使えばよいかを紹介して終わりにしたいと思います。 特定の条件がパスされないと atlantis apply を実行できないようにしたい Atlantis には Command Requirements 機能という機能があります。 これは、 atlantis plan や atlantis apply コマンドを実行する前に、特定の条件を満たすことを強制する機能です。 例えば、Atlantis の設定ファイルに下記のように設定すると、Pull Request が「マージ可能( Mergeable )」な状態でなければ atlantis apply を実行できなくなります。 repos : - id : /.*/ apply_requirements : [ mergeable ] 「マージ可能」な状態は、Atlantis を使用する VCS によって異なります。 Github の場合は、 branch protection rule をパスした状態を指します。 あとは、Github の Settings で有効にしたい条件をチェックして、マージ可能な状態でなければ、下記のように atlantis apply は失敗します。 atlantis apply が実行されていない Pull Request がマージされないようにしたい Atlantis を使用していると、 atlantis apply を実行する前に、誤って Pull Request をマージしてしまうことがあります。 こうなると、いちいち Pull Request を作り直すことになり、面倒です。 なので、下記のように Github の status checks に atlantis/apply を追加したくなります。 追加して、いざ atlantis apply を実行してみると、上記の Mergeable で試したときと同じように失敗します。 これは、 Mergeable を有効にすると、Atlantis が Github の status checks もパスしたかを確認してしまうためです。 つまり、 atlantis apply を実行するために atlantis/apply をパスしなければならないという八方塞がりの状態になってしまいます。 そんなときのために、Altantis には atlantis apply を実行する前に、 atlantis/apply の status check をスキップして、マージ可能かどうかをチェックすることができるオプションが用意されています。 それが --gh-allow-mergeable-bypass-apply です(環境変数でも設定可能です)。 atlantis server --gh-allow-mergeable-bypass-apply これで、Mergeable を設定した状態で、 atlantis apply が実行されていない Pull Request がマージされないようにすることができます。 まとめ Terraform のデプロイを行うツールである Atlantis  と、Atlantis を導入した Terraform リポジトリの運用について紹介しました。 Atlantis には、今回紹介した機能以外にもたくさんの機能があります。 ngrok を使用すればローカル環境でも気軽に試してみることができますので、興味のある方はぜひ動かしてみてください。
アバター
背景 弊社ではさまざまなログを DataDog に集約しているのですが、一部サービスで EKS on Fargate を利用しており、datadog-agent + fluent-bit のサイドカー構成で DataDog にログを送っています。 その中でも Job を使用した場合にうまく DataDog にログが送れず、困っていました。 Job だとサイドカーが動いてくれない + Job終了時にサイドカーが終了してくれない という状況で、Jobに関してはDataDogを諦めて kubectl や ArgoCD 経由でログを一生懸命読む、という運用が続いていました。 結論 k8s 1.29 からデフォルトで有効となったサイドカーコンテナの機能を使用することで問題は解決しました。正解は公式ドキュメントにバッチリ書いてあり、今まで我々は何をそんなに困っていたんだろう?ということをつらつらと言い訳していこうと思います。 kubernetes.io 詳細 サイドカーが機能しない件 DataDog にログが出ず...これは、起動順序に問題があるようでした。メインのコンテナよりもサイドカーを先に起動させる必要があるところまでわかり、manifest の containers で記載順序を変えて対応しようとしました。実際このとき DataDog にログは出たのですが、サイドカーが終了しない問題の解決が難しくなりました。 サイドカーが停止しない件 Job の場合、仕事を完了したメインコンテナは仕事中のサイドカーを残して一人で終了してしまいます。これだと、終わった Job がいつまでも生き残ってしまいます。 そこでメインが終了する際にサイドカーを終了したり、逆にサイドカーがメインコンテナを監視してメイン終了を検知したら自分も終了する command を書いたりの工夫をしてみたのですが、サイドカーが機能しない件の対応で containers の記載順序を変更したところうまくいかなくなりました。 サイドカーコンテナ機能の使用 行き詰ったので心を落ち着けて公式ドキュメントをじっくり読むことにしたところ、わりとすぐに答えが見つかりました。結論にも記載した通り、k8s 1.29 からはデフォルトでサイドカーコンテナの機能が使用できるようになっていて、containers ではなく initContainers にサイドカーを指定するだけでOKでした。 サイドカーコンテナは initContainrs として指定するため、起動順序はメインコンテナよりも先となります。また、サイドカーの restartPolicy は Always を指定します。こうするとメインコンテナが終了した場合のみサイドカーが停止するようになります。 なぜ自前で四苦八苦していたのか 事前に軽く調べた際に「Job には自動でサイドカーを終わらせる方法がなく、自前で用意する必要がある」というブログを参照しており、ちょっと古い記事ではあったのですが、他に情報もなく、鵜呑みにしていたんですね。 公式ドキュメントを読み込んでおくのは基本のようにも思えますが、現場でスピード感を持って判断していく上で、ライトに読めてしまうブログやAIの回答を参考にして済ませてしまうことは実際よくあると思います。 そんな忙しい我々のためにも、軽く調べたら出てくるように情報発信をしていくことが大切だなと感じました。さらに言えば、不思議な力でこの記事が数ヶ月前の我々に届いてくれたらとても嬉しい。そういったサービスには対応していませんか?サンタさん、何卒宜しくお願い致します。 以上
アバター
はじめに MiiTel Analytics Platformチームの小門です。 RevCommではサービス基盤にAWSとして利用していますが、IaCには主にTerraformを用いています。 基本的にTerraformコードはGitHubで管理され、プルリクエストを介してCI/CDを自動実行してリソースの構築、構成変更を行います。 最近、Terraformコードを管理するリポジトリを新設したり既存のコードをリファクタリングする機会があったためナレッジを共有します。 この記事では、Terraform v1.5以降に導入された機能である import / removed / moved ブロックを活用する方法を紹介します。 動機 IaCの活用度合いはサービスや会社、チームの状況に大きく左右されると思います。 必ずしもプロジェクトの初期からIaCが整備されるとは限らないし、サービスや組織の変化に応じてコード管理の都合も変わることでしょう(弊社が正にそうです)。 Terraformは機能が豊富なため上記のような事情に柔軟にアプローチできます。 例えば既存のリソースをIaC管理に取り込む場合は terraform import 、逆に特定のリソースをIaC管理から除外する場合は terraform state rm などのコマンドがあります。 しかしIaCという特性上、手動コマンド実行によるtfstateを操作するのはチーム開発において不都合があります。 例えばチームの誰かによって同じタイミングでCI/CDが起動されると、お互いの変更が競合したりどちらかの変更が後勝ちするような可能性が考えられます。 このような場合でもTerraformの機能を有効活用することでチーム開発に支障をきたさずリファクタリングすることができます。 サンプルコード AWSリソースを管理するための最小のサンプルとして、ECSクラスターを1つ管理するケースを考えてみます。 初めのディレクトリ構成: . └── provider.tf provider.tf terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } } provider "aws" { region = "us-east-1" } 検証コードのバージョン Terraform v.1.9.8 AWS provider: v5.78.0 import ブロック import ブロックはTerraform v1.5以降で利用可能です。 terraform cliの terraform import [ADDR] [ID] と等価です。 import { to = [ ADDR ] id = "[ID]" } IaC管理されていないAWSリソースであるECSクラスター revcomm-2024-adventcalendar を新たにIaC管理に含めます。 ※terraform cliだと terraform import aws_ecs_cluster.foo revcomm-2024-adventcalendar # main.tf resource "aws_ecs_cluster" "foo" { name = "revcomm-2024-adventcalendar" } # import_ecs_cluster.tf import { to = aws_ecs_cluster.foo id = "revcomm-2024-adventcalendar" } . ├── import_ecs_cluster.tf ├── main.tf └── provider.tf ※好みですが、 import ブロックはいずれ削除可能なため main.tf ではなく別ファイルにすることで後で丸ごと消せるようにしています。 この状態で terraform plan を実行すると以下のようになります。 $ terraform plan ... Plan: 1 to import, 0 to add, 0 to change, 0 to destroy. 1 to import かつ 0 to add, 0 to change のため新たにリソースは作成されず、また変更もされないことが分かります。 import ブロックはコマンドの手動実行ではなく コード変更によって IaCの管理対象を操作することができます。 ※以降のコードは terraform apply を実行 && import_ecs_cluster.tf を削除した状態とします。 removed ブロック removed ブロックはTerraform v1.7以降で利用可能です。 その名の通りリソースをIaC管理から外す(リソース実体は削除しない)ためのもので、 import ブロックとは逆の用途です。 terraform cliの terraform state rm と等価です。 terraform state rm [ADDR] removed { from = [ ADDR ] lifecycle { destroy = false } } lifecycleブロックで destroy = false としてリソースを削除しないようにできます。 # main.tf removed { from = aws_ecs_cluster.foo lifecycle { destroy = false } } # resource "aws_ecs_cluster" "foo" { # name = "revcomm-2024-adventcalendar" # } また(IaC管理から)削除するリソースのTerraformコードも合わせて削除する必要があります。 removed ブロックも後から削除可能なため、私のチームでは初めコメントアウトに留め、その後 removed ブロック自体の削除時にresourceブロックも削除する形で運用しています。 planの実行結果は以下のようになります。 $ terraform plan # aws_ecs_cluster.foo will no longer be managed by Terraform, but will not be destroyed # (destroy = false is set in the configuration) ... Plan: 0 to add, 0 to change, 0 to destroy. 0 to add, 0 to change, 0 to destroy のため、実際のリソースに変更は起きません。 movedブロック removed ブロックはTerraform v1.7以降で利用可能です。 Terraformコード上の管理名(ADDR)をリネームする場合に使用します。 terraform cliの terraform state mv と等価です。 terraform state rm [SOURCE] [DESTINATION] moved { from = [ SOURCE ] to = [ DESTINATION ] } 上記までの例で、ECSクラスター revcomm-2024-adventcalendar のTerraformコード上の管理名を(敢えて) foo としていました。 しかし、この命名では役割が分かりづらいためいずれ問題が起きることでしょう。 これを foo から api にリネームするリファクタリングを 安全に 行うことができます。 + moved { + from = aws_ecs_cluster.foo + to = aws_ecs_cluster.api + } - resource "aws_ecs_cluster" "foo" { + resource "aws_ecs_cluster" "api" { name = "revcomm-2024-adventcalendar" } planの実行結果は以下のようになります。 $ terraform plan # aws_ecs_cluster.foo has moved to aws_ecs_cluster.api ... Plan: 0 to add, 0 to change, 0 to destroy. aws_ecs_cluster.foo を aws_ecs_cluster.api に変更しつつ、実際のリソースに影響がないため安全にリファクタリングを実行できます。 まとめ Terraformのリファクタリングを チーム開発の中でも安全に 実施する方法と簡単なケーススタディを紹介しました。 特に import ブロックと removed ブロックを併用することでリポジトリを跨いだリファクタリング、IaCコードの分割などが行えるのがとても気に入っています。
アバター
By Kenji Yamauchi (Analytics Team) In this blog post, we introduce our new Blue/Green-based upgrade strategy for our Amazon EKS powered RevComm analytics platform along with an automation to streamline the process. This is the English version of a similar blog. Please check this post for the Japanese version. RevComm’s Analytics Platform Before we get into the details of the upgrade, a brief introduction to RevComm's analytics infrastructure is in order: although RevComm offers several products, such as MiiTel and MiiTel Meetings, they all share a single infrastructure for performing analytics, such as transcription. This analysis infrastructure does not run from a single application but consists of multiple applications divided into modules such as speech recognition and other analytics functions, which are currently hosted within a single EKS cluster. AWS resources including the EKS cluster and middleware such as the AWS Load Balancer Controller are managed using Terraform. In addition, Kubernetes manifests for applications running in the cluster are managed in a separate repository, and deployed by referencing the main branch with Argo CD running in the cluster (Pull-type GitOps). Upgrade strategy for EKS cluster Since our adoption of EKS, the RevComm analysis infrastructure has been using the in-place method, i.e. upgrading the version of the existing cluster directly. As mentioned above, RevComm's analysis infrastructure uses IaC, so it can be done easily with only a few configuration changes, but there are some drawbacks. More concretely, the in-place strategy conducts rolling updates for the resources including the node groups as described in the EKS Best Practices Guides . As a result, we faced potential downtime during cluster upgrades and couldn't rollback if issues arose. Moreover, the upgrade process took several hours to complete. On the other hand, when going for a Blue/Green strategy upgrade, we prepare a new version cluster alongside the current cluster and let the traffic into the new cluster gradually. As we discard the older cluster only after confirming the behavior of the new cluster, we avoid the aforementioned problems. However, there’s overhead since we have to prepare a new cluster. Nevertheless, we opted for the Blue/Green strategy to improve the availability of the system. Implementation How to switch cluster We implemented the Blue/Green strategy following the article Blue Green Migration of Amazon EKS Blueprints for Terraform because our architecture was similar. Assuming that the version of a current (blue) cluster is 1.25 and one of a new cluster (green) is 1.28, the flow of switching clusters is as follows: Create a new cluster and install middleware through Terraform Deploy the applications on the new cluster with Argo CD Change the manifests Use external-dns to distribute traffic via Ingress annotations with Route 53’s weighted routing. Specify values of set-identifier and aws-weight Store new changes as a feature branch of the manifests repository and refer to them from the Argo CD of the new cluster. The Argo CD of the current cluster still refers to the main branch. Delete the current cluster after confirming the behavior of the new cluster Merge the feature branch into the main branch and make the Argo CD of the new cluster to refer to the main branch. Automation Although we can complete those steps as mentioned before by adjusting the variables of the Terraform scripts and modifying the parts of the manifests, the steps are relatively complicated compared to the in-place strategy. Therefore, we automated them with GitHub Actions to prevent dependence on individual members and operational errors. We automated the two processes: the creation of a new cluster and the deletion of the current cluster. Please look at the diagram below. The Terraform code for the analysis infrastructure has directories for managing backends and variables for each environment (development, staging, and production) under the vars directory, hereafter simply referred to as environment settings. Each environment setting is managed with an identifier for each environment, such as cluster-YYYYYMM . In addition, the environment settings for the current cluster are pointed to by symbolic links. When we create a new cluster with this structure, we need to generate new environment settings, create a new cluster by terraform apply , and recreate the symbolic link. On the other hand, when we remove the current cluster, we need to remove the middleware and the applications hosted in the current cluster, the current cluster and the environment settings for the current cluster. To prevent operational errors that could occur during manual processes, we automated these steps using GitHub Actions. The Actions take identifiers and cluster versions as inputs and perform the entire process—creating, switching, and deleting clusters, as well as deploying applications to the new cluster—in a matter of minutes. Conclusion In this article, we introduced our migration from an in-place EKS upgrading strategy to Blue/Green. Compared to the in-place method, we needed to automate and establish procedures to avoid increasing man-hours. Still, as we had the groundwork of IaC, we were able to introduce the Blue/Green method with maximum benefit. The analysis infrastructure had few stateful elements, and there were few things to consider on the application side, which also made it compatible. Thank you for reading and have a happy upgrading time!
アバター
2024年10月25日(金)~27日(日)にインドネシアで開催された PyCon APAC 2024 にバックエンドエンジニアの 松土 慎太郎、陶山 嶺、小門 照太の3名が登壇しました。 tech.revcomm.co.jp 今回はイベントの振り返りとして登壇資料と登壇者の感想を紹介します。 登壇振り返り Empowering your real life with Raspberry Pi 概要: 音声認識及び音声合成を活用して、その日のスケジュールを教えてくれる音声ボットをRaspberry Piによって作ります。初学者の方を対象に、ハンズオン形式でRaspberry Piのパワーを日常生活に取り入れる案内をいたします。 登壇者: 松土 慎太郎 発表資料: docs.google.com 登壇者の感想 PyCon APAC 2024で「Empowering Your Real Life with Raspberry Pi」というテーマで発表しました。英語は得意ではありませんが、ハードウェア開発に興味を持ってもらえるよう、構成の工夫や発表の練習にいつも以上に力を入れました。その結果、参加者から多くの質問や反応をいただき、関心を引くことができたと感じています。 また、弊社RevCommのジャカルタオフィスを訪問し、現地のスタッフと交流する機会も作ることができました。直接顔を合わせて話すことで、日頃の業務のつながりをより深めることができたのは大きな収穫です。 このような貴重な経験を与えてくれた会社やチームに感謝しています。今回の学びや気づきを、今後の業務に還元していきたいと思います。 The power of Python's type hints: Case studies focusing on famous libraries 概要: 近年のPythonは型ヒントの強化が活発で、メジャーアップデートのたびに便利な機能が追加されています。 さらに型ヒントを活用するライブラリやツールも多く登場し、コミュニティからの絶大な人気を集めています。 (中略) Pythonの型ヒントはまだまだ多くの可能性を秘めていると思います。 本セッションを通じて、普段の開発で型ヒントをより便利に活用し、新たなアイディアを自身の手で具体化していきましょう。 登壇者: 陶山 嶺 発表資料: docs.google.com 登壇者の感想 PyCon APAC 2024では「The power of Python's type hints: Case studies with a focus on well-known libraries」というタイトルで、FastAPI、Pydantic、SQLAlchemyといったRevCommの業務でもわたしがよく利用しているライブラリの内部の実装を紹介するトークを行いました。 今回はわたしにとって初めての海外イベント参加、初めての英語で行うトークだったので、当然ながら準備は大変でした。しかし、初めてだからこそしっかりと準備を行なっていたので、当日が近づくにつれて徐々に「大丈夫」という気持ちに変化していました。いまは無事にトークも終わり、達成感を感じています。 国内のイベントではこれまで何度かトークをしていましたが、今回のPyCon APAC 2024では初めてプロポーザルを出したときのような新鮮な気持ち、チャレンジ精神で向き合うことでき、得られたものもその分大きかったです。また、今回はジャカルタオフィスのメンバーたちとも交流でき、現地で直接コミュニケーションができたからこそ学べたことも多かったです。機会があればまた挑戦したいと思います。 Developing Python Libraries Using Rust 概要: Python以外の言語で実装された機能(モジュール、クラス、関数)をPythonのライブラリとして使用することが可能です。 有名なものでは Numpy / Pandas は高速化のために主にC言語をベースに実装されています。 最近ではC/C++以外にもRust言語の活用が注目されています。 本セッションでは、Rust を利用してPythonライブラリを開発する利点や手順などを解説します。 また実際にRustが使用されているライブラリの実例を紹介します。 登壇者: 小門 照太 発表資料: docs.google.com 登壇者の感想 私のトークタイトルは「Developing Python Libraries Using Rust」でした。 ※2024年9月PyCon JPのトーク「Rustを活用したPythonライブラリの開発」を英語に翻訳したものです。 今回このタイトルを選んだ理由は私自身がRust言語を学びたかったためであり「登壇ドリブン学習」でした。 PythonのカンファレンスでRustを中心にしたトークは少し異質だったかもしれませんが、Python開発者が持ち帰ることのできる有益な内容を心掛けました。 結果として、会場では多くの方が聞きに来てくれたり発表後に質問や感想を言いに来てくれて充実した登壇となったと思います。 英語での登壇は初めてでしたが、今回は同僚2名(+ つい最近退職した元同僚が1名)と同行できたことはとても心強かったです。 プロポーザルを提出するきっかけを含め、今回のような活動ができる仲間がいるのはとても幸せなことだと感じています。 おわりに 以上、PyCon APAC 2024 への登壇に関するまとめになります。技術評論社様のWebページにおいても弊社の小門 照太が寄稿したPyCon APAC 2024への参加レポートが公開されているため、よろしければこちらもご参照いただければと思います! gihyo.jp RevCommでは今回の PyCon APAC 2024 の開催地であるインドネシアにおいてもサービス展開しており、ジャカルタにオフィスがあります。今回の PyCon APAC 2024 への登壇に伴い、現地のジャカルタオフィスに所属するスタッフと交流する機会を作ることができるという点も大きなモチベーションとなり、今回、3名のメンバーが登壇する運びとなりました。 後日、弊社のnoteにおいても PyCon APAC 2024 の登壇に関するインタビュー記事を掲載予定です。もしご興味がありましたらぜひそちらもご覧いただければと思います! note.com
アバター
MiiTel Platform チームの小門です。 2024年10月25日(金)~27日(日)に開催される PyCon APAC 2024 に RevComm のエンジニア陶山 嶺と松土 慎太郎、わたし小門 照太の3名が登壇します イベント概要 https://2024-apac.pycon.id/ 日程: 2024年10月25日(金)~27日(日) 会場 Universitas Nahdlatul Ulama Yogyakarta チケット ※オンラインチケットもあります 登壇情報 発表一覧とスケジュールは以下に公開されています https://pretalx.com/pycon-apac-2024/talk/ Empowering your real life with Raspberry Pi Harness the Raspberry Pi to create a voice-activated bot that tells you your schedule for the day, using both speech recognition and synthesis. Designed for beginners, this session will guide you through a hands-on project that brings the power of Raspberry Pi to your everyday life. 日時: 2024年10月26日 11:30-12:00 (Asia/Jakarta) 登壇者: Shintaro Matsudo リンク: https://pretalx.com/pycon-apac-2024/talk/HE8QDG/ The power of Python's type hints: Case studies focusing on famous libraries This session will show how these libraries implement the ideas. I believe that type hints in Python still has a lot of potential. Through this session, you will be able to use type hints more conveniently and flesh out new ideas by yourself. 日時: 2024年10月26日 13:00-13:30 (Asia/Jakarta) 登壇者: Rei Suyama リンク: https://pretalx.com/pycon-apac-2024/talk/RH7GPM/ Developing Python Libraries Using Rust In this talk, I will explain the advantages and procedures for developing Python libraries using Rust. I will also introduce examples of libraries where Rust is being used. 日時: 2024年10月26日 15:45-16:15 (Asia/Jakarta) 登壇者: Shota Kokado リンク: https://pretalx.com/pycon-apac-2024/talk/V8V7EW/ おわりに 私と陶山の2名は先月開催されたPyCon JP 2024に引き続きの登壇( リンク )、 また3名とも昨年日本で開催されたPyCon APAC 2023に引き続きPyCon APACでの登壇となります。 もし現地(ジョグジャカルタ)で参加される方がいましたらお話できると嬉しいです。 英語での登壇が初めてのためチャレンジとなりますが、楽しんで発表してこようと思います!
アバター
はじめに Rules of React という安全で効果的なReactアプリケーションを記述するためのルールがReact公式から公開されました。 Writing idiomatic React code can help you write well organized, safe, and composable applications. These properties make your app more resilient to changes and makes it easier to work with other developers, libraries, and tools. These rules are known as the  Rules of React . https://github.com/reactjs/react.dev/blob/40d73490733a1665596eee8b547278231db3b8e3/src/content/reference/rules/index.md より引用 Rules of Reactに反することにより、バグの原因となってしまったり、理解することが難しいコードになる恐れがあると言及されています。 They are rules – and not just guidelines – in the sense that if they are broken, your app likely has bugs. Your code also becomes unidiomatic and harder to understand and reason about. https://github.com/reactjs/react.dev/blob/40d73490733a1665596eee8b547278231db3b8e3/src/content/reference/rules/index.md より引用 調べてみて、このRules of Reactは「そもそもReactとは一体どのようなフレームワークなのか?」を理解する上でも有用なのではないかと思いました。 そこで、この記事ではRules of Reactを題材に、あらためてReactとはどのようなフレームワークなのかについて見ていきたいと思います。 想定読者 この記事はどちらかというとReactの初学者〜ある程度慣れてきた方を想定して記述しています。 抽象的な内容が結構多いので、もし具体的な内容にのみ興味があるという場合は、終盤の「 実践編 」の内容だけ参照ください。 注意 今後、リリースされる予定のReact v19では use() などの新しいAPIが導入される予定です。この記事はそれらのAPIについては考慮に入れず執筆していますが、今後、Reactにおける副作用の取り扱い方法にも変化が起きる可能性も考えられます。しかし、Reactの背景にある考えについてはそう大きくは変わらないと思います。 Components and Hooks must be pure (コンポーネントとフックは純粋でなければならない) Rules of Reactに関するドキュメントの一つに Components and Hooks must be pure (コンポーネントとフックは純粋であるべき)というページがあります github.com 直訳すると「コンポーネントとフックは純粋でなければならない」というような感じではないかと思います。では「純粋」とは一体何を指しているのでしょうか? これはRules of Reactを理解する上で重要な考えではないかと思うため、まずは純粋関数というものについて見ていきます。 純粋関数と参照透過性について 以下のような関数があったとします。 const plus = ( a , b ) => a + b ; const double = ( x ) => 2 * x ; const inc = ( x ) => 1 + x ; これらの関数は、 引数のみに依存し、副作用が存在しない という共通した特徴を持ちます。こういった関数を 純粋関数 と呼びます。 まず、純粋関数の特徴である「 引数のみに依存する 」という点についてはわかりやすいのではないかと思います。上記の関数はすべて引数以外の要素には全く依存をしていません。 では、もう一方の「 副作用が存在しない 」とはどういうことでしょうか? 大雑把に説明すると、外部の状態を変更する処理は副作用があると説明できるかと思います。具体的には、以下のような処理は副作用であると思います。 グローバル変数やパッケージローカルな変数の上書き 乱数の生成 localStorage へのデータの保存 fetch() によるAPIの呼び出し Console への出力 純粋関数は引数以外の要素に依存しないため、ある純粋関数に 同じ引数の組み合わせが与えられた場合、常に同じ結果を返す という特徴があります (このような性質を参照透過性と呼びます) 次は逆に純粋ではない関数の例を見てみます。 const formatDate = () => { const date = new Date () ; const y = date . getFullYear () ; const m = String ( date . getMonth () + 1 ) . padStart ( 2 , '0' ) ; const d = String ( date . getDate ()) . padStart ( 2 , '0' ) ; return ` ${ y } / ${ m } / ${ d } ` ; } ; この formatDate 関数はなぜ純粋関数ではないのでしょうか?以下の行がポイントです。 const date = new Date () ; new Date() は呼び出しのたびに結果が変わる処理(=参照透過性がなく、純粋ではない)であり、この new Date() を呼んでいる formatDate も同様に純粋ではなくなります。 それでは、この formatDate を純粋関数にするためにはどうしたらいいでしょうか?この場合は、 date を formatDate 関数内で生成するのではなく、引数として受け取るようにすれば純粋関数にできます。 // フォーマット対象の日付を引数で受け取る const formatDate = ( date ) => { const y = date . getFullYear () ; const m = String ( date . getMonth () + 1 ) . padStart ( 2 , '0' ) ; const d = String ( date . getDate ()) . padStart ( 2 , '0' ) ; return ` ${ y } / ${ m } / ${ d } ` ; } ; この formatDate の例では new Date() を例に説明しましたが、以下のように関数の外の変数を変更するような関数も純粋関数ではありません。 // 関数の外の変数に依存している let queue = [] ; const enqueue = ( x ) => { queue . push ( x ) ; } ; const dequeue = () => queue . shift () ; この場合もキューを引数として受け取るようにすると、副作用を排除することができます。 const enqueue = ( queue , x ) => { return [ ...queue, x ] ; } ; const dequeue = ( queue ) => { const [ head , ... rest ] = queue; return [ head, rest ] ; } ; const [ item , queue ] = dequeue(enqueue(enqueue(enqueue( [] , 1 ), 2 ), 3 )); item; // => 1 queue; // => [2, 3] また、JavaScriptにおいてよく見かける、引数に与えられたオブジェクトを直接変更するような関数も純粋関数ではありません。 const completeTask = ( task ) => { task . status = 'completed' ; } ; このようなケースでは、以下のように新しいオブジェクトを生成して返却してあげれば、副作用を排除することができます。 const completeTask = ( task ) => ({ ... task , status : 'completed' }) ; それ以外にも、フロントエンドにおいてよく見かけるケースが多いと思われる localStorage や fetch などのAPIに依存した関数も純粋関数ではありません。 // localStorageという引数以外の外部のリソースに依存している const readItems = ( id ) => { const data = localStorage . getItem ( id ) ; return parseItems ( data ) ; } ; // fetchによってHTTPリクエストを送信している const getUserByID = ( id ) => { const res = await fetch ( buildUserByIDURL ( id )) ; return await res . json () ; } ; ただし、上記のように localStorage へのデータの永続化や復元、 fetch() によるHTTPリクエストの送信などはアプリケーションを構成する上で非常に重要な要素です。副作用そのものは便利なアプリケーションを開発するためには必要不可欠なものです。 副作用の存在をきちんと認識し、むやみな乱用を避けたり、きちんと分離することなどが重要ではないかと思います。 純粋関数のメリット 純粋関数について紹介しましたが、具体的にこの純粋関数を使うメリットとは何でしょうか?以下のような点が考えられるでしょう。 1. 結果が予測しやすい 引数以外の要素に依存せず外部要因によって計算結果が変わらないため、計算結果を予測しやすいケースが多いと思います。 また、純粋関数には外部依存がないため、大抵の場合、テストダブル(モックやスタブ、フェイクなど)を用意する必要がなくユニットテストを容易に記述することができます。 2. コードの再利用がしやすい 外部の状態やリソースなど、特定の外部コンテキストへの依存が少なく、引数以外の要素には依存しないため、コードを変更することなく様々な箇所で再利用がしやすいです。 3. 計算結果を安全にキャッシュできる 純粋関数には副作用がなく、同じ引数で呼ばれれば必ず同じ結果を返す性質があるので、関数の計算結果を安全にキャッシュすることができます。 具体例として、メモ化と呼ばれる関数の最適化手法があるため、紹介します。 ja.wikipedia.org 以下はメモ化の単純化した例です。 memoize は指定された関数 fn をメモ化します。 // 指定された関数fnをメモ化します function memoize ( fn ) { const cache = new Map (); return (... args ) => { const key = JSON . stringify (args); if (cache. has (key)) { return cache. get (key); } const result = fn(); cache. set (key, result); return result; } ; } この memoize 関数は、以下のように使用します。 const add = ( a , b ) => a + b; // addのメモ化バージョン const memoizedAdd = memoize(add); memoizedAdd( 1 , 2 ); // => 3 memoizedAdd( 1 , 2 ); // => 3 (引数の組み合わせが同じため、add(1, 2)を呼ばずにキャッシュされた結果が返却されます) memoizedAdd( 2 , 3 ); // => 5 (引数の組み合わせが異なるため、add(2, 3)が呼ばれます) 純粋関数は引数以外の要素には依存せず、副作用も存在しないため、安全にメモ化を行うことができます。 上記の例は説明のためだいぶ簡略化されています。もし実際にメモ化を使いたい場面が出てきた際は、以下のような本格的なライブラリを使うことをお勧めします。 lodashの memoize ramda.jsの memoizeWith メモ化について紹介しましたが、Reactには React.memo() や useMemo() などのAPIがあります。これらはコンポーネントなどに対してメモ化を適用するためのAPIであると考えられるかと思います。 Reactコンポーネントを純粋関数として実装する それでは、具体例の一つとしてこの純粋関数をReactのコンポーネントに当てはめて考えてみます。 Reactコンポーネントは、StateやEffectなどを持たなければ、 props を受け取り VNode を返す純粋関数として実装することができます。 github.com 以下のコンポーネントは props.users にのみ依存しており、 props.users が同じであれば、何度呼んでも同じレンダリング結果が得られます。 function UserList ( props ) { return ( < ul > { props . users . map (( x ) => ( < li key = { x . id } > { x . name } </ li > )) } </ ul > ) ; } 副作用を持つReactコンポーネントについて それでは逆にReactにおいて副作用を扱いたい場合、つまり純粋ではないコンポーネントを実装したい場合はどうすれば良いのでしょうか?こういった場合にEffectやイベントハンドラーなどを利用します。 function UserList () { const [ isLoading , setIsLoading ] = useState( false ); const [ users , setUsers ] = useState( [] ); // 副作用を起こすためにEffectを使う useEffect(() => { const ac = new AbortController (); setIsLoading( true ); fetch ( 'https://api.example.com/users' , { signal : ac.signal } ) . then (( res ) => res.json()) . then (( users ) => setUsers(users)) . finally (() => setIsLoading( false )); return () => ac. abort (); } , [] ); // ... 省略 ... } Reactと参照透過性の関係について Reactのコンポーネントの実態はprops, State, またはContextを受け取りVNodeを返す関数であり、入力として受け取ったprops, State, またはContextが同じであれば同じレンダリング結果が得られます。このようにReactではフレームワークレベルで参照透過性が意識されていることが伺えます。これによりUIのレンダリング結果を予測しやすくしたり、デバッグを行いやすくなることなどが期待されます。 Rules of Reactの各ルールについて github.com 前置きが長くなりましたが、上記までの純粋関数や参照透過性などに関する内容などを踏まえて、あらためてRules of Reactで紹介されているルールについていくつか見ていきます。 Side effects must run outside of render (副作用はレンダリングフェーズの外で実行しなければならない) これは副作用はレンダリングフェーズの外で行うべきであるというルールで、具体的にはイベントハンドラーやEffect ( useEffect )によって副作用は実行されるべきです。 例えば、コンポーネント内において、コンポーネントの外の変数を変更しているようなケースはこのルールに違反します。 let id = 0 ; function Message ( props ) { id = id + 1 ; // <= ここ return ( < div > Message # { id } : { props.message } </ div > ); } 上記コンポーネントにおいて問題なのは以下の箇所です。 let id = 0 ; function Message ( props ) { id = id + 1 ; // <= ここ // ... 省略 ... } ここではコンポーネントのレンダリングフェーズにおいて、コンポーネント外の変数が上書きされてしまっています。(副作用が発生している) このように、コンポーネントのレンダリングフェーズにおいて副作用を発生させることはReactにおいては行うべきではありません。もし副作用を発生させたい場合は、Effectやイベントハンドラーなどを利用する必要があります。 このようにレンダリングフェーズにおいて副作用が発生しないことを前提とすることで、Reactでは優先度に応じて特定のコンポーネントだけレンダリングを中断したり再開したりする余地が生まれます。 Components and Hooks must be idempotent (コンポーネントとフックはべき等でなければならない) 直訳すると コンポーネントとフックはべき等でなければならない といった意味になるかと思います。 べき等であるとはなんでしょうか? 大雑把に言えば、ある処理を何回行っても同じ結果が得られる性質を指します。 glossary.cncf.io 純粋関数は同じ引数が与えられれば常に同じ結果を返すため、引数が一致している場合、純粋関数にはべき等性が担保されます。 ではこのルールについて、先ほど使ったコンポーネントを再掲して紹介します。 let id = 0 ; function Message ( props ) { id = id + 1 ; // <= ここ return ( < div > Message # { id } : { props.message } </ div > ); } 上記のコンポーネントは、この Components and Hooks must be idempotent ルールに違反しています。どうしてかというと、このコンポーネントは id という外部の変数に依存しているので、例え同じ <Message message='foo' /> という呼び出しを行った場合でも、実行の度にレンダリング結果が変わってしまいます (=べき等ではない) Reactにおいては、あるコンポーネントに同じprops, State, またはContextが与えられれば、常に同じレンダリング結果を生成する(=べき等である)ことが期待されます。 このルールを守るためには、以下のような点を守る必要があります。 Effectなど、定められた方法以外で副作用を発生させない ( Side effects must run outside of render ルールに準拠する) useEffect などの各種フックには正しいdepsを指定する Props and state are immutable (Propsとstateは不変である) このルールはpropsやStateを直接の変更を禁止しています。 例えば、以下のように props オブジェクトを直接変更している場合はこのルールに違反しています。 function Double ( props ) { props. count *= 2 ; return < span > { props. count } </ span > ; } // 本来は以下のようにすべき // function Double(props) { // return <span>{props.count * 2}</span>; // } また、上記のコードはレンダリングフェーズにおいて引数として渡されたpropsオブジェクトを直接変更している(=副作用を起こしている)ので、 Side effects must run outside of render にも違反しています。 Stateを更新する場合も、変数に代入するのではなく、 useState から返却されたsetter関数を使う必要があります。 let [ isLoading , setIsLoading ] = useState( false ); useEffect(() => { isLoading = true ; // <= これは誤り // 以下のようにすべき // setIsLoading(true); } , [] ); Return values and arguments to Hooks are immutable (フックの戻り値と引数は不変である) フックに引数として渡されたオブジェクトや、フックが返却したオブジェクトを直接変更してはならないというルールです。なぜ変更してはいけないのかというと、そのフックに依存したコンポーネントや別のフックが意図せぬ振る舞いをしてしまう可能性があるためです。 フックに渡す引数については Props and state are immutable ルールと同様に、直接の編集は避けるべきです。 例えば以下のようなフックがあったとします。 export function useFetch ( url , options ) { if (options. headers == null ) options. headers = { 'content-type' : 'application/json' } ; // ここ const [ isLoading , setIsLoading ] = useState( false ); const [ error , setError ] = useState( null ); const [ data , setData ] = useState( null ); useEffect(() => { const ac = new AbortController (); const signal = ac.signal; setIsLoading( true ); fetch (url, options) . then (( res ) => res.json()) . then (( data ) => setData(data)) . catch (( error ) => setError(error)) . finally (() => setIsLoading( false )); } , [ url, options ] ); return { isLoading , error , data } ; } このフックにおける、下記の箇所で Return values and arguments to Hooks are immutable ルールの違反があります。 export function useFetch ( url , options ) { if (options. headers == null ) options. headers = { 'content-type' : 'application/json' } ; // ここ // ... 省略 ... } この場合、引数の options を直接編集するのではなく、新しいオブジェクトを生成すべきです。 if (options. headers == null ) options = { ...options, headers : { 'content-type' : 'application/json' } } ; Values are immutable after being passed to JSX (JSXに渡された値は不変である) これは一見、想像しづらいかもしれないですが、以下のようなコードでこのルールの違反が発生しています。 function Layout ( props ) { const styles = { fontSize : '14px' , width : '100%' } ; const header = < Header styles = { styles } /> ; const main = ( < Main styles = { styles } > { props.children } </ Main > ); styles.fontSize = '12px' ; // => ここ const footer = < Footer styles = { styles } /> ; return ( <> { header } { main } { footer } </> ); } 問題なのは以下の箇所で、 styles オブジェクトは <Header> のpropsとして渡されたあとに直接変更が加えられています。このような場合、意図せぬレンダリング結果を引き起こしてしまうことが考えられます。 const styles = { fontSize : '14px' , width : '100%' } ; const header = < Header styles = { styles } /> ; // ... 省略 ... styles.fontSize = '12px' ; // => ここ const footer = < Footer styles = { styles } /> ; この場合も Return values and arguments to Hooks are immutable ルールなどのケースと同様に、新しいオブジェクトを作成するとよいでしょう。 const styles = { fontSize : '14px' , width : '100%' } ; const header = < Header styles = { styles } /> ; // ... 省略 ... const footerStyles = { ...styles, fontSize : '12px' } ; // 新しいオブジェクトを作る const footer = < Footer styles = { footerStyles } /> ; なぜRules of Reactが重要か? まず、Reactにおいてコンポーネントはどういった記述を避けるべきかがしっかりと定義されたことが挙げられるかと思います。Ruels of Reactに従っておくことでバグが発生しにくいコードを記述するのに役立ち、また今後のReactや周辺エコシステムのアップデートなどにも追従しやすくなることも期待されます。 また、Rules of Reactに従っておくことで、React公式によって開発されている React Compiler による最適化の恩恵を受けやすくなるメリットもあります。 github.com React CompilerはReactで提供されている useCallback や useMemo , React.memo などによる最適化の適用を自動化してくれます。ただし、このReact Compilerがきちんと動作するためには、ReactコンポーネントやフックがRules of Reactに従っていることが重要です。 React Compilerは実験的ツールであるため、現時点での導入はまだ早いとは思いますが、後述する eslint-plugin-react-compiler というESLintプラグインがあるため、まずはこちらの導入を検討すると良いです。 Reactとは何なのか? Rules of Reactの一連のルールについての概要を確認しました。これらの一連のルールに共通する点として「副作用の存在をきちんと意識する」ということがReactにおいて重要なポイントなのではないかと思いました。 Reactが誕生した当初、人気のあったいくつかのフレームワーク (Angular.js v1など)は双方向バインディングという機能を備えていました。この機能はとても便利である一方、複雑化するとレンダリング結果が予測しづらくなりがちであるという課題がありました。Reactではフレームワークレベルで参照透過性を意識して設計されることにより、このレンダリング結果が予測しづらくなる課題を解消することが目的の一つであったのではないかと推測しています。 Reactの特徴としてよく挙げられる宣言的であるという性質はReactにおいて重要な特性の一つではあると思いますが、それはどちらかといえば参照透過性によってもたらされる副次的な要素であるのではないかと個人的には考えています。 また、Reactにおけるコンポーネントの実態はpropsまたはStateを受け取ってVNodeというオブジェクトを返却する関数と考えられます。もしコンポーネントがStateやEffect, Contextなどに依存せず、propsのみに依存しているのであれば、コンポーネントを副作用のない純粋関数として実装することもできます。そういったコンポーネントは副作用もなく特定の文脈などへの依存も少ないため、容易に再利用ができますし、レンダリング結果の予測やテストコードの記述なども容易に行えます。 こういった点などを基に考えると、Reactというのは副作用や参照透過性といったものの存在を念頭において設計することで、高い再利用性やレンダリング結果の予測などを可能とすることを目的としたフレームワークであると考えます。 実践編 Rules of Reactのルールについていくつか紹介しました。具体的にRules of Reactを実際のアプリケーション開発に適用するにはどうすれば良いでしょうか? React公式から推奨されている方法などについて紹介します。 <StrictMode> を有効化する <StrictMode> とはReactフレームワークによって提供されているコンポーネントです。 この <StrictMode> が有効化されると、Reactは意図的にあるコンポーネントを複数回余分にレンダリングしたり、Effectを余分に実行します。 この振る舞いにより、もし Components and Hooks must be idempotent や Side effects must run outside of render などのルールに違反しているコンポーネントやフックが存在する場合に、意図せぬ振る舞いを引き起こす可能性があります。 <StrictMode> は問題のあるコンポーネントやフックを特定するのに役立ち、導入も比較的しやすいと思われるため、ぜひ導入しておくと便利です。 import { StrictMode } from 'react' ; import { createRoot } from 'react-dom/client' ; const root = document . getElementById ( 'root' ) ; createRoot ( root ) . render ( < StrictMode > < App /> </ StrictMode > ) ; eslint-plugin-react-hooks を導入する eslint-plugin-react-hooks についてはおそらく使ったことがある方も多いのではないかと思います。 www.npmjs.com Rules of Reactで紹介されているルールのうち、Rules of Hooksに違反しているコードを検出してくれます。 github.com ESLintプラグインであり導入のハードルなども比較的低いと考えられるため、これについてはぜひ導入をしておくとよいでしょう。 eslint-plugin-react-compiler を導入する eslint-plugin-react-compiler はReact公式によって開発されているESLintプラグインです。 www.npmjs.com React QueryやMUI (Material UI)などの有名なライブラリでもすでに導入されており、ある程度大規模なプロジェクトにおいても少しずつ運用が進められているようです。 github.com github.com このプラグインを設定しておくことで、例えば、コンポーネントやフックからグローバル変数などに対して操作を行おうとすると、以下のようなエラーが発生します。 Writing to a variable defined outside a component or hook is not allowed. Consider using an effect react-compiler/react-compiler 同様に、コンポーネントやフックからグローバル変数に対して再代入を行うと、以下のようなエラーが発生します。 Unexpected reassignment of a variable which was defined outside of the component. Components and hooks should be pure and side-effect free, but variable reassignment is a form of side-effect. If this variable is used in rendering, use useState instead. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render) このように eslint-plugin-react-compiler はRules of Reactに違反しているコンポーネントやフックなどのコードを検出してくれます。結構厄介そうなバグとかも拾ってくれそうなので、個人的にはかなり便利なのではないかと感じます。 現時点だとESLint v9のFlat Configにはまだ対応していなさそうなので、もしFlat Configと併用したい場合は @eslint/compat を利用する必要がありそうです。 import { fixupPluginRules } from '@eslint/compat' ; import tseslint from 'typescript-eslint' ; import pluginReact from 'eslint-plugin-react' ; import pluginReactCompiler from 'eslint-plugin-react-compiler' ; export default tseslint.config( ...tseslint.configs.recommended, pluginReact.configs.flat.recommended, // ... 省略 ... { plugins : { 'react-compiler' : fixupPluginRules(pluginReactCompiler) } , rules : { 'react-compiler/react-compiler' : 'error' , } , } , ); React Compilerの導入そのものはまだ早いとは思いますが、 eslint-plugin-react-compiler についてはESLintプラグインであり導入も比較的しやすいとは思うため、もし可能なら今のうちに入れておくのもよいと思います。 おわりに 以上、Rules of Reactを基にReactについて改めて見ていきました。現時点ではドキュメントの分量もそこまで多くはないので、比較的スムーズに読み進めやすいのではないかと思います。最新のドキュメントは以下のリンクから閲覧いただけます。 react.dev また、今回、 eslint-plugin-react-compiler を試してみたのですが、触ってみた感覚としてはとても便利な印象を受けました。コードレビューなどでは気付きにくい厄介な問題なども検出してくれそうなため、もし余裕がありそうなら導入をしてみるとよさそうに感じました。
アバター
MiiTel Platform チームの小門です。 2024年9月27日(金)~29日(日)に開催される PyCon JP 2024 に RevComm のエンジニア陶山 嶺と、わたし小門 照太の2名が登壇します イベント概要 https://2024.pycon.jp/ja 公式サイトから引用 PyCon JP は、Python ユーザが集まり、Python や Python を使ったソフトウェアについて情報交換、交流をするためのカンファレンスです。 PyCon JP の開催を通じて、Python の使い手が一堂に集まり、Python にまつわる様々な分野の知識や情報を交換し、新たな友達やコミュニティとのつながり、仕事やビジネスチャンスを増やせる場所とすることが目標です。 日程: 2024年9月27日(金)~29日(日) 会場: TOC有明コンベンションホール 主催: 一般社団法人 PyCon JP Association チケット申し込みはこちらから pyconjp.connpass.com 登壇情報 実例から学ぶ型ヒントの活用手法 近年のPythonは型ヒントの強化が活発で、メジャーアップデートのたびに便利な機能が追加されています。 さらに型ヒントを活用するライブラリやツールも多く登場し、コミュニティからの絶大な人気を集めています。 (中略) Pythonの型ヒントはまだまだ多くの可能性を秘めていると思います。 本セッションを通じて、普段の開発で型ヒントをより便利に活用し、新たなアイディアを自身の手で具体化していきましょう。 日時: 2024年9月28日(土) 12:40 - 13:10 登壇者: Rei Suyama リンク: https://2024.pycon.jp/ja/talk/LXVHNY Rustを活用したPythonライブラリの開発 Python以外の言語で実装された機能(モジュール、クラス、関数)をPythonのライブラリとして使用することが可能です。 有名なものでは Numpy / Pandas は高速化のために主にC言語をベースに実装されています。 最近ではC/C++以外にもRust言語の活用が注目されています。 本セッションでは、Rust を利用してPythonライブラリを開発する利点や手順などを解説します。 また実際にRustが使用されているライブラリの実例を紹介します。 日時: 2024年9月28日(土) 13:30 - 14:00 登壇者: Shota Kokado リンク: https://2024.pycon.jp/ja/talk/7GPRYL おわりに 今回登壇する2名ともに昨年に引き続きの登壇となり、光栄です。 会場でお会いできることを楽しみしております!
アバター