TECH PLAY

KINTOテクノロジーズ

KINTOテクノロジーズ の技術ブログ

936

この記事は KINTOテクノロジーズアドベントカレンダー2024 の11日目の記事です🎅🎄 メリークリスマス✌️🎅 KINTOテクノロジーズで my route(iOS) を開発しているRyommです。 本記事ではカスタムスタイルの紹介をします。 はじめに 私がこの書き方を知ったのはApp Dev Tutorialsがきっかけです。 https://developer.apple.com/tutorials/app-dev-training/creating-a-card-view#Customize-the-label-style なんてスタイリッシュなんだ…! カスタムスタイルを使うことで、SwiftUIのコードが格段に読みやすく、洗練されたコードになる…!私もスタイリッシュなコードを書きたい! そんな衝動に駆られて使い始めましたが、実際かなり便利で読みやすいのでおすすめです。 特に、用途別に乱立した構造体名を覚えていなくても ~~Style() にドットで候補を探せるところが気に入っています。 カスタムスタイルのつくりかた 例えばLabelのカスタムスタイルを作成する場合、 LabelStyle を継承した構造体を作成し、プロトコルに準拠したメソッド(ここでは makeBody(configuration:) )内にスタイルを定義します。 configurationに含まれる値はものによって異なるので都度調べる必要がありますが、LabelStyleConfigurationに関してはTextとImageのViewが入っています。 /// 文字+アイコン のラベルスタイル struct TrailingIconLabelStyle: LabelStyle { func makeBody(configuration: Configuration) -> some View { HStack { configuration.title configuration.icon } } } さらに LabelStyle を拡張して、作成したカスタムスタイルを静的プロパティとして追加すると、呼び出し時に .labelStyle(.trailingIcon) のように呼び出すことができて可読性が向上します。ン〜スタイリッシュ! extension LabelStyle where Self == TrailingIconLabelStyle { static var trailingIcon: Self { Self() } } もし「spaceを指定したい」など、引数を持たせたい場合はカスタムスタイルにメンバプロパティを追加することで実現できます。 /// 文字+アイコン のラベルスタイル struct TrailingIconLabelStyle: LabelStyle { // デフォルト値を設定しておくとドット始まりの呼び出し方法もキープできる var spacing: CGFloat = 4 func makeBody(configuration: Configuration) -> some View { HStack(spacing: spacing) { configuration.title configuration.icon } } } // 呼び出し Label().labelStyle(.trailingIcon) // spaceにはデフォルト値が使われる Label().labelStyle(TrailingIconLabelStyle(spacing: 2)) // spaceを2に指定 使いどころ アプリ全体で広く使う共通デザインや、上記の TrailingIconLabelStyle のように普遍的なカスタムスタイルに使うと良いでしょう。 たとえば、my routeではProgressViewで使っています。 ProgressView自体のスタイル設定もですが、ProgressViewを表示中に背景をグレーっぽくするのもスタイルに含めることができます。 struct CommonProgressViewStyle: ProgressViewStyle { func makeBody(configuration: Configuration) -> some View { ZStack { ProgressView(configuration) .tint(Color(.gray)) .controlSize(.large) Color(.loadingBackground) .frame(maxWidth: .infinity, maxHeight: .infinity) } } } extension ProgressViewStyle where Self == CommonProgressViewStyle { static var common: Self { Self() } } ちなみに、ProgressViewに background() を指定するとProgressViewに必要なサイズのみしか描画されないので、ZStackでColorをProgressViewの下に敷き、背景色が与えられたサイズ全体に広がるようにしています。 このようにスタイルを作成することで、以下のように簡潔でスタイリッシュに書けるようになりました。 struct SomeView: View { @State var loadingStatus: LoadingStatus var body: some View { SomeContentView .overlay { if loadingStatus == .loading { ProgressView() .progressViewStyle(.common) } } .disabled(loadingStatus == .loading) } } おわりに カスタムスタイルの紹介でした! 以下のページにあるものはカスタムスタイルを作成できます。 https://developer.apple.com/documentation/swiftui/view-styles よりスタイリッシュなコードを目指して一歩前進 🦌 🎄
アバター
This article is part of day 11 of KINTO Technologies Advent Calendar 2024 Merry Christmas! ✌ I'm Ryomm, and I work on developing the My Route iOS app at KINTO Technologies. In this article, I will introduce custom styles. Introduction App Dev Tutorials were the reason I learned to create custom styles. https://developer.apple.com/tutorials/app-dev-training/creating-a-card-view#Customize-the-label-style How stylish...! Using custom styles significantly enhances the readability and sophistication of SwiftUI code...! "I want to write stylish code, too!" Initially, that was what inspired me to start using it, but now I recommend it because it’s genuinely convenient and makes the code much easier to read. What I particularly like is that you can search for options using dots in ~~Style() even if you don’t remember the specific structure names, as they are organized based on their purpose. How to create a custom style For example, if you want to create a custom style for a Label, create a structure that inherits LabelStyle and define the style in a protocol-compliant method (in this case makeBody(configuration:) ). The values within the configuration object vary depending on what you're creating, so it's important to check each time. For LabelStyleConfiguration, it includes Text and Image views. /// Character + Icon LabelStyle struct TrailingIconLabelStyle: LabelStyle { func makeBody(configuration: Configuration) -> some View { HStack { configuration.title configuration.icon } } } You can also extend LabelStyle to add your custom style as a static property, which can be called as .labelStyle(.trailingIcon) when invoked, and improve readability. So~ stylish! extension LabelStyle where Self == TrailingIconLabelStyle { static var trailingIcon: Self { Self() } } If you want to have a parameter, such as "specify a space," you can do this by adding a member property to your custom style. /// Character + Icon LabelStyle struct TrailingIconLabelStyle: LabelStyle { // you can set the default value to preserve the way the dot starts are called var spacing: CGFloat = 4 func makeBody(configuration: Configuration) -> some View { HStack(spacing: spacing) { configuration.title configuration.icon } } } // call The default value is used in Label().labelStyle(.trailingIcon) // space Label().labelStyle(TrailingIconLabelStyle(spacing: 2)) // Set space to 2 Uses You can use it for common designs that you use widely throughout apps, or for universal custom styles like TrailingIconLabelStyle above. For example, my route uses it in ProgressView. While ProgressView itself is styled, you can also include a grayish background when ProgressView is displayed. struct CommonProgressViewStyle: ProgressViewStyle { func makeBody(configuration: Configuration) -> some View { ZStack { ProgressView(configuration) .tint(Color(.gray)) .controlSize(.large) Color(.loadingBackground) .frame(maxWidth: .infinity, maxHeight: .infinity) } } } extension ProgressViewStyle where Self == CommonProgressViewStyle { static var common: Self { Self() } } By the way, when you use background() with a ProgressView, it only applies to the area required by the ProgressView. To ensure the background color covers a larger area, you can use a ZStack to place the color beneath the ProgressView, allowing the background to expand to the desired size. By defining a style in this way, you can achieve concise and elegant code, as shown in the example below. struct SomeView: View { @State var loadingStatus: LoadingStatus var body: some View { SomeContentView .overlay { if loadingStatus == .loading { ProgressView() .progressViewStyle(.common) } } .disabled(loadingStatus == .loading) } } Conclusion That wraps up this introduction to custom styles! You can create custom styles on the following page. https://developer.apple.com/documentation/swiftui/view-styles Take a step toward writing more stylish and elegant code!
アバター
Introduction Hello, we are Chang and Hosaka, in charge of the my route by KINTO iOS app development in the Mobile App Development Group. In our mobile app development group, we usually use GitHub Actions as a CI/CD tool. This time, we introduced Bitrise for the first time to the my route by KINTO iOS app, so we would like to talk about it. What is Bitrise ? Bitrise is a cloud-based CI/CD (continuous integration / continuous delivery) service for the automated building, testing, and deployment of mobile apps. Bitrise is designed to streamline mobile app development, and it supports major mobile app development frameworks such as iOS, Android, React Native, and Flutter. Some of the key features of Bitrise include: Build Automation Builds are automatically triggered when code in the repository is updated. Builds can be easily configured using the visual interface. Test Automation Tests are automatically run after the builds are complete. Bitrise supports integration with a variety of testing tools, allowing automation at different test levels, including unit testing and UI testing. Automated Deployment If the test passes, Bitrise will automatically take steps to deploy the app. Bitrise supports deployment to app stores such as the App Store and Google Play. Variety of Integration Bitrise supports integration with a variety of tools and services, including GitHub, Bitbucket, Slack, Jira, Firebase, and more. Cloud-based Services Bitrise is a cloud-based service that does not require infrastructure configuration or management. Developers can easily take advantage of Bitrise’s features. Bitrise is a powerful tool for streamlining and improving the quality of mobile app development, and is a very valuable CI/CD service to developers and development teams. The Reason for Introducing Bitrise There are two reasons why we implemented Bitrise in the my route by KINTO iOS app. We had an opportunity to hear from Bitrise Ltd. before building a CI/CD environment, and at that time all the team members had replaced their PCs from Intel to M1, so we were fascinated by Bitrise, which can be built in the same M1 environment. The results of the experiment below, comparing Bitrise and Github Actions on an Intel Medium machine (the lowest performance), show that the cost can be reduced by about 30% and the processing time can be shortened by about 50%. Bitrise and GitHub Actions performance comparison experiment (tested in a different app): Processing Time Comparison Experimental Attempt / Machine Name Bitrise (Intel Medium) Github Actions 1 07:48 16:24 2 11:42 16:18 3 06:53 16:09 Average 08:48 16:17 Cost Comparison Github Actions cost per minute is $0.08 Bitrise cost per minute is $0.106666 Bitrise calculation: Given that 1 credit (cr.) = elapsed minutes (min.) × machine spec (2)... (i), and $400/7,500 credit = apprrx. 0.05333($/cr)... (ii), For (i) and (ii), apprx. 0.05333($/cr.) × 2(cr./min.) = apprx. 0.106666 ($/min.) Experimental Count / Machine Name Bitrise (Intel Medium) Github Actions 1 $0.85 $1.36 2 $1.28 $1.36 3 $0.75 $1.36 Average $0.96 $1.36 Using Bitrise in my route by KINTO To implement Bitrise in my route KINTO, we signed up for Bitrise's Teams pricing plan and adopted an M1 Medium machine consuming 2 credits per minute. The Teams plan has a credit limit set according to the price, and exceeding that limit incurs additional costs, so we aimed to optimize costs by also using GitHub Actions. With GitHub Actions, Linux is 1/10 the cost of macOS . Therefore, we use GitHub Actions for steps that can run on Linux (no app build required) and Bitrise for steps that require macOS (app build required). Bitrise Workflow my route by KINTO, mainly automates the following: unit testing, deployment to App Store and TestFlight, and build result notifications to Slack. Currently, builds are triggered by pushing to the develop and release branches, and scheduled builds are done on weekday mornings. We have observed that a single build takes about 6-11 minutes (12-22 credits). GitHub Actions Workflow GitHub Actions automates the static analysis flow. SwiftLint: A static analysis tool for Swift that automatically points out any code violations in the PR. SonarQube: A static analysis tool that analyzes code duplication and other issues that SwiftLint cannot cover. Summary and Future Prospects Looking ahead, Bitrise is expected to continue to expand and improve its features to meet the needs of mobile app development. For example, we can expect more advanced testing and deployment options, more flexible workflow settings, and further expansion of cloud-based resources. It is also expected to provide a more seamless development experience, including collaboration with the developer community and improved integration with other tools. KINTO Technologies would like to keep a close eye on the trends and lead to further utilization of this technology. Here is the review. https://findy-tools.io/products/bitrise/18/39
アバター
GitHub Actionsだけで実現するKubernetesアプリケーションのContinuous Delivery こんにちは。Toyota Woven City Payment Solution開発グループの楢崎と申します。 我々は、 Woven by Toyota で Toyota Woven City で利用される決済基盤アプリケーションの開発に携わっており、バックエンドからWebフロントエンド、モバイルアプリケーションまで決済に関わる機能を横断的に開発しています。 決済バックエンドはKubernetes上で動作し、いわゆるクラウドネイティブなツール群を使って開発しています。 今回はKubernetesアプリケーションを構築・安定運用していく上でキーとなる、GitOps(Gitでインフラ構成ファイルを管理し変更を適用指定する運用方法)を踏襲しつつ、CD(Continuous Delivery)プロセスに関して、一般に用いられている、いわゆる「クラウドネイティブなツール」ではなく、GitHub Actionsだけで構築することを目指します。 ここでいうCDの機能はあくまで Kubernetesの構成管理ファイルの変更の適用 コンテナイメージのアップデート です。他にもBlue / GreenやCanaryデプロイなど応用的なCDプロセスはありますが、「小さくスタート」することを想定しています。既にDevOpsチームが組成されていて、その恩恵にあやかれる人ではなく、最小の開発人数で、かつ新たなツールなしに普段利用しているGitHub Actionsのみを利用してKubernetes上で動作するアプリケーションを生産性高く継続的デリバリーさせたい人が対象になります。 レポジトリもアプリケーションのコードとKubernetesの構成管理ファイルを同じレポジトリで管理していることを想定しています。(権限の設定次第ではレポジトリをまたいで実行可能だとは思いますがここでは触れません) (Gitlabを普段お使いの方であれば Auto DevOps という非常に優秀なツールがあるので、決してGitHub及びGitHub Actions最高!というつもりはありませんので悪しからず) クラウドネイティブなKubernetes向けCI/CDツール Kubernetes向けアプリケーションのCI/CDと聞いて読者の皆さんはどのようなツールを思いつきますか? Argo CD Flux CD PipeCD Tekton などが挙げられます。 いずれのツールも非常に高機能で、Kubernetesの機能をフルに活かすために非常に有用です。 またGitOpsを実践する上で、Kubernetesの構成ファイルやアプリケーションイメージを柔軟に安全に更新できます。 一方でツール特有の知識や運用も必要で、DevOpsに専門の人員がいるような大きな組織ではないと継続的に運用するのは難しいのではないでしょうか? CDツールの運用そのものにKubernetesが必要で、Kubernetesの構成ファイルを管理するツールのためにKubernetesの構成ファイルが必要ということで、少人数の組織では導入や運用の敷居も非常に高いと思っています。 この記事では以下の図のようなパイプラインを、GitHub Actionsだけで構成することを考えます。 Kubernetesは特定のクラウドではなく汎用的なクラスタを想定しています。何かしらのコンテナレジストリがあることを想定しています。構成管理ファイルはKustomizeを例にしますが、Helm, Terraformなど何でも応用可能だと思います。 flowchart TD A[コードの変更] -->|ビルドパイプラインを実行| B[コンテナイメージのビルド、プッシュ] B -->|コンテナイメージ更新用のパイプラインが起動| C[新しいコンテナイメージでkustomizationを書き換えたプルリクエストができる] C -->|プルリクエストのレビュー| D[新しいコンテナイメージをKubernetesにデプロイ] linkStyle default stroke-width:2px,color:blue,stroke-dasharray:0 デモ Kubernetesの構成ファイルを管理するフォルダとアプリケーションのフォルダが入っているレポジトリを考えます。 フォルダ構成は以下になります。ここでは具体的なコードやDockerfileの中身、各アプリケーションのソース等は省略します。 ├── .github │   ├── actions │   │   └── image-tag-update │   │   └── action.yaml │   └── workflows │   ├── build-go.yaml │   ├── build-java.yaml │   ├── build-node.yaml │   └── kubectl.yaml ├── go-app │   ├── src/ │   └── Dockerfile ├── java-app │   ├── src/ │   └── Dockerfile ├── k8s │   ├── service-go.yaml │   ├── service-java.yaml │   ├── service-node.yaml │   └── kustomization.yaml └── node-app    ├── src/ └── Dockerfile それぞれのアプリケーションは以下のような形でplaceholderとし、 apiVersion: apps/v1 kind: Deployment metadata: name: app spec: ... template: ... spec: containers: - name: some-server image: go-placeholder # placeholderとしてkustomizationと同じ文字列を入れておく その複数のplaceholderをkustomization.yaml上で一元管理します。 apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: techblog resources: - service-go.yaml - service-java.yaml - service-node.yaml images: - name: go-placeholder newName: go-app newTag: v1.1.1 - name: java-placeholder newName: java-app newTag: v2.7.9alpha - name: node-placeholder newName: node-app newTag: latest まずKubernetesの構成ファイルを適用するために、以下のようなyamlのGitHub Actionsを構成します。 name: kubectl on: pull_request: branches: - "**" paths: - "k8s/**" #Kubernetesのmanifest fileが入っている場所 push: branches: - main paths: - "k8s/**" jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: azure/setup-kubectl@v4 - env: KUBECONFIG_CONTENTS: ${{ secrets.KUBECONFIG_CONTENTS }} #事前にkubeconfigをGitHubのシークレットに格納しておく run: | echo "${KUBECONFIG_CONTENTS}" > $HOME/.kube/config chmod 600 $HOME/.kube/config - run: kubectl apply --dry-run=server -k ./k8s >> $GITHUB_STEP_SUMMARY - if: github.ref == 'refs/heads/main # mainブランチだったら実際に適用 run: kubectl apply -k k8s/ これは管理者権限を持ったkubeconfigが手元にある場合の一般的なKubernetesの構成ファイルを適用するパイプラインです。 各クラウドなどのクラスタの設定方法に応じてconfigの取得方法は変更してください。 次に各アプリケーションをpushする際に自動でプルリクエストを作るか、コンテナのイメージタグを書き換えるcompositeを作成します。 name: image-tag-update description: 'コンテナイメージの更新時にkustomizationのイメージタグを書き換えるタスク' inputs: target_app: description: '対象のアプリケーション' required: true tag_value: description: '新しいコンテナイメージタグ' required: true token: description: 'PRや内容の更新の権限を持ったトークン' required: true runs: using: 'composite' steps: - uses: actions/checkout@v4 id: check-branch-exists continue-on-error: true with: ref: "image-tag-update" # タグ更新用のデフォルトブランチ名 - uses: actions/checkout@v4 # checkoutはブランチが存在しないとデフォルトブランチにフォールバック、みたいなことはできないのでこういう書き方 if: steps.check-branch-exists.outcome == 'failure' with: ref: main - uses: mikefarah/yq@master # yqで対象のplaceholderのタグの値を置換 with: cmd: yq eval '(.images[] | select(.name == "'"${{ inputs.target_app }}-placeholder"'")).newTag = "'"${{ inputs.tag_value }}"'"' -i k8s/kustomization.yaml - uses: peter-evans/create-pull-request@v6 if: steps.check-branch-exists.outcome == 'failure' # プルリクエストがないと新しいプルリクエストを作成 with: title: 'コンテナイメージの更新' body: | `${{ inputs.target_app }}` を更新します branch: "image-tag-update" - uses: stefanzweifel/git-auto-commit-action@v5 if: steps.check-branch-exists.outcome == 'success' # チェックアウトが成功したら、既存のブランチにコミットを追加 with: commit_message: "Image update for ${{ inputs.target_app }}" 各アプリケーションのイメージを作成する過程で、上記のcompositeを呼びます。複数のアプリケーションを管理している場合は、それぞれのアプリケーションの後に付け加えるとよいでしょう。 ... - uses: docker/setup-buildx-action@v3 - uses: docker/build-push-action@v6 with: file: ./Dockerfile push: true tags: ${{ env.tag }} # なにかしらのタグ - uses: ./.github/actions/image_update if: github.ref == 'refs/heads/main' with: target_app: go tag_value: ${{ env.tag }} token: ${{ secrets.GITHUB_TOKEN }} # コンテンツ、プルリクエスト編集権限を持ったgithub token これで、アプリケーションを実行するタイミングでコンテナイメージが自動でアップデートされ、プルリクエストベースで新しいコンテナイメージがデプロイできるようになります! (タグの導出方法は各自のワークフローにおまかせします。下記の例はマイナーバージョンをインクリメントした例です) - name: go-placeholder newName: go-app - newTag: v1.1.1 + newTag: v1.1.2 運用上の注意点 デプロイのタイミング イメージ更新用のプルリクエストが、マージされた瞬間にデプロイされます。インフラの構成ファイルの修正と合わせてリリースしたい場合は、このブランチに修正を追加するか、マージするタイミングを合わせて適用するといいでしょう。 新規コンテナアプリケーションの追加 例えば上記の例でPythonのアプリケーションを足したいという時に、イメージ更新用のプルリクエストがそのまま残っていると、Pythonのイメージタグをどれだけ更新してもプルリクエスト自体に最新版の変更が反映されてない限り、空振りし続けるので注意が必要です。 切り戻し Commitをrevertすれば戻せるので非常にシンプルです。 Reconcileのタイミング GitOps Toolの多くがドリフトを抑制するためのリコンサイルをほぼリアルタイムで実施できるのに対して、このやり方だとCDパイプラインが動作したタイミングでないと実施できません。 Kubernetesのクラスタに更新権限をどれくらいのチームメイトが保有して権限を行使しているかにも応じてツールの使い分けは大事だと思います。 Container Registryを直接見ているわけではない コンテナレジストリから直接コンテナイメージの最新版を取得するものもありますが、この方法では実際に見ているわけではありません。確実にコンテナが存在するか、確認するステップをコンテナレジストリごとに実装したほうが良さそうです。 GitHub Actionsの権限設定に関して contents と pull-requests の更新権限が必要になってきます。Actionsのパーミッション、GitHub Appなどに権限をアサインして使ってください。詳しくは こちら 。 後に実行されたコンテナイメージで上書きされる CDツールには、コンテナイメージのタグの値をみて、Semantic Versioningなどの規則に従ってどちらが新しいバージョンか判別する仕組みがあります。 上記で示したworkflowはタグの値に関係なく、後に実行されたパイプラインでイメージタグを上書きします。 この挙動が問題であれば値を検証して、上書きすべきか判定する必要があります。 まとめ このやり方を用いることで、GitOpsがGitHub上で完結して、非常にシンプルにKubernetesアプリケーションの継続的デリバリーが実践できるのではないのかなと思います。 CDツールのエラーもGitHub Actions上に集約できるので、普段のCIプロセスと同じ方法で実行結果やエラーの内容が確認できるのは非常に嬉しいですね。 色々なツールが多く存在し、目移りすることも多いKubernetesのツール選定ですが、身の丈にあったツールを利用してKubernetesアプリケーション開発の生産性を高めていきたいですね。
アバター
この記事は KINTOテクノロジーズアドベントカレンダー2024 の10日目の記事です🎅🎄 Background When developing the KINTO かんたん申し込みアプリ App, we implemented some shared code using KMP (Kotlin Multiplatform) and published it as a Swift Package. This approach allowed us to efficiently share code across platforms and simplify the development process by avoiding code duplication. Our iOS Team currently uses XcodeGen to manage dependencies, and importing KMP code can be as simple as making a 4-line modification to the project.yml file. Here is an example of such a modification: packages: + Shared: + url: https://github.com/[your organization]/private-android-repository + minorVersion: 1.0.0 targets: App: dependencies: + - package: Shared - package: ... However, since our code resides in private repositories, some additional setup is required. This blog will outline those steps and explain how we streamlined the process. About Package.swift Here’s a brief explanation of how we publish KMP code as a Swift Package: Compile the KMP code into an .xcframework . Package the .xcframework into a zip file and calculate its checksum. Create a new release page on GitHub and upload the zip file as part of the release assets. Obtain the zip file’s URL from the release page. Generate the Package.swift file based on the URL and checksum. Commit the Package.swift file and add a git tag to mark the release. Associate the git tag with the release page and officially publish the GitHub release. The resulting Package.swift file will look something like this: // swift-tools-version: 5.10 import PackageDescription let packageName = "Shared" let package = Package( name: packageName, ... targets: [ .binaryTarget( name: packageName, url: "https://api.github.com/repos/[your organization]/private-android-repository/releases/assets/<asset_id>.zip", checksum: "<checksum>" ) ] ) Permission Setup for Development Environment Since the URL resides in a private repository, you will encounter the following error if no permission configuration is done: To resolve this, we explore two options: .netrc files and Keychain. Option 1: Using a .netrc File You can store your GitHub credentials in a .netrc file, which is a simple way to authenticate API requests: #Example: echo "machine api.github.com login username password ghp_AbCdEf1234567890" >> ~/.netrc echo "machine api.github.com login <Your Github Username> password <Your Personal Access Token>" >> ~/.netrc This method is quick and effective but may pose security risks since the token is stored in plaintext. Option 2: Using Keychain If you prefer not to store the token in plaintext, you can use Keychain to securely store your credentials: Open Keychain Access.app . Select ①, the login keychain. Select ②, to create a new Password Item. In the dialog box, enter the following information: Keychain Item Name: https://api.github.com Account Name: Your GitHub username Password: Your Personal Access Token This approach is more secure and integrates seamlessly with macOS authentication mechanisms. For SSH Users The above instructions assume you cloned the iOS repository using the https protocol. If you did, you already have the necessary permissions for github.com configured. However, if you cloned the repository using the ssh protocol, you might lack permissions for github.com , leading to permission-related errors during the resolveDependencies phase. To fix this, you can add an entry for the domain github.com in the .netrc file: #Example: echo "machine github.com login username password ghp_AbCdEf1234567890" >> ~/.netrc echo "machine github.com login <Your Github Username> password <Your Personal Access Token>" >> ~/.netrc Alternatively, use Keychain Access to add an item with the name https://github.com . Either method ensures your system has the required permissions. GitHub Actions After resolving the local development environment issues, you also need to address permission issues in the CI environment to ensure smooth automation during builds. Retrieving Tokens in GitHub Actions Using a Personal Token One straightforward approach is to create a Personal Access Token (PAT) with access to private repositories and pass it to the CI environment via Actions secrets. While effective, this method has several drawbacks: Token Expiration Tokens with an expiration date require periodic updates, and forgetting to update them may cause CI failures. Tokens without an expiration date pose long-term security risks. Broad Permissions A personal account usually has access to multiple private repositories, making it difficult to restrict PAT permissions to a single repository. Personal Dependency If the account owner loses access to private repositories due to role changes, CI workflows will fail. Using a GitHub App Using a GitHub App is a more robust solution, offering several advantages: Fine-grained permissions for repositories No dependency on individual accounts Temporary tokens that enhance security Setting Up a GitHub App We ultimately used a GitHub App to configure access permissions. Here is the process: Create a GitHub App in your organization. Install the App in both iOS and Android projects to manage repository access. Configure the App’s AppID and Private Key in the iOS project’s Actions secrets. Add code in the workflows to retrieve a temporary Access Token. Here’s an example: steps: - name: create app token uses: actions/create-github-app-token@v1 id: app-token with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: "YourOrgName" - name: set access token for private repository shell: bash env: ACCESS_TOKEN: ${{ steps.app-token.outputs.token }} run: | git config --global url."https://x-access-token:$ACCESS_TOKEN@github.com/".insteadOf "https://github.com/" touch ~/.netrc echo "machine github.com login x-access-token password $ACCESS_TOKEN" >> ~/.netrc echo "machine api.github.com login x-access-token password $ACCESS_TOKEN" >> ~/.netrc By using a GitHub App, we ensure our CI workflows are secure, efficient, and free from dependency on individual user accounts. This approach minimizes risk and streamlines development across teams.
アバター
この記事は KINTOテクノロジーズアドベントカレンダー2024 の10日目の記事です🎅🎄 「Mobility Night」は、モビリティ領域のソフトウェア技術者、ビジネスパーソン、研究者、プロダクトマネージャーなどが気軽に集まり、業界特有の知見や課題を共有する勉強会シリーズです。#0(初回)のクローズド開催を経て、いよいよ第1回(#1)をオープンな形で開催することができました。 今回のテーマは、モビリティサービスの基盤技術である「GPS・位置情報」。カーナビや地図アプリ、オンデマンド交通、そして将来の自動運転やスマートシティ基盤まで、「いまどこにいるか」を正確に把握し、活用することはサービス価値の根幹を支えます。 当日は以下の5つのセッションが行われ、GPS・位置情報技術を軸に、それぞれが独自の切り口から課題と可能性を示しました。 この記事は技術広報グループでMobility Nightの企画運営も行っている中西が執筆しています。 1. Exploring New Google Places API 登壇者: KINTOテクノロジーズ株式会社 numaさん Google Places APIは地図プラットフォームの中核機能の一つであり、周辺検索や施設情報取得を効率的に行うための重要なインターフェースです。このセッションでは、テキスト入力中から即時に候補を提示するAutocomplete機能の強化や、Fieldsパラメータによる必要情報の絞り込みなど、最新の改善点が紹介されました。 ポイント: パフォーマンスとコスト最適化: Fields指定で不要なデータ取得を削減し、APIコストを抑えるとともにレスポンス高速化が可能。 ユーザーエクスペリエンス向上: 欲しい情報に素早くアクセスできる体験は、移動中のユーザーにとって大きな利点。Autocomplete強化で検索負荷を軽減し、UXを磨き上げる。 将来への展望: 現在は位置情報取得が中心だが、将来的にはIoTセンサーや行動履歴分析と組み合わせたパーソナライズ戦略も期待できる。 https://speakerdeck.com/kintotechdev/exploring-new-google-places-api 2. AIドラレコサービスの走行データで作る位置情報データプロダクト 登壇者: GO株式会社 松浦慎平さん ドライブレコーダーは事故記録用のデバイスという印象が強いですが、このセッションでは「走行データ=街をセンサー化するプラットフォーム」として再解釈されました。映像+GPSデータをAI解析することで、道路標識や信号、舗装工事などの情報を動的に地図へ反映できる可能性が示唆されました。 ポイント: ダイナミックな地図更新: 静的だった地図を“生きた情報基盤”へ進化させ、道路インフラ変化をほぼリアルタイムで反映。 複数車両データの統合: 異なる車両から得られるデータを突き合わせることで、一時的な標識や工事箇所などを高精度に検出。 プライバシー対策: 個人情報が映り込む映像を適切に匿名化しつつ道路情報を保持する技術・運用が必須。 将来的応用: 自動運転用HDマップ整備、スマートシティ計画、新サービス創出など、多面的なビジネス展開が期待。 https://speakerdeck.com/pemugi/aidorarekosabisunozou-xing-deta-dezuo-ruwei-zhi-qing-bao-detapurodakuto-wei-zhi-qing-bao-jing-du-xiang-shang-nogong-fu 3. GPSモジュールを触って学ぶ、衛星測位技術の概要 登壇者: チャリチャリ株式会社 VP of Engineering 蛭田慎也さん GPSは当たり前に利用されていますが、都市環境では電波反射や視界不良、衛星数の偏りなど多くの実務的課題が存在します。このセッションでは基礎的な衛星測位技術を理解し、精度向上の可能性と対策を探りました。 ポイント: 環境依存課題: ビル街でのマルチパス、トンネル下での衛星ロストなど、ロケーションごとの特殊要件が精度を左右。 マルチGNSS活用: GPS単独でなくGLONASS、Galileo、BeiDou、みちびき(QZSS)など複数システムを組み合わせて精度底上げ。 ハイブリッド手法: 加速度・ジャイロセンサ、Wi-Fi/Bluetoothビーコン、マップマッチングなど補完技術で精度改善。 基礎知識が指針に: こうした理解が将来のプロダクトデザインや品質保証、データ分析を行う際の指針となる。 4. 後処理で位置情報を補正する技術を試してみた(仮) 登壇者: 株式会社Luup IoTチーム 高原健輔さん リアルタイムでの高精度測位が困難な場合、後から精度を引き上げる「PPK(Post-Processing Kinematic)」という選択肢があります。高価なRTK装置や特別な通信インフラを用いず、取得済みデータと基準局データを組み合わせて後処理する手法です。 ポイント: PPKのメリット: リアルタイムにこだわらず、後日精度向上が可能。初期投資を抑えながらセンチメートル級精度を最終的に実現。 コスト効率と拡張性: 将来的に需要が増すシナリオで後から精度改善を行える柔軟性。配送ロボット、ドローン、シェアモビリティなどで有効。 応用範囲: 地図整備、走行ログ高度化、インフラ検査など、事後分析が中心の領域で大きな価値を発揮。 https://speakerdeck.com/kensuketakahara/hou-chu-li-dewei-zhi-qing-bao-wobu-zheng-suruji-shu-woshi-sitemita 5. オンデマンドバスサービス導入前のシミュレーションロジックの構築(仮) 登壇者: トヨタコネクティッド株式会社 先行企画部 新技術開発室 Halufy(ハルフィ)さん オンデマンド交通は柔軟性が魅力ですが、収益性や持続可能性を確保するのは容易ではありません。このセッションでは事前シミュレーションによる精緻な需要予測や運行計画設計が紹介されました。 ポイント: 持続可能なモデル構築: 補助金頼みにならずに最適なステーション配置、運行台数、時間帯設定をデータで検証。 戦略的データ活用: 位置情報を中心にODデータや予約希望を統合し、需要予測や価格戦略、ルート最適化を試行。 長期的ビジョン: 他のモビリティ手段やインフラと連携し、都市全体の交通効率化や利便性向上を目指す土台となる。 今後扱いたいトピックとMobility Nightの展望 今回のMobility Night #1は、GPS・位置情報に特化することで、モビリティ業界の「現在地把握」技術に深く切り込みました。参加者からは「位置情報という身近なテーマがこんなに奥深いとは」「基礎から先端活用まで通して聞けるのは貴重」という声が多数寄せられています。 しかし、モビリティ業界にはGPS・位置情報以外にも多くのテーマが存在します。今後は、 IoTデバイス活用: センサーからのリアルタイムデータ収集・制御 データ分析: 需要予測や高度なオペレーション最適化 プロダクトデザイン: UX向上やユーザー満足度最大化 品質保証: 信頼性確保や安全基準遵守 といった領域も掘り下げ、業界全体のイノベーションを促す場にしていきたいと考えています。 Mobility Nightは、運営メンバーが企画するだけでなく、参加者からの登壇希望やテーマ提案も歓迎しています。Discordを通じて意見交換や共催募集が可能な環境を整え、誰もが関わりやすいコミュニティを目指します。 https://discord.com/invite/nn7QW5pn8B まとめ 「Mobility Night #1」では、GPS・位置情報技術を軸に、モビリティサービスの中核をなす技術的課題と、その克服による新たな価値創造の可能性が明確になりました。静的な地図を動的な情報基盤へアップデートする試み、環境に左右されるGPS精度を高度な手法で補正する取り組み、オンデマンド交通をデータ駆動型で計画する戦略など、多様なアプローチが交錯しました。 これらの知見は、今後のMobility Nightで扱うIoT、データ分析、プロダクトデザイン、品質保証などのテーマとも結びつき、業界全体の進歩を加速させるはずです。引き続きMobility Nightにご注目いただき、ともに学び、交流し、新たな価値を創造していきましょう!
アバター
Hello everyone, this is Martin from the Mobile Development Group here at KINTO Technologies! With this guide I hope to give you a quick overview on how to build your TFLite (TensorFlow Lite) models from scratch so let's dive straight into it. This article is the entry for December 9th in the KINTO Technologies Advent Calendar 2024 🎅🎄 Preparation There are basically two ways to prepare your dataset. One is to do the annotation process locally and the other is to annotate your dataset online whilst collaborating and sharing the initial workload better with your team members. This guide tries to emphasize the use of Roboflow ( https://roboflow.com/ ). Roboflow's model export functionality allows you to export trained models in various formats, making it easy to deploy them in your own applications or further fine-tune them. In our case we want to train TFlite models so we would want to export to the TFRecord format as shown in the image below. However, in case you are not using any third party online annotation tools such as Roboflow to annotate your images online and you want to annotate locally, you could try out the free Python library labelImg: https://github.com/HumanSignal/labelImg In general, either locally or online, first we need to collect the dataset of images and label them to get the corresponding bounding box classification meta data (xml) files. (in our case creating the VOC [Visual Object Classes] Pascal meta data) more information about Pascal VOC can be found here: https://roboflow.com/formats/pascal-voc-xml After creating a Google Cloud Platform standard runtime instance you will need to connect it to your Colab notebook. In essence, Google Colab provides a secure, scalable, and collaborative platform for data science and machine learning teams within organizations. Once that is done we first need to import the necessary libraries to get Tensorflow going (Step 1) Creation of GCP standard instance: Creation of Colab Enterprise ( https://cloud.google.com/colab/docs ) notebook: Connect your Google Cloud bucket (Step 4) Execution Install the TensorFlow Object Detection API (Step 5 in this guide) Generate the TFRecord files required for training. (need generate_tfrecord.py script to produce csv files for this) Edit the model pipeline config file and download the pre-trained model checkpoint Train and evaluate the model Export and convert the model into TFlite(TensorFlow Lite) format Deployment Deploy the TFlite model on Android / iOS / IoT devices So now get's started Here are the steps that you should undergo within your Colab Enterprise notebook in detail: 1) Import Libraries !pip install tensorflow==2.13.0 import os import glob import xml.etree.ElementTree as ET import pandas as pd import tensorflow as tf print(tf.__version__) * 2) Create customTF2 , training and data folders in your GCP cloud storage bucket (Necessary only the first time) * Create a folder named customTF2 in your GCP cloud storage bucket Create two sub-folders called training and data inside the customTF2 folder (The training folder is where the checkpoints will be saved during training) Creation of folder structure in your GCS bucket: 3) Download, save and upload the following as generate_tfrecord.py file to the customTF2 folder to your bucket. (Necessary only for the first time) from __future__ import division from __future__ import print_function from __future__ import absolute_import import os import io import pandas as pd import tensorflow as tf import argparse from PIL import Image from tqdm import tqdm from object_detection.utils import dataset_util from collections import namedtuple, OrderedDict def __split(df, group): data = namedtuple('data', ['filename', 'object']) gb = df.groupby(group) return [data(filename, gb.get_group(x)) for filename, x in zip(gb.groups.keys(), gb.groups)] def create_tf_example(group, path, class_dict): with tf.io.gfile.GFile(os.path.join(path, '{}'.format(group.filename)), 'rb') as fid: encoded_jpg = fid.read() encoded_jpg_io = io.BytesIO(encoded_jpg) image = Image.open(encoded_jpg_io) width, height = image.size filename = group.filename.encode('utf8') image_format = b'jpg' xmins = [] xmaxs = [] ymins = [] ymaxs = [] classes_text = [] classes = [] for index, row in group.object.iterrows(): if set(['xmin_rel', 'xmax_rel', 'ymin_rel', 'ymax_rel']).issubset(set(row.index)): xmin = row['xmin_rel'] xmax = row['xmax_rel'] ymin = row['ymin_rel'] ymax = row['ymax_rel'] elif set(['xmin', 'xmax', 'ymin', 'ymax']).issubset(set(row.index)): xmin = row['xmin'] / width xmax = row['xmax'] / width ymin = row['ymin'] / height ymax = row['ymax'] / height xmins.append(xmin) xmaxs.append(xmax) ymins.append(ymin) ymaxs.append(ymax) classes_text.append(str(row['class']).encode('utf8')) classes.append(class_dict[str(row['class'])]) tf_example = tf.train.Example(features=tf.train.Features( feature={ 'image/height': dataset_util.int64_feature(height), 'image/width': dataset_util.int64_feature(width), 'image/filename': dataset_util.bytes_feature(filename), 'image/source_id': dataset_util.bytes_feature(filename), 'image/encoded': dataset_util.bytes_feature(encoded_jpg), 'image/format': dataset_util.bytes_feature(image_format), 'image/object/bbox/xmin': dataset_util.float_list_feature(xmins), 'image/object/bbox/xmax': dataset_util.float_list_feature(xmaxs), 'image/object/bbox/ymin': dataset_util.float_list_feature(ymins), 'image/object/bbox/ymax': dataset_util.float_list_feature(ymaxs), 'image/object/class/text': dataset_util.bytes_list_feature(classes_text), 'image/object/class/label': dataset_util.int64_list_feature(classes), })) return tf_example def class_dict_from_pbtxt(pbtxt_path): # open file, strip \n, trim lines and keep only # lines beginning with id or display_name with open(pbtxt_path, 'r', encoding='utf-8-sig') as f: data = f.readlines() name_key = None if any('display_name:' in s for s in data): name_key = 'display_name:' elif any('name:' in s for s in data): name_key = 'name:' if name_key is None: raise ValueError( "label map does not have class names, provided by values with the 'display_name' or 'name' keys in the contents of the file" ) data = [l.rstrip('\n').strip() for l in data if 'id:' in l or name_key in l] ids = [int(l.replace('id:', '')) for l in data if l.startswith('id')] names = [ l.replace(name_key, '').replace('"', '').replace("'", '').strip() for l in data if l.startswith(name_key)] # join ids and display_names into a single dictionary class_dict = {} for i in range(len(ids)): class_dict[names[i]] = ids[i] return class_dict if __name__ == '__main__': parser = argparse.ArgumentParser( description='Create a TFRecord file for use with the TensorFlow Object Detection API.', formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('csv_input', metavar='csv_input', type=str, help='Path to the CSV input') parser.add_argument('pbtxt_input', metavar='pbtxt_input', type=str, help='Path to a pbtxt file containing class ids and display names') parser.add_argument('image_dir', metavar='image_dir', type=str, help='Path to the directory containing all images') parser.add_argument('output_path', metavar='output_path', type=str, help='Path to output TFRecord') args = parser.parse_args() class_dict = class_dict_from_pbtxt(args.pbtxt_input) writer = tf.compat.v1.python_io.TFRecordWriter(args.output_path) path = os.path.join(args.image_dir) examples = pd.read_csv(args.csv_input) grouped = __split(examples, 'filename') for group in tqdm(grouped, desc='groups'): tf_example = create_tf_example(group, path, class_dict) writer.write(tf_example.SerializeToString()) writer.close() output_path = os.path.join(os.getcwd(), args.output_path) print('Successfully created the TFRecords: {}'.format(output_path)) 4) Mount your GCS bucket, install GCSFUSE and link your folder from google.colab import auth auth.authenticate_user() !echo "deb https://packages.cloud.google.com/apt gcsfuse-bionic main" > /etc/apt/sources.list.d/gcsfuse.list !curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key add - !apt -qq update !apt -qq install gcsfuse !gsutil ls -r gs://your-cloud-storage-bucket-name !mkdir customTF2 !gcsfuse --implicit-dirs your-cloud-storage-bucket-name customTF2 5) Clone the tensorflow models git repository & Install TensorFlow Object Detection API %cd /content # clone the tensorflow models on the colab cloud vm !git clone --q https://github.com/tensorflow/models.git #navigate to /models/research folder to compile protos %cd models/research # Compile protos. !protoc object_detection/protos/*.proto --python_out=. # Install TensorFlow Object Detection API. !cp object_detection/packages/tf2/setup.py . !python -m pip install . 6) Test the model builder (Suggested) %cd /content/models/research # testing the model builder !pip install 'tf-models-official >=2.5.1, <2.16.0' !python object_detection/builders/model_builder_tf2_test.py 7) Download pre-trained model checkpoint (Necessary only for the first time) Current working directory is /content/customTF2/customTF2/data/ Download ssd_mobilenet_v2_fpnlite_320x320_coco17_tpu-8.tar.gz into the data folder & unzip it. A list of detection checkpoints for other tensorflow 2.x can be found here . %cd /content/customTF2/customTF2/data/ #Download the pre-trained model ssd_mobilenet_v2_fpnlite_320x320_coco17_tpu-8.tar.gz into the data folder & unzip it. !wget http://download.tensorflow.org/models/object_detection/tf2/20200711/ssd_mobilenet_v2_fpnlite_320x320_coco17_tpu-8.tar.gz !tar -xzvf ssd_mobilenet_v2_fpnlite_320x320_coco17_tpu-8.tar.gz 8) Get the model pipeline config file, make changes to it and put it inside the data folder (Necessary every time and when you change the amount of class numbers) Current working directory is /content/customTF2/customTF2/data/ Download ssd_mobilenet_v2_fpnlite_320x320_coco17_tpu-8.config from /content/models/research/object_detection/configs/tf2 . Make the required changes to it and upload it to the /content/customTF2/customTF2/data/ folder. OR Edit the config file from /content/models/research/object_detection/configs/tf2 in colab and copy the edited config file to the /content/customTF2/customTF2/data folder. You can also find the pipeline config file inside the model checkpoint folder we just downloaded in the previous step. You need to make the following changes: change num_classes to the number of your classes change test.record path, train.record path & labelmap path to the paths where you have created these files (paths should be relative to your current working directory while training) change fine_tune_checkpoint to the path of the directory where the downloaded checkpoint from step 12 is change fine_tune_checkpoint_type with value classification or detection depending on your classification type change batch_size to any multiple of 8 depending upon the capability of your GPU (eg:- 24,128,...,512) - usually 24 for smaller datasets and 32 for larger datasets works well with a standard colab enterprise instance change num_steps to number of steps you want the detector to train. #copy the edited config file from the configs/tf2 directory to the data/ folder in your GCP storage !cp /content/models/research/object_detection/configs/tf2/ssd_mobilenet_v2_fpnlite_320x320_coco17_tpu-8.config /content/customTF2/customTF2/data In the next step we want to make use of the official TensorBoard tool to visualize our runs and graphs to inspect learning and classification loss over time. More information on how to read graphs and how to use the tool can be found here: https://www.tensorflow.org/tensorboard/get_started#:~:text=TensorBoard%20is%20a%20tool%20for,during%20the%20machine%20learning%20workflow . 9) Load TensorBoard (Recommended) # cload tensorboard %cd /content/customTF2/customTF2/training # !pip install tensorboard # tensorboard --inspect --logdir /content/customTF2/customTF2/training # !gcloud init # !gcloud auth application-default login %reload_ext tensorboard %tensorboard --logdir '/content/customTF2/customTF2/training' 10) Train the model Navigate to the object_detection folder in colab vm %cd /content/models/research/object_detection 10 (a) Training using model_main_tf2.py (Suggested method) Here {PIPELINE_CONFIG_PATH} points to the pipeline config and {MODEL_DIR} points to the directory in which training checkpoints and events will be written. For best results, you should stop the training when the loss is less than 0.1 if possible, else train the model until the loss does not show any significant change for a while. The ideal loss should be below 0.05 (Try to get the loss as low as possible without overfitting the model. Don’t go too high on training steps to try and lower the loss if the model has already converged viz. if it does not reduce loss significantly any further and takes a while to go down. ) !pip install tensorflow==2.13.0 # Run the command below from the content/models/research/object_detection directory """ PIPELINE_CONFIG_PATH=path/to/pipeline.config MODEL_DIR=path to training checkpoints directory NUM_TRAIN_STEPS=50000 SAMPLE_1_OF_N_EVAL_EXAMPLES=1 python model_main_tf2.py -- \ --model_dir=$MODEL_DIR --num_train_steps=$NUM_TRAIN_STEPS \ --sample_1_of_n_eval_examples=$SAMPLE_1_OF_N_EVAL_EXAMPLES \ --pipeline_config_path=$PIPELINE_CONFIG_PATH \ --alsologtostderr """ !python model_main_tf2.py --pipeline_config_path=/content/customTF2/customTF2/data/ssd_mobilenet_v2_fpnlite_320x320_coco17_tpu-8.config --model_dir=/content/customTF2/customTF2/training --alsologtostderr 10 (b) Evaluation using model_main_tf2.py (Optional, just if you want more customization) You can run this in parallel by opening another colab notebook and running this command simultaneously along with the training command above (don't forget to mount your gcp storage, clone the TF git repo and install the TF2 object detection API there as well). This will give you validation loss, mAP, etc so you have a better idea of how your model is performing. Here {CHECKPOINT_DIR} points to the directory with checkpoints produced by the training job. Evaluation events are written to {MODEL_DIR/eval} . # Run the command below from the content/models/research/object_detection directory """ PIPELINE_CONFIG_PATH=path/to/pipeline.config MODEL_DIR=path to training checkpoints directory CHECKPOINT_DIR=${MODEL_DIR} NUM_TRAIN_STEPS=50000 SAMPLE_1_OF_N_EVAL_EXAMPLES=1 python model_main_tf2.py -- \ --model_dir=$MODEL_DIR --num_train_steps=$NUM_TRAIN_STEPS \ --checkpoint_dir=${CHECKPOINT_DIR} \ --sample_1_of_n_eval_examples=$SAMPLE_1_OF_N_EVAL_EXAMPLES \ --pipeline_config_path=$PIPELINE_CONFIG_PATH \ --alsologtostderr """ !python model_main_tf2.py --pipeline_config_path=/content/customTF2/customTF2/data/ssd_mobilenet_v2_fpnlite_320x320_coco17_tpu-8.config --model_dir=/content/customTF2/customTF2/training/ --checkpoint_dir=/content/customTF2/customTF2/training/ --alsologtostderr Retraining your model (In case you get disconnected) If you get disconnected or lose your session on colab vm, you can start your training where you left off as the checkpoint is saved on your cloud storage inside the training folder. To restart the training simply run steps 1, 4, 5, 6, 9 and 10. Note that since we have all the files required for training like the record files, our edited pipeline config file, the label_map file and the model checkpoint folder, we do not need to create these again. The model_main_tf2.py script saves the checkpoint every 1000 steps. The training automatically restarts from the last saved checkpoint itself. However, if you see that it doesn't restart training from the last checkpoint you can make 1 change in the pipeline config file. Change fine_tune_checkpoint to where your latest trained checkpoints have been written and have it point to the latest checkpoint as shown below: fine_tune_checkpoint: "/content/customTF2/customTF2/training/ckpt-X" (where ckpt-X is the latest checkpoint) 11) Test your trained model Export inference graph Current working directory is /content/models/research/object_detection %cd /content/models/research/object_detection !pip install tensorflow==2.13.0 ##Export inference graph !python exporter_main_v2.py --trained_checkpoint_dir=/content/customTF2/customTF2/training --pipeline_config_path=/content/customTF2/customTF2/data/ssd_mobilenet_v2_fpnlite_320x320_coco17_tpu-8.config --output_directory /content/customTF2/customTF2/data/inference_graph Test your trained Object Detection model on images (Provide test image of your liking and adjust image_path) Current working directory is /content/models/research/object_detection %cd /content/models/research/object_detection # Different font-type for labels text.(This step is optional) !wget https://www.freefontspro.com/d/14454/arial.zip !unzip arial.zip -d . %cd utils/ !sed -i "s/font = ImageFont.truetype('arial.ttf', 24)/font = ImageFont.truetype('arial.ttf', 50)/" visualization_utils.py %cd .. %cd /content/models/research/object_detection !pip install tensorflow=="2.12.0" #Loading the saved_model import tensorflow as tf import time import numpy as np import warnings warnings.filterwarnings('ignore') from PIL import Image from google.colab.patches import cv2_imshow from object_detection.utils import label_map_util from object_detection.utils import visualization_utils as viz_utils IMAGE_SIZE = (12, 8) # Output display size as you want import matplotlib.pyplot as plt PATH_TO_SAVED_MODEL="/content/customTF2/customTF2/data/inference_graph/saved_model" print('Loading model...', end='') # Load saved model and build the detection function detect_fn=tf.saved_model.load(PATH_TO_SAVED_MODEL) print('Done!') #Loading the label_map category_index=label_map_util.create_category_index_from_labelmap("/content/customTF2/customTF2/data/label_map.pbtxt",use_display_name=True) def load_image_into_numpy_array(path): return np.array(Image.open(path)) # Replace with your test image image_path = "/content/customTF2/customTF2/data/images/your_test.jpg" #print('Running inference for {}... '.format(image_path), end='') image_np = load_image_into_numpy_array(image_path) # The input needs to be a tensor, convert it using `tf.convert_to_tensor`. input_tensor = tf.convert_to_tensor(image_np) # The model expects a batch of images, so add an axis with `tf.newaxis`. input_tensor = input_tensor[tf.newaxis, ...] detections = detect_fn(input_tensor) # All outputs are batches tensors. # Convert to numpy arrays, and take index [0] to remove the batch dimension. # We're only interested in the first num_detections. num_detections = int(detections.pop('num_detections')) detections = {key: value[0, :num_detections].numpy() for key, value in detections.items()} detections['num_detections'] = num_detections # detection_classes should be ints. detections['detection_classes'] = detections['detection_classes'].astype(np.int64) image_np_with_detections = image_np.copy() viz_utils.visualize_boxes_and_labels_on_image_array( image_np_with_detections, detections['detection_boxes'], detections['detection_classes'], detections['detection_scores'], category_index, use_normalized_coordinates=True, max_boxes_to_draw=200, min_score_thresh=.8, # Adjust this value to set the minimum probability boxes to be classified as True agnostic_mode=False) %matplotlib inline plt.figure(figsize=IMAGE_SIZE, dpi=200) plt.axis("off") plt.imshow(image_np_with_detections) plt.show() Converting trained SSD (Single Shot Detector) model to TFLite model 12) Install tf-nightly TFLite converter works better with tf-nightly. %cd /content/models/research/object_detection !pip install tensorflow=="2.12.0" !pip install numpy==1.26.4 !pip install tf-nightly 13) Export SSD TFLite graph Current working directory is /content/models/research/object_detection # !pip3 uninstall keras # !pip3 install keras==2.14.0 !pip3 install --upgrade tensorflow keras !pip3 install tensorflow=="2.12.0" # !pip3 install --upgrade tensorflow keras # !pip3 install tensorflow=="2.13.1" # !pip3 install numpy --upgrade # !pip3 uninstall numpy # !pip3 install numpy=="1.22.0" # !pip3 install tensorflow --upgrade #!python --version %cd /content/models/research/object_detection !python export_tflite_graph_tf2.py --pipeline_config_path /content/customTF2/customTF2/data/ssd_mobilenet_v2_fpnlite_320x320_coco17_tpu-8.config --trained_checkpoint_dir /content/customTF2/customTF2/training --output_directory /content/customTF2/customTF2/data/tflite 14) Convert TF saved model to TFLite model Current working directory is /mydrive/customTF2/data/ %cd /content/customTF2/customTF2/data/ Check input and output tensor names !saved_model_cli show --dir /content/customTF2/customTF2/data/tflite/saved_model --tag_set serve --all Converting to TFlite: Use either Method (a) or Method (b). METHOD (a) Using command-line tool tflite_convert - (Basic model conversion) # The default inference type is Floating-point. %cd /content/customTF2/customTF2/data/ !tflite_convert --saved_model_dir=tflite/saved_model --output_file=tflite/detect.tflite METHOD (b) Using Python API - (For advanced model conversion with optimizations etc) %cd /mydrive/customTF2/data/ #'''******************************** # FOR FLOATING-POINT INFERENCE #*********************************''' #import tensorflow as tf saved_model_dir = '/content/customTF2/customTF2/data/tflite/saved_model' #converter = tf.lite.TFLiteConverter.from_saved_model(saved_model_dir) #tflite_model = converter.convert() #open("/content/customTF2/customTF2/data/tflite/detect.tflite", "wb").write(tflite_model) #'''************************************************** # FOR FLOATING-POINT INFERENCE WITH OPTIMIZATIONS #***************************************************''' import tensorflow as tf converter = tf.lite.TFLiteConverter.from_saved_model(saved_model_dir,signature_keys=['serving_default']) converter.optimizations = [tf.lite.Optimize.DEFAULT] converter.experimental_new_converter = True converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS, tf.lite.OpsSet.SELECT_TF_OPS] tflite_model = converter.convert() with tf.io.gfile.GFile('/mydrive/customTF2/data/tflite/detect.tflite', 'wb') as f: f.write(tflite_model) #'''********************************** # FOR DYNAMIC RANGE QUANTIZATION #************************************* # The model is now a bit smaller with quantized weights, but other variable data is still in float format.''' # import tensorflow as tf # converter = tf.lite.TFLiteConverter.from_saved_model('/content/customTF2/customTF2/data/tflite/saved_model',signature_keys=['serving_default']) # converter.optimizations = [tf.lite.Optimize.DEFAULT] # tflite_quant_model = converter.convert() # with tf.io.gfile.GFile('/content/customTF2/customTF2/data/tflite/detect.tflite', 'wb') as f: # f.write(tflite_quant_model) # '''*********************************************************************** # FOR INTEGER WITH FLOAT FALLBACK QUANTIZATION WITH DEFAULT OPTMIZATIONS # ************************************************************************** # Now all weights and variable data are quantized, and the model is significantly smaller compared to the original TensorFlow Lite model. # However, to maintain compatibility with applications that traditionally use float model input and output tensors, # the TensorFlow Lite Converter leaves the model input and output tensors in float''' # import tensorflow as tf # import numpy as np # saved_model_dir = '/content/customTF2/customTF2/data/tflite/saved_model' # def representative_dataset(): # for _ in range(100): # data = np.random.rand(1, 320, 320, 3) # yield [data.astype(np.float32)] # converter = tf.lite.TFLiteConverter.from_saved_model(saved_model_dir) # converter.optimizations = [tf.lite.Optimize.DEFAULT] # converter.representative_dataset = representative_dataset # tflite_quant_model = converter.convert() # with open('/content/customTF2/customTF2/data/tflite/detect.tflite', 'wb') as f: # f.write(tflite_quant_model) # '''********************************* # FOR FULL INTEGER QUANTIZATION # ************************************ # The internal quantization remains the same as previous float fallback quantization method, # but you can see the input and output tensors here are also now integer format''' # import tensorflow as tf # import numpy as np # saved_model_dir = '/content/customTF2/customTF2/data/tflite/saved_model' # def representative_dataset(): # for _ in range(100): # data = np.random.rand(1, 320, 320, 3) # yield [data.astype(np.float32)] # converter = tf.lite.TFLiteConverter.from_saved_model(saved_model_dir) # converter.optimizations = [tf.lite.Optimize.DEFAULT] # converter.representative_dataset = representative_dataset # converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] # converter.inference_input_type = tf.uint8 # converter.inference_output_type = tf.uint8 # tflite_quant_model_full_int = converter.convert() # with open('/content/customTF2/customTF2/data/tflite/detect.tflite', 'wb') as f: # f.write(tflite_quant_model_full_int) Read more about post-training quantization here . You can also read about these in this colab notebook. 15) Create TFLite metadata !pip install tflite_support_nightly %cd /content/customTF2/customTF2/data/ %cd tflite/ !mkdir tflite_with_metadata %cd .. Create a labelmap.txt file with the names of the classes written in each line inside the data folder. Finally run the following cell to create the detect.tflite model with metadata attached to it. Current working directory is /content/customTF2/customTF2/data/ %cd /content/customTF2/customTF2/data/ !pip uninstall tensorflow !pip install tensorflow=="2.13.1" # Attach Metadata to TFLite from tflite_support.metadata_writers import object_detector from tflite_support.metadata_writers import writer_utils import flatbuffers import platform from tensorflow_lite_support.metadata import metadata_schema_py_generated from tensorflow_lite_support.metadata import schema_py_generated from tensorflow_lite_support.metadata.python import metadata from tensorflow_lite_support.metadata.python import metadata_writers import flatbuffers import os from tensorflow_lite_support.metadata import metadata_schema_py_generated as _metadata_fb from tensorflow_lite_support.metadata.python import metadata as _metadata from tensorflow_lite_support.metadata.python.metadata_writers import metadata_info from tensorflow_lite_support.metadata.python.metadata_writers import metadata_writer from tensorflow_lite_support.metadata.python.metadata_writers import writer_utils ObjectDetectorWriter = object_detector.MetadataWriter _MODEL_PATH = "/content/customTF2/customTF2/data/tflite/detect.tflite" _LABEL_FILE = "/content/customTF2/customTF2/data/labelmap.txt" _SAVE_TO_PATH = "/content/customTF2/customTF2/data/tflite/tflite_with_metadata/detect.tflite" writer = ObjectDetectorWriter.create_for_inference( writer_utils.load_file(_MODEL_PATH), [127.5], [127.5], [_LABEL_FILE]) writer_utils.save_file(writer.populate(), _SAVE_TO_PATH) # Verify the populated metadata and associated files. displayer = metadata.MetadataDisplayer.with_model_file(_SAVE_TO_PATH) print("Metadata populated:") print(displayer.get_metadata_json()) print("Associated file(s) populated:") print(displayer.get_packed_associated_file_list()) model_meta = _metadata_fb.ModelMetadataT() model_meta.name = "SSD_Detector" model_meta.description = ( "Identify which of a known set of objects might be present and provide " "information about their positions within the given image or a video " "stream.") # Creates input info. input_meta = _metadata_fb.TensorMetadataT() input_meta.name = "image" input_meta.content = _metadata_fb.ContentT() input_meta.content.contentProperties = _metadata_fb.ImagePropertiesT() input_meta.content.contentProperties.colorSpace = ( _metadata_fb.ColorSpaceType.RGB) input_meta.content.contentPropertiesType = ( _metadata_fb.ContentProperties.ImageProperties) input_normalization = _metadata_fb.ProcessUnitT() input_normalization.optionsType = ( _metadata_fb.ProcessUnitOptions.NormalizationOptions) input_normalization.options = _metadata_fb.NormalizationOptionsT() input_normalization.options.mean = [127.5] input_normalization.options.std = [127.5] input_meta.processUnits = [input_normalization] input_stats = _metadata_fb.StatsT() input_stats.max = [255] input_stats.min = [0] input_meta.stats = input_stats # Creates outputs info. output_location_meta = _metadata_fb.TensorMetadataT() output_location_meta.name = "location" output_location_meta.description = "The locations of the detected boxes." output_location_meta.content = _metadata_fb.ContentT() output_location_meta.content.contentPropertiesType = ( _metadata_fb.ContentProperties.BoundingBoxProperties) output_location_meta.content.contentProperties = ( _metadata_fb.BoundingBoxPropertiesT()) output_location_meta.content.contentProperties.index = [1, 0, 3, 2] output_location_meta.content.contentProperties.type = ( _metadata_fb.BoundingBoxType.BOUNDARIES) output_location_meta.content.contentProperties.coordinateType = ( _metadata_fb.CoordinateType.RATIO) output_location_meta.content.range = _metadata_fb.ValueRangeT() output_location_meta.content.range.min = 2 output_location_meta.content.range.max = 2 output_class_meta = _metadata_fb.TensorMetadataT() output_class_meta.name = "category" output_class_meta.description = "The categories of the detected boxes." output_class_meta.content = _metadata_fb.ContentT() output_class_meta.content.contentPropertiesType = ( _metadata_fb.ContentProperties.FeatureProperties) output_class_meta.content.contentProperties = ( _metadata_fb.FeaturePropertiesT()) output_class_meta.content.range = _metadata_fb.ValueRangeT() output_class_meta.content.range.min = 2 output_class_meta.content.range.max = 2 label_file = _metadata_fb.AssociatedFileT() label_file.name = os.path.basename("labelmap.txt") label_file.description = "Label of objects that this model can recognize." label_file.type = _metadata_fb.AssociatedFileType.TENSOR_VALUE_LABELS output_class_meta.associatedFiles = [label_file] output_score_meta = _metadata_fb.TensorMetadataT() output_score_meta.name = "score" output_score_meta.description = "The scores of the detected boxes." output_score_meta.content = _metadata_fb.ContentT() output_score_meta.content.contentPropertiesType = ( _metadata_fb.ContentProperties.FeatureProperties) output_score_meta.content.contentProperties = ( _metadata_fb.FeaturePropertiesT()) output_score_meta.content.range = _metadata_fb.ValueRangeT() output_score_meta.content.range.min = 2 output_score_meta.content.range.max = 2 output_number_meta = _metadata_fb.TensorMetadataT() output_number_meta.name = "number of detections" output_number_meta.description = "The number of the detected boxes." output_number_meta.content = _metadata_fb.ContentT() output_number_meta.content.contentPropertiesType = ( _metadata_fb.ContentProperties.FeatureProperties) output_number_meta.content.contentProperties = ( _metadata_fb.FeaturePropertiesT()) # Creates subgraph info. group = _metadata_fb.TensorGroupT() group.name = "detection result" group.tensorNames = [ output_location_meta.name, output_class_meta.name, output_score_meta.name ] subgraph = _metadata_fb.SubGraphMetadataT() subgraph.inputTensorMetadata = [input_meta] subgraph.outputTensorMetadata = [ output_location_meta, output_class_meta, output_score_meta, output_number_meta ] subgraph.outputTensorGroups = [group] model_meta.subgraphMetadata = [subgraph] b = flatbuffers.Builder(0) b.Finish( model_meta.Pack(b), _metadata.MetadataPopulator.METADATA_FILE_IDENTIFIER) metadata_buf = b.Output() When asked, proceed with 'Y' 16) Download the TFLite model Congrats, you are done! Final thoughts Google Colab Enterprise is a powerful cloud-based platform for machine learning, making it an ideal environment for building TensorFlow Lite models. After over a year of using this platform, I've found that the most time-consuming part of the process is data preparation and the initial trial-and-error phase. This stage requires significant iteration and testing to identify challenges in recognizing specific parts of the dataset and to address false positives, where images are incorrectly classified. *The Android robot header image was reproduced or modified from work created and shared by Google and used according to terms described in the Creative Commons 3.0 Attribution License.
アバター
この記事は KINTOテクノロジーズアドベントカレンダー2024 の9日目の記事です🎅🎄 弊社KINTOテクノロジーズは、300人を超えるエンジニア中心の組織です。複雑化する事業環境の中で、私たちは常に組織の効率性と創造性のバランスを模索し続けています。拠点の分散やリモートワークの増加により、部門を超えたコミュニケーションは限定的なものとなり、この課題に真剣に向き合う必要がありました。 この記事では日々のコミュニケーションで利用しているSlackを軸にどのように組織を活性化するのか、そしてSlackをベースにタレントサーチを構築してどのようなことを実現しようとしているのか、その取組の第一歩をご紹介すべく技術広報グループの中西が書いています。 タレントサーチとは社員のスキル情報をデータ化して検索できる仕組みのことです なぜタレントサーチが必要だったのか 効率的に業務をこなすことは大切ですが、イノベーションを生み出すためには、偶然の出会いや「無駄」に思える会話が実は重要な役割を果たします。私たちの組織では、日々の業務に集中するあまり、「暗黙知」や「潜在的な可能性」を見落としがちでした。 例えば、「このスキルを持つ人はいないだろうか」と思っても、誰に相談すればいいか分からない。組織の規模が大きくなるにつれ、こうした情報の非対称性は深刻な課題となっていました。そこで私たちは、Slackプロフィールを戦略的に活用し、この課題に正面から取り組むことにしたのです。 Slackプロフィール活用のメリット 技術広報グループの視点 これまで目立たなかった人材の発見 組織内には、その才能や可能性に気づかれていない人材が多く存在します。技術広報グループのメンバーは日々社内の皆さんとコミュニケーションを取っておりますが、それでも全ての社員の皆さんを深く知ることは難しく、Slackプロフィールは、そうした「隠れた人材」を可視化する新しい手段となっていきます。 プロジェクト支援の迅速化 適切なスキルを持つ人材を素早く特定できることで、プロジェクトの立ち上げや課題解決のスピードが劇的に向上することを期待しています。今までは、〇〇というスキルを持っている人居ないですか?などと社内でも口伝てで探し回ったりしていますが我々のような組織のハブとなる組織を介さずにコミュニケーションが取れるネットワークを構築していくことは今後の組織の成長にとってとても重要なことです 部署間のコラボレーション促進 これまで技術広報グループでは、社内の勉強会や交流イベント、社外講師をお招きしての勉強会など、様々な企画を実施し、それまで接点のなかった部署間でのつながりを作り続けることで社内でも自然にコミュニケーションが発生し、一度繋がったところから数珠つなぎにネットワークが構築され、日々新たなコラボレーションが生まれています。今回のSlack施策もそれに拍車をかけていくことでしょう 全社員にとってのメリット キャリア成長の機会拡大 自分のスキルや興味を明確に表現することで、これまで気づかなかった新たな可能性が開かれます。自分では思わぬキーワードでつながることで、草の根で様々な機会が生まれてきます。これは単に業務に限らず、共通の悩みを持つ方々がお互いに学んで成長する機会が生まれてきます。 スキルを持つ同僚への素早いアクセス 具体的には、新入社員が「Next.jsに詳しいフロントエンドエンジニア」を探す際、Slack上ですぐに簡単に検索できるようになり、学習や課題解決における大きな助けとなっていきます。Slack上でメッセージを検索する延長線上に社内のタレントデータベースが構築されて検索できるようになります。 自然な社内交流の活性化 社内には様々な趣味の草の根活動も存在しています。それが趣味と呼べるかどうかは別にして興味領域ごとに、腰の健康に関するチャンネルから簡単に作れるレシピをシェアするチャンネル。各種スポーツやゲーム、もちろん新しい技術に関するチャンネルもありますし、これらに個人がより紐づけやすくなってきて、業務以外でのつながりがあることで、業務で発生した緊急時の対応でもスムーズに執り行えるということもあります。 プロフィール作成をサポートする仕組み 「何を書けばいいか分からない」という声に応えるため、技術広報グループが積極的にサポートしています。このアプローチは、単なる情報収集ではなく、社員一人ひとりの可能性を引き出すための丁寧な対話プロセスです。 弊社はテックブログを開始した当初より社員の皆さんの才能を見つけ出せるようにインタビューを実施させていただいたり、伴走しながら記事の執筆や登壇資料の作成、イベント企画や運営、勉強会の運営サポートなど行っています。今回のプロフィール作成に関しても、この記事を読んでいる社員の方でまだプロフィールを埋めていないという方や、何を書いたらよいかわからないという方はぜひお声がけください。一緒にあなたの魅力を見つけて社内で発信していきましょう! サポート内容: 個別ヒアリングによる経験や興味の引き出し 1対1の対話を通じて、本人も気づいていない潜在的な強みを探ります。 プロフィール作成用のテンプレート 自己表現が苦手な方でも、安心して記入できます。 自己表現が苦手な方への言語化支援 専門スタッフが寄り添いながら、自分の強みや興味を適切に表現する手伝いをします。 テンプレート: 検索結果 今後の展望 現在は手動でのタレントサーチですが、将来的にはAIを活用したスキルマッチングシステムの構築を目指しています。蓄積されたデータを効果的に活用し、より効率的で戦略的な人材活用の実現を視野に入れています。将来的に個々の社員の可能性をさらに深く理解し、最適な機会と結びつけることができるでしょう。 おわりに Slackプロフィールは、単なる自己紹介欄ではありません。それは、人と人とを結びつける組織の潜在能力を引き出すための戦略的なツールであり、一人ひとりの可能性を解放する鍵なのです。 あなたの興味、スキル、可能性を積極的に発信することで、組織全体の可能性を広げることができます。私たちは、この小さな一歩が、やがて大きな変革につながると信じています。
アバター
この記事は KINTOテクノロジーズアドベントカレンダー2024 の9日目の記事です🎅🎄 はじめに こんにちは、DX開発グループでアプリケーションエンジニアを行っている亀山です。 近年、生成AIはさまざまな分野で活用されており、私たちの開発チームでも生成AIを活用したシステムの構築が行われています。 さらに、私たちの開発チーム内で広く使用されているJavaは、既存の知見やツールを活かしながら生成AIのインターフェースを効率的に構築することができると考えました。本記事では、こうした背景を踏まえ、Javaを用いて生成AIを呼び出し、結果を処理する方法について解説します。 今回はその導入編として、Javaコードを用いてAzure OpenAIのライブラリを用いて、呼び出す基本的な実装について、シンプルなコード例を用いてお話したいと思います。Azure OpenAIはOpenAIに比べ、高いスケーラビリティや信頼性に優れ、規模の大きい業務システムとも親和性が高いプラットフォームとされています。 Azure Open AIのセットアップ Azure サブスクリプションに登録します。 https://azure.microsoft.com/en-us/pricing/purchase-options/azure-account?icid=ai-services&azure-portal=true あとは下記ページの説明に従ってエンドポイントとキーを取得します。 https://learn.microsoft.com/ja-jp/azure/ai-services/openai/chatgpt-quickstart?tabs=command-line%2Cjavascript-keyless%2Ctypescript-keyless%2Cpython-new&pivots=programming-language-java 取得先のAzureコンソールは こちら になります。 ※先ほど登録したアカウントでのログインが必要なページになります。 OpenAIライブラリのセットアップ 今回Azure OpenAIをコールするにあたり、AzureのSDKライブラリを使用します。 Azure OpenAIではこのSDKライブラリによって簡単に生成AIを呼び出すためのコーディングを行うことができます。 Gradleの場合 dependencies { implementation 'com.azure:azure-ai-openai:1.0.0-beta.12' implementation 'com.azure:azure-core-http-okhttp:1.7.8' } Mavenの場合 <dependencies> <dependency> <groupId>com.azure</groupId> <artifactId>azure-ai-openai</artifactId> <version>1.0.0-beta.12</version> </dependency> <dependency> <groupId>com.azure</groupId> <artifactId>azure-core-http-okhttp</artifactId> <version>1.7.8</version> </dependency> </dependencies> 使用バージョンは執筆時点のものであるため、最新バージョンついては公式ドキュメントでご確認ください。 特に今回使用するAzure OpenAI client library for Javaは現在ベータ版であるため、今後安定版がリリースされた場合にはそちらを使用していただくことをおすすめします。 実際にAzure OpenAIのチャットモデルを呼び出してみる 参考: https://github.com/Azure/azure-sdk-for-java/tree/main/sdk/openai/azure-ai-openai /src/main/resource/config.properties endpoint=https://{リソース名}.openai.azure.com/ apiKey={APIキー} 取得したAzure OpenAIのエンドポイントとAPIキーを別ファイルで管理するためこちらに入力してください。 シークレット情報の管理方法はご自身またはチームの方針に合わせていただいても大丈夫です。 /src/main/java/com/sample/app/Main.java package com.sample.app; // ご自身のパッケージ名に合わせてください import com.azure.ai.openai.OpenAIClient; import com.azure.ai.openai.OpenAIClientBuilder; import com.azure.ai.openai.models.*; import com.azure.core.credential.AzureKeyCredential; import java.util.ArrayList; import java.util.List; public class Main { public static void main(String[] args) { // プロパティファイルを読み込む ※別の方法でキー情報を管理する場合は適宜変更してください Properties properties = new Properties(); try (InputStream input = Main.class.getClassLoader().getResourceAsStream("config.properties")) { if (input == null) { System.out.println("config.properties ファイルが見つかりません。"); return; } properties.load(input); } catch (IOException ex) { System.out.println(ex.getMessage()); return; } // プロパティから設定値を取得 String endpoint = properties.getProperty("endpoint"); String apiKey = properties.getProperty("apiKey"); // OpenAIクライアントを作成 var client = new OpenAIClientBuilder() .endpoint(endpoint) .credential(new AzureKeyCredential(apiKey)) .buildClient(); // プロンプトを準備 List<ChatRequestMessage> messages = new ArrayList<>() .setTemperature(0.7) // 応答のランダム性、高いほど多様(0.0~2.0) .setMaxTokens(100) // 応答の最大トークン数 .setFrequencyPenalty(0.0) // 頻出する単語に対するペナルティ(-2.0~2.0) .setPresencePenalty(0.6); // 既存のトピックに関連させるものに対するペナルティ(-2.0~2.0) messages.add(new ChatRequestSystemMessage("あなたは優秀なAIアシスタントです。")); messages.add(new ChatRequestUserMessage("初心者向けに、Javaのクラスとオブジェクトの違いを説明してください。")); // リクエストオプションを設定 var options = new ChatCompletionsOptions(messages); var chatCompletions = client.getChatCompletions("gpt-4o", options); // 使用するデプロイ名または生成AIモデル名を指定してください // リクエストを送信して結果を取得 for (ChatChoice choice : chatCompletions.getChoices()) { ChatResponseMessage message = choice.getMessage(); System.out.printf("Index: %d, Chat Role: %s.%n", choice.getIndex(), message.getRole()); System.out.println("Message:"); System.out.println(message.getContent()); } } } 生成AIの呼び出しにあたり、様々なパラメータを設定することができます。普段使用するChatGPTアプリケーションでは現在設定できないパラメータで、こういったパラメータを調整できるのもプログラムから生成AIを呼び出すメリットの1つです。今回は4つのパラメータ(temperature、maxTokens、frequencyPenalty、presencePenalty)を設定しましたが、他にも様々なパラメータがあります。詳細は こちら を参照してください。 また、messagesの部分で下記2種類のメッセージをセットしました。前者のChatRequestSystemMessageはセットしなくても実行可能です。 ChatRequestSystemMessage 生成AIモデルの振る舞いや役割を設定するためのメッセージで、会話のトーンや応答の仕方を定義します。 ChatRequestUserMessage ユーザーからの具体的な質問や指示をAIに伝えるためのメッセージで、このメッセージに対する回答が返却値としてOpenAIから返されます。 getChatCompletionsの第1引数はデプロイ名またはモデル名を入力します。 デプロイ名はAzureポータルで取得します。Azure以外のOpenAIを使用する場合はモデル名「gpt-4o」「gpt-3.5-turbo」などを入力します(上記の例だとモデル名を入力しています)。 .gitignore /src/main/resources/config.properties 今回ご説明するように/src/main/resource/config.propertiesで管理する場合は.gitignoreに上記1行を追加してください。 特にリポジトリで管理する際はAPIキーなどのシークレット情報の取り扱いにはくれぐれもご注意ください 実行結果 下記のようなレスポンスをOpenAIから得ることができました。(実際にはMarkdown形式の文字列です) Index: 0, Chat Role: assistant. Message: Javaにおけるクラスとオブジェクトの違いは、初めてプログラミングを学ぶ人にとって重要な概念です。以下に分かりやすく説明します。 クラス 設計図 : クラスはオブジェクトを作成するための設計図やテンプレートと考えることができます。クラスにはオブジェクトの属性(フィールド)や動作(メソッド)が定義されています。 宣言 : Javaではクラスを定義するために`class`キーワードを使用します。例えば、車を表すクラスは以下のように定義できます。 public class Car { // フィールド(属性) String color; int year; // メソッド(動作) void drive() { System.out.println("The car is driving"); } } オブジェクト インスタンス : オブジェクトは、クラスから生成された実体(インスタンス)です。オブジェクトは特定のデータを持ち、そのデータに対する操作を行うことができます。 生成 : Javaでは`new`キーワードを使用してクラスからオブジェクトを生成します。例えば、`Car`クラスからオブジェクトを作成する場合は次のようになります。 public class Main { public static void main(String[] args) { // Carクラスのインスタンスを作成 Car myCar = new Car(); myCar.color = "Red"; myCar.year = 2020; // オブジェクトのメソッドを呼び出す myCar.drive(); } } まとめ クラス はオブジェクトを作成するためのテンプレートであり、属性や動作を定義しています。 オブジェクト はそのクラスの実際のインスタンスであり、具体的なデータを持ち、定義された動作を実行できます。 この基本的な関係を理解することで、より複雑なプログラム構築を始めることができます。 終わりに 今回の記事では、JavaでAzure OpenAIを利用する基本的な方法について紹介しました。 JavaによるOpenAIの利用に関する情報はまだ少ないため、本記事が皆様のお役に立てれば幸いです。 次回はより高度な活用方法について解説していきたいと思っておりますので、引き続きよろしくお願いいたします。
アバター
Introduction My name is PannNuWai and I work in the Global Development Group at KINTO Technologies. In Global Development Group test automation team, I build and maintain test automation environments for the product development teams, and write test scripts with the product test team. In my previous company, I was involved in testing, but after joining KINTO Technologies, I had my first experience with automation testing using Appium, which provided me with valuable learning opportunities. I didn’t have any experience in Appium, so I had to start studying from scratch. However, I am now capable of handling everything from initial configuration to designing server architecture. In this automation testing role, I primarily focused on testing smartphone apps and will outline the issues I resolved during the process. In this article, I would like to talk about how to switch to dark mode using Appium version 1.22.3 for automation testing. What is automation testing? Software testing is the process of identifying issues in software to ensure that defective products are not released. In this article, 'automation testing' refers to the use of tools that support and automate the software testing process. Benefits of automation testing [^1] Early fault detection Improve quality while keeping costs down Tests can be performed even with a lack of human resources Tests can be performed more quickly Human error can be eliminated Tests can be performed outside of business hours [^1]: https://products.sint.co.jp/obpm/blog/test_automation What is Appium? It is an open source tool for testing native, web views, and hybrid apps on iOS, Android, and desktop platforms. [^2] [^2]: https://appium.io Appium supports Java, PHP, and Python programming languages, so it is an automation testing tool that testers can easily use while choosing their preferred language. There are three components: Appium Client Appium Server End Device in the architecture of Appium. The mobile device and app details are set up in the Appium Client. The Appium Server uses Node.js language to connect the simulator (iOS) or emulator (Android) while launching the json file. Finally, the end device is managed through the Appium server that has been launched. What is Appium Inspector? Appium Inspector is a standard procedure for uniquely identifying the UI elements of a mobile app. It works on both actual devices or simulators (iOS) or emulators (Android). Note - the Appium Inspector tool is specifically designed to retrieve only native mobile application attributes, so it does not support finding locators in a web browser (Chrome). The Appium desktop application is a combination of the Appium server itself and the Element inspector, designed to detect all visible elements of mobile applications while developing test scripts. [^3] [^3]: https://www.kobiton.com/book/chapter-5-the-appium-inspector-2 What is Dark Mode? Dark mode is a display setting for the user interface of smartphones, laptops, etc. Instead of displaying dark text (dark mode) on a bright screen, light text (light mode) is displayed on a black screen. In addition to the existing dark mode feature on both Android and iOS phones, we often use the dark mode feature in our apps. When testing mobile app automation, testing the dark mode feature was also a key checking point. So, I would like to talk about the dark mode of mobile apps using Appium. Problem There is a problem when using Appium to test dark mode. For example, when testing the login screen to see if the characters of username and password are displayed, the Appium inspector retrieves the location of the element for username and password . Normally, you only need to check that the element is displayed as follows. AssertTrue(driver.findElementByXPath("USER_NAME").isDisplayed()); AssertTrue(driver.findElementByXPath("PASSWORD").isDisplayed()); However, in dark mode, it is not enough to just retrieve the location of the element and check its display. Checking that the screen has changed to black is an important part of dark mode. You need to check the hexadecimal values for the black and white colors. Test Method Now, let’s actually write the source code of the dark mode test case using Appium. changeToDarkTheme Step 1 Retrieve the location (ElementId) of the element from the Appium inspector. Step 2 Use assertElementColorMode(MobileElement elementId, ColorMode colorMode) to confirm if light mode is the Default setting. Step 3 Press the dark mode button. Step 4 Use assertElementColorMode(MobileElement elementId, ColorMode colorMode) to confirm if the Display setting changes to dark mode. public class DisplayChangePage extends Base { public static final String THEME_CELL_ID = "id/theme_parent"; @Test(groups = "DisplayChangePage", dependsOnGroups = "Setting") public void changeToDarkTheme() { driver.manage().timeouts().implicitlyWait(60, TimeUnit.SECONDS); MobileElement themeCell = getDriver().findElementById(THEME_CELL_ID); assertElementColorMode(themeCell, ColorMode.LIGHT); themeCell.click(); driver.manage().timeouts().implicitlyWait(60, TimeUnit.SECONDS); tapElement( findElementByTextContains(ViewType.CHECKED_TEXT, resourceText("darkTheme")) ); themeCell = getDriver().findElementById(THEME_CELL_ID); assertElementColorMode(themeCell, ColorMode.DARK); } } assertElementColorMode Set the ElementId where the Theme cell is located and the ColorMode you want to change as parameters. Step 1 Retrieve evidence of the Element where the Theme cell is located. Use getElementBufferedImage(MobileElement element) to save the evidence as an image file. Step 2 Check that the saved image file does not become null . Step 3 Get the color from the (x = 10, y = 10) image file point and check the color of the dark mode you want to change. public interface AppiumHelpersInterface extends FindElementsInterface { AppiumDriver<MobileElement> getDriver(); Device getDevice(); /** * Get buffered image of mobile element * * @param element Mobile element * @return Buffered image */ default BufferedImage getElementBufferedImage(MobileElement element) { File image = element.getScreenshotAs(OutputType.FILE); try { return ImageIO.read(image); } catch (IOException e) { return null; } } /** * Assert element's color mode * * @param element Mobile Element * @param mode Color mode */ default void assertElementColorMode(MobileElement element, ColorMode mode) { BufferedImage image = getElementBufferedImage(element); Assert.assertNotNull(image); Assert.assertTrue(Utils.getColorString(image, 10, 10).matches(mode.cellRegex())); } } getColorString Change the color of x-point and y-point of the acquired image to hexadecimal and return the array. /** * Get color string from image at point x and y * * @param image BufferedImage * @param x int * @param y int * @return Hexadecimal Color String */ public static String getColorString(BufferedImage image, int x, int y) { int rgba = image.getRGB(x, y); int[] rgb = new int[]{ (rgba >> 16) & 0xff, (rgba >> 8) & 0xff, (rgba) & 0xff }; return String.format("%02x%02x%02x", rgb[0], rgb[1], rgb[2]); } cellRegex Determine the values for dark and light modes. public enum ColorMode { LIGHT, DARK; public String cellRegex() { // 22222 - lighter black if (this == DARK) return "2[(0-9|a-f)]2[(0-9|a-f)]2[(0-9|a-f)]"; // ffffff return "f[(0-9|a-f)]f[(0-9|a-f)]f[(0-9|a-f)]"; } } public interface ColorModeInterface { String darkModeScript(); Map<String, Object> darkModeSettings(); Map<String, Object> lightModeSettings(); default void configureDarkMode(ColorMode mode) { getDriver().executeScript(darkModeScript(), mode == ColorMode.DARK ? darkModeSettings() : lightModeSettings()); } } Caution This case involves using Appium, so only the native app's dark mode feature can be utilized. Summary In this article, I have explained how to switch to dark mode. It can be used on both iOS (version 13 and up) and Android (version 5.0 and up) for dark mode automation testing. This time, I tested the Native App's dark mode feature, but I would also like to explore testing the Web App's dark mode feature in the future Since December, the number of members on the test automation team in Global Development has increased. In the future, I hope to collaborate with team members on automation testing using not only Appium but also other tools like Katalon. Reference DarkMode Appium Architecture
アバター
この記事は KINTOテクノロジーズアドベントカレンダー2024 の8日目の記事です🎅🎄 こんにちは、学びの道の駅チームのHOKAです。 学びの道の駅が発足されてからそろそろ1年が経過するので、ちょっとした振り返りBlogを書いてみます。 一年前に考えていたこと 学びの道の駅チームはちょうど1年前の2023年11月末に、きんちゃん、中西、HOKAの3人が集まって「このたくさんの勉強会をもっと盛り上げていきたい」というところからスタートしました。 そこで、私たちは2024年の年明けすぐに集まり、インセプションデッキを作りました。 その結果がこちら↓ 社内の「勉強会」と「勉強会」が交わる「道の駅」として、勉強会を軸にした社内活性を支援します。 社内広報活動 今度、こういうテーマで勉強会やるよ! 気になるあの勉強会、どんな感じなんだろう? 勉強会の支援 勉強会を始めてみたいけど、どうやって始めると良いのか? 勉強会の運営しているけど、盛り上がらない… などの、お悩み相談 詳しくは「 はじまりのテックブログ 」に記載しております。 はじめの6か月でやったこと まず、月に一度開催されるKTCの全社MTG(通称:本部会)で学びの道の駅の活動を毎月報告しました。 おそらく、下記のスライドは社内でお馴染みになったかと思います。 活動サマリー 勉強会全般の相談​:勉強会を始めたい!相談したい!等「勉強会に関するご相談」を承る場​ 突撃!​となりの勉強会:事務局が「みなさんの勉強会」を見学する活動​ KTC Podcast​:勉強会の運営者にインタビュー!音声で空気感ごと社内にお届け​ TechBlog​:TechBlogで「KTCにこんな勉強会あるよ、参加したよ」を伝えます​ 新たな活動 ーその1ー 6か月を過ぎた頃から、私たちの活動に賛同してくれた方が現れ始めました。 「どんな勉強会があるかを検索できるようにしたい」という課題を抱えていたのですが、モバイルアプリ開発グループのエンジニアがSlackを活用した検索システムを作ってくれました。 これにより、勉強会検索方法は、Slackチャンネルでキャラクターまなびぃにメンションすると、勉強会を見つけられることになったのです。 詳しくは開発者のBlogをご覧ください https://blog.kinto-technologies.com/posts/2024-12-04_manabyi/ 新たな活動 ーその2ー 「勉強会の資料や動画を誰でもいつでも閲覧できる状態にしたい」という課題もありました。 そんな中、コーポレートITグループのエンジニアが自ら名乗り出て、Sharepointを活用した勉強会のポータルサイトを作ってくれました。 その名も「学びの道の駅 Portal」 無味乾燥だったフォルダが、まるでYouTubeのようになりました。 動画や資料がそろったことにより、 「当日参加できなくなってしまった勉強会、資料だけでも見ておこう」 「先日の勉強会を復習したいな、動画を見るか」 といったムーブメントができるようになりました。 その他、うれしかったこと 勉強会を運営中の方から相談の依頼が来るようになったこと。 Podcastの出演依頼をすると、皆さん快諾してくれること。 全社イベントのポスターに「学びの道の駅」という言葉が使われていたこと。 グループ会社の社内向け資料に、KTCを紹介する文脈で「学びの道の駅」が紹介されたこと。 まだ1年も経っていないのに、社内だけでなくグループ会社まで私たちの活動が届くようになるとは、1年前の自分たちでは想像さえしておりませんでした。 ふとした時に、社員の方から「道の駅、めっちゃ良いね!」と言ってもらえるのも大きな励みとなっています。 技術広報グループにジョイン そんな学びの道の駅チームですが、2024年9月から技術広報グループの傘下に入ることになりました。 技術広報グループとは 2022年にKINTOテクノロジーズ TechBlogを立ち上げ、社外に向けたイベントや、社内の勉強会の開催などエンジニアがアウトプットをする場づくりをしてきました。そもそも業務で成果を出すこともアウトプットですが、勉強会やTechBlogもエンジニアにとっては重要なアウトプットの場だと私たちは考えております。技術広報グループでは「業務と業務の間」とも言えるアウトプットの場づくりをしてきました。 2023年末に私たちの始めた「学びの道の駅」は、「業務と業務の間」に必要とされる学びの場=インプットの場を作っており、技術広報グループと非常に近い存在だったのです。 ※そもそも技術広報グループ発起人の中西は、「インプットからアウトプットまで下支えすることでエンジニアの成長を促す」という構想だったので、別々のものではなかったのかもしれません。 今後の学びの道の駅チームは ぜひ中西によるTechBlogをご覧いただければ幸いです。 https://blog.kinto-technologies.com/posts/2024-12-03-the-next-goal/
アバター
この記事は KINTOテクノロジーズアドベントカレンダー2024 の8日目の記事です🎅🎄 はじめに こんにちは。モバイルアプリ開発グループの中本です。 普段は、大阪にいながら東京のメンバーと協力して、 KINTO Unlimitedアプリ のiOS開発をしています。 本記事では、KINTO Unlimitedアプリ(iOS)のアーキテクチャ改善の過程について詳しく説明します。 このアプリのアーキテクチャは、1st → 2nd → 3rdと段階的に進化し、最終的には独自のアーキテクチャへと移行しました。 それぞれの段階における設計や課題について、以下でお話しします。 1st Generation Architecture VIPERアーキテクチャを採用 すべての画面をUIKit + xib/storyboardで実装 Combineを使用してViewを更新 ファーストリリースに向けて納期が短かったこともあり、社内で実績のあったアーキテクチャを採用 1stの設計 flowchart TD id1(ViewController) -- publish --> id2(ViewModel) -- subscribe --> id1 id2 -- request --> id3(Interactor) id1 -- apply view property --> id4(UIView) id1 -- transition --> id5(Router) ViewController ViewModelにイベントを通知 ViewModelからのイベントに基づいてアウトプットを購読 購読結果に応じてViewを更新し、Routerを呼び出して画面遷移を実施 ViewModel Combineを使用してリアクティブに状態を変化 イベントPublisherを変換し、Viewの状態をPublisher経由でアウトプット Interactor APIや内部DBにリクエストを実施 Router 他の画面への遷移処理を実施 UIView コード/xib/storyboardを使用してレイアウト 1stの課題 UIKitを使用したレイアウトは開発コストが高く、特にxib/storyboardを使用した場合は変更が容易ではない → SwiftUIへ移行したい! 2nd Generation Architecture UIKitからSwiftUIへ移行 UIKitによるレイアウトをSwiftUIに置き換え、開発効率を改善 UIHostingControllerを使用してSwiftUIのViewをViewControllerに注入 画面遷移は従来通りUIKitで実施 当時、SwiftUIの画面遷移APIは不安定だったためUIKitのまま SwiftUIへの移行に専念する 一度にたくさん変更すると、機能仕様のデグレが懸念されるため 2ndの設計 flowchart TD id1(ViewController) -- input --> id2(ViewModel) -- output --> id1 id2 -- request --> id6(Interactor) id1 -- mapping --> id3(ScreenModel) -- publish --> id1 id3 -- publish --> id4(View) -- publish --> id3 id1 -- transit --> id5(Router) ViewController HostingControllerInjectableプロトコルを実装し、SwiftUI Viewを追加 ViewModelのアウトプットを購読し、ScreenModel(ObservableObject)に反映 ViewModelのアウトプットやScreenModelのPublisherを購読し、Routerを用いて画面遷移を実施 ScreenModel Viewの状態を保持するObservableObject ViewModel / Interactor / Router 1st Generationと同様の機能 2ndの課題 状態管理がViewModelとScreenModelの両方で行われるため、ロジックが分散し、開発・保守コストが増加 1stからの課題 Combineによるリアクティブな状態変化の実装は保守性に懸念があり、コード量が多く可読性に難がある 1画面につき1つのViewModelであるため、機能の多い画面ではViewModelが巨大化 → CombineやViewModelから脱却したい! 3rd Generation Architecture Combineを用いたViewModelから状態を集中管理するViewStoreを中心としたアーキテクチャへ移行 イベントの結果をAnyPublisherを経ずに直接ObservableObjectに反映できる仕組みを実現 Combineを使用せず、async/awaitを用いてリアクティブな状態変更を実現 状態管理ロジックを機能ごとに分割可能 3rdの設計 flowchart TD subgraph ViewStore id1(ActionHandler) -- update --> id2(State) end id2 -- bind --> id5(View) -- publish action --> id1 id1 -- publish routing --> id3(ViewController) -- publish action --> id1 id3 -- transit --> id4(Router) id1 -- request --> id6(Interactor) ViewStore State Viewの状態を保持するObservableObjectで、SwiftUIのViewで使用 Action 従来のViewModelのtransformメソッドにおけるINPUTに相当する機能を提供するenum ActionHandler Actionを引数にとり、Stateを更新するハンドラー async/awaitを使用して実装 ViewController routerSubjectを購読し、Routerを用いて画面遷移を実施 Interactor / Router 2nd Generationと同様 ActionHandlerの分割 機能の多い画面では、ActionHandlerとStateを分割することで、コードの可読性や保守性を向上させることができる StateのactionPublisherを他のStateにバインドすることで、あるViewから他のViewにアクションを送ることが可能 flowchart TD subgraph ViewStore id2 -- action --> id1 id1(ActionHandler1) -- update --> id2(State1) id5 -- action --> id4 id4(ActionHandler2) -- update --> id5(State2) id8 -- action --> id7 id7(ActionHandler3) -- update --> id8(State3) end subgraph Parent View id3 id6 id9 end id2 -- bind --> id3(View1) id5 -- bind --> id6(View2) id8 -- bind --> id9(View3) おわりに 今回の取り組みは、機能開発と並行して1年以上かけて進めてきました。 現在では、ほぼすべてのソースコードが3rd Generation Architectureに置き換わっています。 その結果、コードの可読性や保守性が向上し、今後の開発が非常にやりやすくなったと感じています。 引き続き、さらなる改善を重ねていければと思います!
アバター
この記事は KINTOテクノロジーズアドベントカレンダー2024 の12日目の記事です🎅🎄 はじめに こんにちは。KINTOテクノロジーズ モバイル開発グループ プロデューサーチームの やまゆき です。  弊社では生成AIの使い方を学ぶ機会が、たくさん提供されています。  私はエンジニアではありませんが、今回、生成AIでビジネスサイド向けの運用ツール「お知らせHTML作成ツール」を開発してみました。 この話をすると、よく「え!?生成AIを組み込んだの!?」と勘違いをされるのですが、 そうではなく「生成AIを道具として使って開発した」というのがこの記事の内容です。  非エンジニアでも、自分で開発して業務を改善できる!そのことを実感したので、事例としてご紹介したいと思います。 背景:やろうと思った理由 私の担当アプリでは、PUSH通知をタップすると「お知らせの詳細ページ」が開きます。このページはWeb Viewになっており、HTMLファイルをWebにアップロードすることでお知らせを表示できます。 そのため、次のようなステップで運用されていました。 (1)ビジネスサイドから、表示したい内容をExcel・Word形式で入稿 (2)開発サイドの誰かがコーディングしてHTMLファイルを作成 (3)ビジネスサイドでHTMLファイルをチェック (4)開発サイドの誰かが修正 [場合によって(3)(4)をくり返し] (5)Web反映  入稿は、週に1〜2回のペース。 入稿元のビジネスサイドでは、入稿時に手元で完成形を確認できないため、何度も修正依頼をせざるを得ないという課題がありました。また、開発サイドとしても、修正を含め ”ちょっとしたコーディング” が頻発することで業務が圧迫され、スムーズな運用とは言い難い状況でした。  これらの課題を解決したい!ただ、CMS導入など大掛かりなことはできない・・・。 そこで、生成AIを使って今回開発した運用ツール「お知らせHTML作成ツール」を作るに至りました。  - 解決したかった課題(まとめ) 入稿時に手元で完成形を確認できるようにする(=修正を無くす) HTMLコーディング作業を無くす(=業務圧迫の改善) - 「Excel・WordのHTMLファイル出力」や「入稿時に生成AIでHTMLファイル化」ではダメだった理由 これらの方法でもHTMLファイルを作成できますが、ここで必要とされるHTMLファイルは単純な文章だけでなく、アプリ内のボタンやYouTubeの埋め込み動画、デザインが施されたトピックエリアなど、複雑な内容を含んでいました。また、Webの知識がない担当者でも手元で完成形を確認できるように、一発で高い品質のHTMLファイルを作成する必要があったため、これらの方法では運用が難しいと判断しました。 手順1:必要最小限の機能を定義する まずやったことは、必要最小限の機能は何か?を書き出すことです。あれやこれや理想を言い出すとキリがないものですが、 非エンジニアの自分が一人で開発するのですから、ツール開発を夢で終わらせないためMVP開発を目指すことにしました。 - 私が書き出した必要最小限の機能 (1)フォーム形式で入力した内容を、HTMLファイルとして出力できる (2)必要な入力欄は「タイトル・日付・見出しetc…」 (3)入力欄はユーザーの任意で、いくつでも追加・削除できる (4)出力するHTMLファイルには、指定のデザインを反映する (5)出力するHTMLファイルには、指定の計測パラメーターを反映する 手順2:プロンプトを書く 手順1で書き出した必要最小限の機能をもとに、以下のようなプロンプトを書いて生成AIへ指示を出しました。 私はCopilotを使いましたが、ChatGPT・Geminiなど、何を使っても問題ないと思います。 - 私が書いたプロンプト ユーザーが入力した"お知らせ"の内容を、 HTMLファイルとしてダウンロードできるWEBページを作成してください。 # 必要な入力欄 タイトル 日付 見出し サブ見出し 段落 画像 ボタン Youtube埋め込みタグ # 指示 (1)フォーム形式で入力した内容を、HTMLファイルとして出力できる (2)必要な入力欄は上記を参照 (3)入力欄はユーザーの任意で、いくつでも追加・削除できる (4)出力するHTMLファイルには、指定のデザインを反映する (5)出力するHTMLファイルには、指定の計測パラメーターを反映する # 規定のデザイン(CSS) https://〜 # 計測パラメーター 〜〜〜 手順3:調整する 手順2のプロンプトを生成AIへ入力すると、初手としては十分すぎるアウトプットが返ってくるはずです。それを元に、何だかちょっと違うな?あと少しこうだったら、という部分を生成AIと会話しながら調整します。 例 先ほどのフォームを、以下の指示を元に改善してください。 # 指示 画像の入力欄はURLではなく、その場でアップロードできるようにしてください。 難しい手順は踏まず、誰でも画像を反映できる状態が望ましいです。 - 調整する時のポイント 調整時は、改善点を1つずつ指示して進めることをおすすめします。例えば、「画像は〜」「ボタンの動作は〜」「入力欄は〜」と複数の改善点を一気に伝えてしまうと、生成が失敗した際に、どこで躓いたか分かりにくくなるためです。また、同じ理由で、生成されたコードやプロンプトの履歴は、バージョンを付けて残しておくことをおすすめします。 会話をしながら、少しづつ調整していきましょう! - 粘り強く調整すれば、想像以上のものを生成できる! 私の場合、この調整手順で必要最小限の機能を実現したのはもちろんですが、最終的には以下のような機能まで生成し、組み込むことに成功しました。 入力内容をプレビューする 入力欄の順番を後から入れ替えられるようにする 過去に出力したHTMLファイルを読み込んで編集する 画像サイズが極端に大きい場合にエラーを出し、リサイズ方法を案内する - デザインを調整して仕上げる 最後に見栄えがよくなるようCSSを調整しました。CSSは調べたり、これもまた生成AIに聞いたりして調整が可能ですが、面倒な方は以下のようなプロンプトで生成AIに指示してもいいかもしれません。 例 一般的な入力フォームとして使いやすいデザインとは、どのようなデザインですか? それでは、それらのデザインを先ほどのフォームに反映してください。 - 完成例 こちらが、実際に私が生成AIを使って開発した運用ツールです! https://www.youtube.com/watch?v=F-eyKyS8HSo さいごに この運用ツール開発によって、当初あった5ステップを2ステップに削減することに成功しました。 (1)ビジネスサイドから、表示したい内容をExcel・Word形式で入稿 (2)開発サイドの誰かがコーディングしてHTMLファイルを作成 (3)ビジネスサイドでHTMLファイルをチェック (4)開発サイドの誰かが修正 (場合によって(3)(4)をくり返し) (5)Web反映 ↓ (1)ビジネスサイドから、表示したい内容をHTMLファイルで入稿 (2)Web反映 また、ビジネスサイドからは入稿が楽になったと嬉しい声をもらうことができ、ビジネスサイドも開発サイドも嬉しい、WIN-WINな業務改善となりました! 私個人としては、同じプロジェクトのバックエンドエンジニアに「昔ならこれと同じものを人の手で開発するのに、2週間ほどの時間とお金がもらえたのに…」と嘆かれたことが印象的でした。エンジニアではない私が、それだけの価値あるものを一人で開発できたことに驚いています。 生成AIには怖さすら感じることがありますが、怖がっていても何もならないので、たくさん活用したい!そう強く思い直すきっかけになりました。 何事も同じですが、コツは何度もチャレンジすることだと思います(笑)最後までご覧いただき、ありがとうございました。
アバター
This article is part of day 12 of KINTO Technologies Advent Calendar 2024 🎅🎄 Introduction Hello. Yamayuki here, from the Producer Team in KINTO Technologies’ Mobile Development Group.  Our company offers many opportunities to explore the use generative AI.  While I am not an engineer, I took the initiative to experiment with generative AI to develop an operational tool for business-side staff. This tool simplifies the creation of HTML announcements. When I share this with others, they often get the idea that I pulled off the amazing feat of building generative AI into it, but that is not the case. What I actually did was use generative AI to help develop it. This article is about that process. Even non-engineers can develop solutions to improve work! Having experienced this firsthand, I wanted to share it as a concrete example. Background: Why I Decided to Do It For the apps I am responsible for, tapping a push notification opens the announcement details page. This page is a web view that displays announcements by uploading an HTML file to the web. The operation looks as follows: (1) Business-side staff submit an Excel or Word manuscript of the content they want to display. (2) Someone on the development side codes the HTML file. (3) The business-side staff review the HTML file. (4) Someone on the development side makes corrections (Steps 3 and 4 were repeat in some cases.) (5) The content is reflected on the web.  Manuscripts were submitted at a pace of approximately two per week. A challenge with this process was that the business-side staff submitting the manuscripts did not have the final version ready for review, leading to repeated requests for corrections. Additionally, the frequent need for "a bit of coding" to handle submissions and corrections became a significant burden for the development team as well. Overall, the process was far from efficient or smooth.  We wanted to solve these issues! But at the same time, we were unable to do any drastic changes like introducing a CMS. That is when I decided to leverage generative AI to develop the operational tool I want to discuss in this article: a tool designed to create HTML for announcements.  - Issues we wanted to solve (Summary) Allow manuscript submitters to have the finalized version available for review during submission (eliminating the need for corrections) and remove the necessity for HTML coding (reducing the workload). - Why neither using Excel/Word’s HTML file output nor relying on generative AI to create HTML files during manuscript submission provided an effective solution While these methods can generate HTML files, the requirements in this case went beyond simple text. The HTML needed to include complex elements such as in-app buttons, embedded YouTube videos, and intricately designed sections for specific topics. Additionally, it was essential to produce a high-quality HTML file in a single step, allowing staff with no web expertise to have the finalized version readily available for review. As a result, I concluded that relying on these methods would complicate the operation. Step 1: Define the Minimum Features Required The first thing I did was write down what the minimum features it would require were. I could have written a virtually never-ending want-list, but since I am a non-engineer, I decided to aim for an MVP to ensure that developing the tool would not end up as just a dream. - The minimum required features I wrote down (1) You can input content via a form and get it output as an HTML file. (2) The required input fields are the title, date, headings, etc. (3) Users can add or delete input fields at will. (4) The outputted HTML file reflects the specified design. (5) The outputted HTML file reflects the specified measurement parameters. Step 2: Write a Prompt Based on the minimum required functions I wrote down in step 1, I wrote the following prompt to give instructions to the generative AI. I used Copilot, but I doubt it really matters which one I had used (notable alternatives being ChatGPT and Gemini). - The prompt I wrote Please take announcement content inputted by the user, and create a web page of it that can be downloaded as an HTML file. # Required input fields Title Date Headings Subheadings Paragraphs Images Buttons YouTube embedding tags # Instructions (1) Content inputted via a form can be outputted as an HTML file. (2) See above for the required input fields. (3) Users can add or delete any number of input fields at will. (4) The specified design is reflected in the outputted HTML file. (5) The specified measurement parameters are reflected in the outputted HTML file. # Prescribed design (CSS) https://... # Measurement parameters ... Step 3: Make Adjustments By inputting the prompt from step 2 into the generative AI should produce output that is more than adequate for a first attempt. Based on that, you then adjust the parts that are a slightly off or could use a little tweaking, engaging with the generative AI as you go. Example Please improve the previous form based on the instructions below. # Instructions Make it so that instead of having to enter a URL into the image input fields, you can upload the images right there and then. Preferably, anyone should be able to include images without having to go through a difficult procedure. - The key point when making adjustments When making adjustments, I suggest providing instructions to focus on one improvement at a time. For instance, if you give instructions like, "Images should..., button behavior should..., and input fields should...," you’re asking generative AI to handle multiple improvements at once. If it makes a mistake, it can be challenging to pinpoint where things went wrong. For the same reason, I also recommend maintaining a version-numbered history of the generated code and prompts. Make adjustments gradually, engaging in a step-by-step conversation as you go! things little by little, talking to it as you go! - Through persistent fine-tuning, you can achieve results that surpass your original expectations! In my case, this adjustment process not only allowed me to achieve the minimum required features but also successfully generate and incorporate the following additional ones. Preview the inputted content. Let the order of the input fields be changed later. Import and edit previously outputted HTML files. If an image is excessively large, display an error message and provide instructions on how to resize it - Adjust and polish up the design. Finally, I adjusted the CSS to make it all look nicer. You can adjust the CSS by reviewing the results and providing feedback to the generative AI as you go. However, if that feels cumbersome, you might prefer to provide instructions to the generative AI using a prompt like this: Example What kinds of designs make for easy-to-use general input forms? Okay, please reflect those designs in the previous form. - Finished example Here is the operation tool that I actually developed using generative AI! https://www.youtube.com/watch?v=F-eyKyS8HSo Conclusion Developing this operational tool successfully reduced the initial five-step process to just two. Before: (1) Business-side staff submit an Excel or Word manuscript of the content they want to display. (2) Someone on the development side does codes the HTML file. (3) The business-side staff check the HTML file. (4) Someone on the development side makes corrections (Steps 3 and 4 were often repeated.) (5) The content is reflected on the web. ↓ After: (1) Business-side staff submit an HTML manuscript of the content they want to display. (2) The content is reflected on the web. Additionally, I received positive feedback from the business side, noting that submitting manuscripts had become much easier. Both the business and development teams were pleased, making it a win-win improvement for everyone involved! What particularly stood out to me was when the back-end engineers on the same project pointed out that, in the past, it would have taken about two weeks and a significant amount of money to develop the same functionality manuall. I’m amazed that, as a non-engineer, I was able to independently develop something so valuable. Generative AI can feel intimidating, but letting fear hold me back won’t help, so I’m determined to make the most of it! This experience truly made me reconsider my approach to generative AI in a serious and thoughtful way. This applies to everything, but I believe the key is to keep trying lol. Thank you for reading all the way to the end!
アバター
はじめに こんにちは!コーポレートITG兼技術広報G 学びの道の駅チーム所属の明田です。 普段はコーポレートエンジニアとして、IT機器に関するオン/オフボーディングやグループ内のプロセス/業務改善をしたりしています。 今回はコーポレートエンジニアの自分がひょんなことから「学びの道の駅プロジェクト」に参加し、活発的に開催されている社内勉強会の動画が自発的に集まるポータルをつくった話を紹介しようと思います。 きっかけ:社内勉強会を後で見る手段ってなくないか? 当社、下記のように何度か記事で紹介している通り社内勉強会が頻繁に行われています。 https://blog.kinto-technologies.com/posts/2024-04-23_%E5%AD%A6%E3%81%B3%E3%81%AE%E9%81%93%E3%81%AE%E9%A7%85%E3%81%AF%E3%81%98%E3%82%81%E3%81%BE%E3%81%97%E3%81%9F/ https://blog.kinto-technologies.com/posts/2024-05-21-%E5%AD%A6%E3%81%B3%E3%81%AE%E9%81%93%E3%81%AE%E9%A7%85-iOS%E3%83%81%E3%83%BC%E3%83%A0%E3%81%AE%E5%8B%89%E5%BC%B7%E4%BC%9A%E3%81%AB%E7%AA%81%E6%92%83/ 開催後は社内Slackチャンネルにて録画ファイルが投稿されますが、コーポレートエンジニアとして次のことが社内の課題だと感じていました。 社内にはConfluenceやSharepoint、Boxといったようにドキュメントファイルを共有する場所はあるが、動画コンテンツの置き場が定まっていない。 特に、社内の有益な勉強会の動画ファイルは社内Slackチャンネルにて録画ファイルが投稿されるだけで後から探すことが難しい 全社情報共有チャンネルがメインの投稿場所になっており、他の業務関連情報等も流れてくることから簡単なスクロールだけでは探せない状態だった 勉強会開催後に入社したメンバーはそもそも勉強会があったことも、その動画やファイルがあることを知る術がない これらを解決するにはどうしたらいいだろうか…と考え、思いついたのが動画プラットフォームを構築して勉強会の動画が一堂に集まる場所を作ったらどうか、という案でした。 はじまり:学びの道の駅プロジェクトのメンバーに突撃してみた 勉強会の動画が集まる場所を作る、と思ったときにすぐに思いついたのは学びの道の駅プロジェクトの存在でした。学びの道の駅は「社内の「勉強会」と「勉強会」が交わる「道の駅」として、勉強会を軸にした社内活性を支援」する活動を行っており、自分がやりたいことと活動がバッティングしないか・もし検討しているなら協力して進められるといいな、と勝手な思いを抱きました。 鉄は熱いうちに打て、ということで同じグループかつ学びの道の駅に参加している きんちゃん にまずはその旨を相談したお返事が「いいですね!賛成です!!」とのこと。(めちゃくちゃ嬉しかった記憶があります)その後、コーポレートITGとして本件を進めてOKとなった後に早速他のプロジェクトメンバーにも相談しに行きました。 当時のSlackのやりとりが、学びの道の駅メンバーの熱さが伝わると思うため皆さんに紹介します。 まずは私の相談文章がこちら。 ここで絵文字リアクションがすごいことにお気づきでしょうか?この勢いのままのお返事がこちらです。 話がとっても早い!その後、集まった当日に「明田さんも学びの道の駅に入っちゃえばいいじゃん!」とお誘いをいただき学びの道の駅メンバーとして動画プラットフォームを進めていくことになりました。 本編:動画プラットフォームを構築・社内展開した このブログを読んでくださっている方も、全社MTGやグループ内の勉強会を集めた動画プラットフォームを社内に展開したいという方はいらっしゃると思いますのでここからは実施内容の中身と、その中からいくつかピックアップしてなぜそれを実施したのかを紹介します。 実施内容 動画プラットフォームの構築 社内で使用されていたSharepointサイトを活用し、動画掲載方法等をリニューアル 動画収集方法の決定 社内の公開OKな会議、社内勉強会の動画を収集する 会の主催者に録画ファイルと使用した公開可能なファイルをSharepintサイトのドキュメントにアップロード Sharepointサイトのトップページは「強調表示されたコンテンツ」機能を使用し、勉強会名でフィルター設定する。 一度の設定以降、同じ勉強会名の動画がアップロードされるとトップページに自動で動画が掲載される仕組み 周知、運用 8月末の全社MTGで動画プラットフォーム紹介&動画収集の依頼 Q.なぜSharepointを採用したのか A. 当社がグループウェアでMicrosoft365を採用しているため 動画プラットフォームはYoutubeやVimeo、Brightcove等ありますがこれらは新規に契約をしなければならなかったことや元々動画収集は会の主催者に実施してほしいという要件があったことから慣れ親しんでいるSharepointを採用しました。 Q.なぜ会の主催者自身に動画をアップロードしてもらう方針にしたのか A. 不要な個所の削除などの編集など、主催者自身に行ってほしかった 動画プラットフォームへアップロードすべきかどうかは自主的に判断してもらうのがベストだと考えた ここを第三者である私達学びの道の駅メンバーが作業を代行して請け負うこともできましたが、勉強会開催前のわちゃわちゃした会話やリアルタイムだからこそ伝えられること等を動画にも含むかどうかということと、動画プラットフォームにアップロードすべきかを判断できるのは、勉強会の主催者がいちばん適任だと考えました。 Q.なぜ「強調表示されたコンテンツ」機能を使うことにしたのか A. シンプルに楽だったから アップロードしてもらった動画を見てもらうためにはどうしたらよいか、を考えたときに文字よりも画像表示・何回かクリックした先で動画を見るよりもトップページから興味ある内容を見るほうがリーチ数が高くなると仮定しました。 その後、動画へのアクセス方法をいくつかのパターン分作成して学びの道の駅メンバーに見てもらったときにサムネイル表示ができ、かつ勉強会ごとに動画がまとまっている状態がSharepointサイトのトップページに並んでいる状態がいちばん閲覧者の興味を引くという答えになったことでそれが実現できる「強調されたコンテンツ」機能を活用することになりました。 使用例 参考: https://support.microsoft.com/ja-jp/office/強調表示されたコンテンツの-web-パーツを使用する-e34199b0-ff1a-47fb-8f4d-dbcaed329efd これを使う上での注意ですが、アップロード直後の動画は勉強会名(=動画ファイル名)で行うフィルターに引っかからないことがあります。そのときは少し時間を置いてから、再度試してみてください。 Q.周知は全社MTGの1回だけなのか A. 新入社員向けのオリエンテーションで毎月紹介 毎月ある全社MTGの学びの道の駅&技術広報Gの枠で紹介 冒頭に記載した通り「勉強会開催後に入社したメンバーはそもそも勉強会があったことも、その動画やファイルがあることを知る術がない」という課題を持っていたことから、ここはもちろん対処しました。この2枠あることで、存在感を出すことができています! おわりに:社内の学びをいろいろな方向からこれからも支援します! 動画プラットフォームは全社MTG以降、アクセス数が著しく減少することもなく勉強会動画も収集されており元々感じていた課題を解消する一助になりつつあるのではないかと感じつつここまで、「社内の課題感」「学びの道の駅という強力なサポーターが社内にいる話」と「本編/実際の構築話」の3本をお話しました。 弊社のいいところは、新しいチャレンジに寛容であるところでまさに今回の取り組みはそれを体現できていると感じており、本編にたどりつくまで長く語ってしまいましたが、ここまでこの記事を読んでくださった皆さんにはそれが伝わったのではないかと勝手に思っております。 現在、学びの道の駅は1プロジェクトから技術広報Gの1チームとして活動しています。これからも変わらず「社内の「勉強会」と「勉強会」が交わる「道の駅」として、勉強会を軸にした社内活性を支援」する活動を行い、それらの活動をテックブログ等で紹介していきます。引き続きよろしくお願いします!
アバター
Introduction Hello. I’m Hiroya (@___TRAsh) from the Mobile App Development Group. At our company, we have several in-house product teams, and many of them use Xcode Cloud. Xcode Cloud is an official CI/CD service provided by Apple that automates iOS app builds and CD (deployment to TestFlight). In this post, I’ll cover how to integrate a private repository as a library in Xcode Cloud. Since there weren’t many references available, I faced some challenges getting it to work and wanted to share the solutions I found. Target Readers This guide is intended for readers with some experience in iOS development, especially in setting up CI/CD for iOS environments. Environment - Using Xcode 15.4 - Managing libraries with SwiftPM - Referencing a private repository in libraries - Deploying to TestFlight with GitHub Actions + Fastlane Objective To shift TestFlight deployments from GitHub Actions + Fastlane to Xcode Cloud. By doing so, we can reduce dependency on Fastlane and minimize the tools required in the app submission process. Additionally, Xcode Cloud allows direct reference to Apple Developer certificates, making it easier to manage the certificates needed for app submission. Challenges Xcode Cloud offers many benefits, but using a private repository as a library requires user authentication. Since Xcode Cloud does not natively support these authentication settings, an extra step is needed to reference a private repository as a library. To achieve this, we can use the ci_scripts/ci_post_clone.sh provided by Xcode Cloud to set up the necessary authentication, allowing access to the private repository. .netrc configuration Since Xcode 12.5, .netrc has been supported as a way to store usernames and passwords,`` which can be automatically accessed during git clone operations. By placing in ~/.netrc , the authentication information is automatically applied. Additionally, for managing the private repository as a GitHub Release, I’ve added api.github.com to the .netric configuration. touch ~/.netrc echo "machine github.com login $GITHUB_USER password $GITHUB_ACCESS_TOKEN" >> ~/.netrc echo "machine api.github.com login $GITHUB_USER password $GITHUB_ACCESS_TOKEN" >> ~/.netrc I set my username and access token as secrets in Xcode Cloud’s environment variables and configured ci_post_clone.sh to reference them. Adding the Repository URL In Xcode Cloud settings within App Store Connect, I added the repository URL of the library under Additional Repositories . Removing Settings with Defaults Delete Even after configuring access to the private repository’s library, I encountered an issue where the library dependencies couldn’t be resolved, resulting in the following error: :::message alert Could not resolve package dependencies: a resolved file is required when automatic dependency resolution is disabled and should be placed at XX/XX/Package.resolved. Running resolver because the following dependencies were added: 'XXXX' ( https://github.com/~~/~~.git ) fatalError ::: This error occurs because, on Xcode Cloud, SwiftPM does not reference Package.resolved and instead attempts to resolve package versions automatically. To fix this issue and allow the build to succeed, I deleted certain Xcode defaults. defaults delete com.apple.dt.Xcode IDEPackageOnlyUseVersionsFromResolvedFile defaults delete com.apple.dt.Xcode IDEDisableAutomaticPackageResolution Although I applied these two settings, I couldn't clearly identify the difference between them... To get more information, I ran the xcodebuild help command locally, where I found some similar settings that could help clarify their roles. $ xcodebuild -help ... -disableAutomaticPackageResolution prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file -onlyUsePackageVersionsFromResolvedFile prevents packages from automatically being resolved to versions other than those recorded in the `Package.resolved` file This prevents SwiftPM from automatically resolving packages to versions other than those recorded in the Package.resolved file. However, both settings seem to do exactly the same thing, as no additional differences were found. I also came across a similar question in a SwiftPM issue thread, and this approach was confirmed to work. So, for now, I believe this setup is sufficient. https://github.com/swiftlang/swift-package-manager/issues/6914 For now, by deleting these two settings, SwiftPM will only refer to Package.resolved for library dependencies and resolve them. Conclusion By configuring .netrc in ci_scripts/ci_post_clone.sh , which Xcode Cloud references before starting, I was able to access the private repository. Additionally, setting the defaults delete ensures that SwiftPM resolves dependencies based solely on Package.resolved, allowing the build to succeed on Xcode Cloud. #!/bin/sh defaults delete com.apple.dt.Xcode IDEPackageOnlyUseVersionsFromResolvedFile defaults delete com.apple.dt.Xcode IDEDisableAutomaticPackageResolution touch ~/.netrc echo "machine github.com login $GITHUB_USER password $GITHUB_ACCESS_TOKEN" >> ~/.netrc echo "machine api.github.com login $GITHUB_USER password $GITHUB_ACCESS_TOKEN" >> ~/.netrc Lastly Fastlane is a great tool that has been around for a long time, but by using Xcode Cloud, the process of submitting an app has been simplified. As mentioned earlier, Xcode Cloud offers numerous benefits, so I encourage you to consider implementing it in your workflow. Appendix https://developer.apple.com/documentation/xcode/writing-custom-build-scripts https://speakerdeck.com/ryunen344/swiftpm-with-kmmwoprivatenagithub-releasedeyun-yong-suru https://qiita.com/tichise/items/87ff3f7c02d33d8c7370 https://github.com/swiftlang/swift-package-manager/issues/6914
アバター
この記事は KINTOテクノロジーズアドベントカレンダー2024 の7日目の記事です🎅🎄 はじめに こんにちは! CloudInfrastructure G (Osaka Tech Lab) の井木です。 今回は、Osaka Tech LabのメンバーでMVP開発手法を用いて生成AIを活用した会話APIを作成したことについてい話したいと思います。 ちなみにMVP(Minimum Viable Product)開発とは 最小限の機能を備えたものをまず作成して、ユーザ ( 今回だと発案者のPdM ) のフィードバックを受けながら検証改善を進めていく手法です。 今回のざっくりとした要件に対して非常に効率的な開発手法だと思っています。 作成しようと思ったきっかけ 至極当然な理由です。 KINTOテクノロジーズは、東京、名古屋、大阪 (Osaka Tech Lab) に拠点がありますが、社員の8~9割近く東京に偏っています。 また、拠点ごとで担当を分けているわけではないため、東京のプロジェクトにそれぞれ参加して仕事を行っています。 この状況はよくある状況で、問題があるわけではないですが 大阪メンバーは他の拠点に比べて仲が非常にいいです!(自己申告) そうなると、大阪メンバーだけでやりたいよねとなるのは当たり前ですね。 そのタイミングで、生成AIを活用した会話が成り立つのかを考えているPdMとの出会いは必然です。 テーマ 今回は、生成AIを活用した会話が成り立つのかどうかの仮説をMVP開発で検証することにしました。 会話といってもいろいろあり、接客対応も上司の報告も会話になります。 緊張したかたい会話ではなく、家族、友人と話すような「自然な安心した会話ができるのか」を焦点に充てています。 とりあえずできたもの 全体構成 ※会話にロボットがいたほうがいいとの判断からユカイ工学様の市販ロボット (BOCCO emo) を利用 Azure構成図 ![Azure構成図(簡易版)](/assets/blog/authors/norio_iki/chmcha_azure_architecture.png =700x) 作成するにあたり考慮した点 時間 今回のゴールは、生成AIを活用した会話が成り立つのかをテーマとしましたが 時間と人をかけてできたとしてもそれは、もう生成AIを利用した会話が世の中にあるのでそれはできるでしょうになります。 また今回については、会話という人は簡単にやっているけど実は考えると非常に奥深いテーマとなっており、 やりたいことなどが山ほど出てくるテーマとなっています。そのため時間があればいくらでもできるものです。 MVPに時間をかけて作成してもそれはMVPの価値はないと考え、今回は決めた時間を超えない前提で進めました。 使用した時間 要件検討/MVP作成 2 Day フィードバックを受けながら検証改善 15 hour(Max) 実際に何をやったか 要件検討/MVP作成 今回のMVP開発における環境は決めていなかったのですが、生成AIを活用したシステムをどのように作ればいいかも知見がほとんどない状態でした。 このままだと「自然な安心した会話ができるのか」のテーマを検証する前に生成AIのシステムをどのように作ればいいかからの検証からスタートしなければいけないところでしたが 生成AIのシステムの知見はAzure Light-upのプログラムを提供している ZENARCHITECTS様 の協力を受け、自分たちは今回のテーマに集中できる状態を作り上げています。 ZENARCHITECTS様には、生成AIシステム構築の伴走だけではなく 今回ざっくりとしたテーマから生成AIを利用するために気を付けたほうがいい点なども実経験からのアイデアをいただき、 2日間でMVPを完成することろまで引っ張っていただいております。 フィードバックを受けながら検証改善 実際に利用してみたコメントを受け、開発するメンバーで話し合い改善内容を決定。 現在の場所から会話をする機能を追加したり、カフェばかり話すロボットを直すためプロンプトとか 気が付いたところをフィードバックしてもらうサイクルを1カ月間 (15h) を利用して実施しまいた。 現在の場所から会話する機能の検証には、場所情報を変えながらオフィスで検証するのではなく、本当に移動しながらの検証も実施。 車のサブスクを提供するKINTOならではの、車上でフィードバックを受けながらデプロイを行うなどのリアルタイムでのアップデートも 内製ならできる対応です!(こんな感じで検証改善を繰り返しました!) ![ドライブしながらのデプロイ](/assets/blog/authors/norio_iki/drive_deploy.jpg#right =400x) さいごに 今回作成した生成AIを活用した会話APIについては、現在社内で将来性について検証中となります。 将来かかると想定されるコストに対して、価値が上回ったらさらに開発を進める予定です。 だた、もし時期早々の判断になって継続開発が中止となる場合もあります。 しかし、たとえ継続がなくても今回確認した結果と、新しい分野(生成AIシステムのAzure開発)の経験という成果が残ります。 その成果は、既存システムへのフィードバックにいかせたり、新たなアイデア創出の糧になるものです。 今回は、たとえ継続がなくても失敗ではないと考えます。失敗しないMVP開発を行うにあたり、常に前進して イノベーション・サイクルを回して行ける環境を作っていけると考えます。 今後ともMVPでのチャレンジ自体は進めていきたいと思います! また、もう少し詳しく内容を知りたい方は、ZENARCHITECTS様の事例紹介に記載されておりますのでそちらをご覧くださいませ Azure Light-up
アバター
This article is the entry for day 19 in the KINTO Technologies Advent Calendar 2024 🎅🎄 Introduction Hello, This is Rasel Miah , an iOS Engineer from the Mobile Application Development Group. Today, I’ll introduce an improved approach to updating the UI in SwiftUI using the new @Observable macro introduced in iOS 17. I’ll explain how it works, the problems it solves, and why we should use it. TL;DR Observation provides a robust, type-safe, and performant implementation of the observer design pattern in Swift. This pattern allows an observable object to maintain a list of observers and notify them of specific or general state changes. This has the advantages of not directly coupling objects together and allowing implicit distribution of updates across potential multiple observers. From Apple Documentation In simple terms, Observation is a new and easier way to make a view respond to data changes. Challenges Without Using Observation Before diving into Observation , let me first show you the old method of updating the UI and the challenges associated with it. Let’s start with a simple example. import SwiftUI class User: ObservableObject { @Published var name = "" let age = 20 func setName() { name = "KINTO Technologies" } } struct ParentView: View { @StateObject private var user = User() var body: some View { let _ = print("ParentView.body") VStack { Button("Set Name") { user.setName() } ChildView(user: user) } } } struct ChildView: View { @ObservedObject var user: User var body: some View { let _ = print("ChildView.body") VStack { Text("Age is \(user.age)") } } } User Conforms to the ObservableObject protocol to enable state observation. Contains a @Published property name to notify views about changes. ParentView @StateObject to manage an instance of the User class. ChildView Accepts a User object as an @ObservedObject . Both the parent and child views will use let _ = print("xxx.body") for debugging purposes to log updates to the view. If you build the project, you’ll see the following output in the debug log. ParentView.body ChildView.body No issues so far, as this is the initial state and both views are rendered. However, if you press the setName button, you’ll see the following output. ParentView.body ChildView.body Both ParentView and ChildView are re-drawn, which is not expected since ParentView didn't use any properties of User . Moreover, ChildView relies on a constant variable that doesn’t change, yet it still gets re-drawn. Even if ChildView only returns a static Text, it will still be re-drawn whenever the User model changes because it holds a reference to the User model. This highlights a significant performance issue. This is where the Observation framework steps in to rescue us. Hello @Observable ! The @Observable macro was introduced at WWDC 2023 as a replacement for ObservableObject and its @Published properties. This macro eliminates the need for explicitly marking properties as published while still enabling SwiftUI views to automatically re-render when changes occur. To migrate from ObservableObject to the @Observable macro, simply mark the class you want to make observable with the new Swift macro, @Observable . Additionally, remove the @Published attribute from all properties. @Observable class User { var name = "" let age = 20 func setName() { name = "KINTO Technologies" } } Note:  Struct does not support the  @Observable  macro. That’s it! Now, the UI will only update when the name property changes. To verify this behavior, modify the views as follows: struct ParentView: View { // BEFORE // @StateObject private var user = User() // AFTER @State private var user = User() var body: some View { let _ = print("ParentView.body") VStack { Button("Set Name") { user.setName() } ChildView(user: user) } } } struct ChildView: View { // BEFORE // @ObservedObject var user: User // AFTER @Bindable var user: User var body: some View { let _ = print("ChildView.body") VStack { Text("Age is \(user.age)") } } } Now, we can declare our custom observable model using @State , eliminating the need for @ObservedObject , ObservableObject , @Published , or @EnvironmentObject. @Bindable : A property wrapper type that supports creating bindings to the mutable properties of observable objects. From Apple Documentation After running the code, you’ll see the following output during the initial rendering: ParentView.body ChildView.body If you press the setName button, nothing will appear in the console because ParentView doesn’t need to update as it didn't use any properties of User. The same applies to ChildView . Add Text(user.name) to the ParentView and then build the project. After pressing the setName button, you will see the output: struct ParentView: View { @State private var user = User() var body: some View { let _ = print("ParentView.body") VStack { Button("Set Name") { user.setName() } Text(user.name) // <--- Added ChildView(user: user) } } } // output ParentView.body // change the age from child @Observable class User { var name = "" var age = 20 func setName() { name = "KINTO Technologies" } } struct ParentView: View { @State private var user = User() var body: some View { let _ = print("ParentView.body") VStack { Button("Set Name") { user.setName() } ChildView(user: user) } } } struct ChildView: View { @Bindable var user: User var body: some View { let _ = print("ChildView.body") VStack { Button("Change Age") { user.age = 30 } Text("Age is \(user.age)") } } } // output ChildView.body This indicates that the view is updating correctly without any unnecessary re-rendering. This represents a significant improvement in performance. How does @Observable work? This might seem miraculous. SwiftUI views update without any issues as we simply checked our model with the @Observable macro. But there's more happening behind the scenes. We’ve moved from using the ObservableObject protocol to the Observation.Observable protocol. Additionally, our name & age property is now associated with the @ObservationTracked Macro instead of the @Published Property Wrapper. You can expand the macro to reveal its implementation. The following is the expanded code. @Observable class User { @ObservationTracked var name = "" @ObservationTracked var age = 20 func setName() { name = "KINTO Technologies" } @ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar() internal nonisolated func access<Member>( keyPath: KeyPath<User, Member> ) { _$observationRegistrar.access(self, keyPath: keyPath) } internal nonisolated func withMutation<Member, MutationResult>( keyPath: KeyPath<User, Member>, _ mutation: () throws -> MutationResult ) rethrows -> MutationResult { try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation) } } extension User: Observation.Observable {} By default, an object can observe any property of an observable type that is accessible to the observing object. To prevent a property from being observed, simply attach the @ObservationIgnored macro to that property. @Observable class User { var name = "" @ObservationIgnored var age = 20 func setName() { name = "KINTO Technologies" } } Now, any change to the age property won’t be tracked meaning no UI update will be happened. Performance Analysis Here is the view count report recorded using the Instruments tool. Without Observation With Observation Insights from the Capture Without Observation (First Image) This Instruments session captures the unoptimized performance of a SwiftUI app: Metrics Displayed : View body updates, property changes, and timing summary. View Redraw Count : 9 redraws, including the initial rendering and one redraw for each of the three Set Name button taps. Performance : Total Redraw Duration : 377.71 µs . Average Redraw Time : 41.97 µs per redraw . With Observation (Second Image) This session highlights the optimized rendering achieved with the Observation framework: Metrics Displayed : Same metrics as in the first session, now reflecting improved efficiency. View Redraw Count : 3 redraws, consisting of the initial rendering and only one redraw for state changes, regardless of multiple button taps. Performance : Total Redraw Duration : 235.58 µs . Average Redraw Time : 78.53 µs per redraw . Quantitative Highlights View Redraw Count Reduction : Without Observation: 9 redraws . With Observation: 3 redraws (reduced by 66.67% ). Total Redraw Duration Improvement : Without Observation: 377.71 µs . With Observation: 235.58 µs (reduced by 37.65% ). Redraw Efficiency : Without Observation: More frequent, averaging 41.97 µs . With Observation: Fewer but optimized redraws, averaging 78.53 µs . This comparison illustrates the significant impact of the Observation framework on reducing the number of redraws and improving overall rendering performance, even though the average redraw time per instance is slightly higher due to fewer redraws. Summary In this post, we explored the improvements in SwiftUI with the new @Observable macro introduced in iOS 17. Here’s a quick recap of the key points: Challenges with Previous Approach The old method of using ObservableObject and @Published properties caused unnecessary re-renders, resulting in performance issues, especially when some views did not depend on the changing data. Introducing @Observable This new macro simplifies the state observation process, eliminating the need for @Published , @ObservableObject , and @EnvironmentObject . By marking a class with @Observable , and using @Bindable in views, we can automatically track changes in the data and only trigger view updates when necessary. Performance Improvements The use of @Observable ensures that views are updated only when the relevant data changes, reducing unnecessary re-renders and improving performance. With the @ObservationIgnored macro, developers have more control over which properties should or should not trigger UI updates. Benefits Better performance through more targeted updates. A simplified codebase by removing the need for ObservableObject and @Published. Type-safe management of state changes. More control over which properties trigger view updates. With @Observable , managing UI updates in SwiftUI becomes easier, more efficient, and less error-prone, offering a smoother experience for both developers and end users. That’s all for today. Happy Coding! 👨‍💻 Ref- Observation Discover Observation in SwiftUI, WWDC23
アバター
この記事は KINTOテクノロジーズアドベントカレンダー2024 の6日目の記事です🎅🎄 はじめに こんにちは。KINTO FACTORY開発グループの上原( @penpen_77777 )です。 2024年の7月に入社し、KINTO FACTORYのバックエンドの開発を担当しています。 今回は、業務の中でS3イベントを処理する際に注意すべきだったデータ競合とその解決策についてサンプルコードを通じてご紹介します。 今回想定する読者 AWSのS3イベントが重複して通知されたり、通知の順序が入れ替わることに悩んでいる方 Rust、S3、DynamoDB、Lambda、Terraformについて基本的な知識がある方 サンプルコードを読む際にこの辺りの知識があると理解しやすいです S3イベントの概要 S3イベント^[AWSによるS3イベント通知に関するドキュメント https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/EventNotifications.html]とは、S3へのオブジェクトをアップロード、削除などの操作をトリガーに発生するイベントのことです。 S3イベントをLambda関数やSNSなどで検知することによって、S3にまつわる様々な処理を自動化できます。 S3イベントの問題点 S3イベントを処理する上で注意するべきなのは、イベントが重複して通知されたり順序が入れ替わったりすることがあるという点です。 例えば、同一オブジェクトキーに対してオブジェクト削除後にオブジェクト作成する処理を考えてみましょう。 この場合、オブジェクトの削除イベントが先に通知され、その後に作成イベントが通知されることが期待されます(同一オブジェクトに対して削除→作成した場合の期待するS3イベントの受信順序の図を参照) しかし、S3イベントは順序が保証されないため、作成イベントが先に通知され、後に削除イベントが通知されることがあります(同一オブジェクトに対して削除→作成した場合の起こりうるS3イベントの受信順序の図を参照) 結果、オブジェクトを削除するイベントによる処理結果が最新になってしまい、処理内容によってはデータの一貫性が保証されないという問題が発生することがあります。 gantt title 同一オブジェクトに対して削除→作成した場合の期待するS3イベントの受信順序 dateFormat HH:mm:ss axisFormat %H:%M:%S section オブジェクトの削除 オブジェクト削除 :done, cre1, 00:00:01, 1s 削除イベント受信・処理 :done, cre2, 00:00:03, 1s section オブジェクトのアップロード オブジェクトアップロード :done, cre1, 00:00:02, 1s 作成イベント受信・処理 :active, cre2, 00:00:04, 1s gantt title 同一オブジェクトに対して削除→作成した場合の実際に起こりうるS3イベントの受信順序 dateFormat HH:mm:ss axisFormat %H:%M:%S section オブジェクトの削除 オブジェクト削除 :done, cre1, 00:00:01, 1s 削除イベント受信・処理 :active, cre2, 00:00:04, 1s section オブジェクトのアップロード オブジェクトアップロード :done, cre1, 00:00:02, 1s 作成イベント受信・処理 :done, cre2, 00:00:03, 1s この問題の解決策としてS3イベントに含まれるsequencerキーを使ってイベントの順序を保証する方法があります^[S3イベントの構造に関するドキュメント。 https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/notification-content-structure.html]。 イベントのシーケンスを決定する方法の 1 つとして、sequencer キーがあります。イベントが発生した順序でイベント通知が届く保証はありません。ただし、オブジェクト (PUT) を作成するイベントからの通知 と削除オブジェクトは sequencer を含みます。これは、特定のオブジェクトキーのイベントの順序を決定するために使用できます。 同じオブジェクトキーに対する 2 つのイベント通知の sequencer の文字列を比較すると、sequencer の 16 進値が大きいほうのイベント通知が後に発生したイベントであることがわかります。イベント通知を使用して Amazon S3 オブジェクトの別のデータベースまたはインデックスを維持している場合は、イベント通知を処理するたびに sequencer の値を比較し、保存することを推奨します。 次の点に注意してください。 複数のオブジェクトキーのイベントの順序を決定するために sequencer を使用することはできません。 sequencer の長さが異なる場合があります。これらの値を比較するには、最初に短い方の値を右に 0 と挿入してから、辞書式比較を実行します。 まとめると以下の通りです。 sequencerはオブジェクトのPUTやDELETEイベントに含まれる値で、イベントの順序を決定するために使用可能 sequencerを辞書式比較し、値が大きい方が後に発生したイベント 長さが異なる場合は、短い方の値の右側に0を挿入してから比較 複数のオブジェクト同士のS3イベント順序を決定するために使用できない 同一オブジェクトに対するPUTやDELETEイベントの順序を決定するために使用する 例えば、Rust上でS3イベントのシーケンサ比較を実装する場合は以下のように実装できます。 S3のシーケンサの性質を表現するための構造体 S3Sequencer のフィールドとコンストラクタを定義します。 // 1. 構造体S3Sequencerを定義する use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct S3Sequencer { bucket_name: String, object_key: String, sequencer: String, } // 2. S3Sequencerのコンストラクタを定義する // バケット名、オブジェクトキー、シーケンサを引数に取る impl S3Sequencer { pub fn new(bucket_name: &str, objcet_key: &str, sequencer: &str) -> Self { Self { bucket_name: bucket_name.to_owned(), object_key: objcet_key.to_owned(), sequencer: sequencer.to_owned(), } } } 次に、イベントの前後関係をS3Sequencerの大小を比較することで判別させるため、 PartialOrd トレイト^[数学的に言うと半順序集合を表現できます。 全順序集合と言うのもありそちらは Ord トレイトを実装します。比較方法を実装するためにわざわざトレイトが分けられているのは面白いなと感じます。 https://doc.rust-lang.org/std/cmp/trait.PartialOrd.html https://ja.wikipedia.org/wiki/%E9%A0%86%E5%BA%8F%E9%9B%86%E5%90%88#%E5%8D%8A%E9%A0%86%E5%BA%8F%E9%9B%86%E5%90%88]と PartialEq トレイト^[PartialEqトレイトの定義 https://doc.rust-lang.org/std/cmp/trait.PartialEq.html] を実装していきます。 この2つのトレイトを実装すれば、以下のように == や < 、 > などの比較演算子を使ってシーケンサの大小を比較できます。 let seq1 = S3Sequencer::new("bucket1", "object1", "abc123"); let seq2 = S3Sequencer::new("bucket1", "object1", "abc124"); if seq1 < seq2 { println!("seq1はseq2より古いイベントです"); } else if seq1 == seq2 { println!("seq1とseq2は同じイベントです"); } else { println!("seq1はseq2より新しいイベントです"); } PartialOrdトレイトの実装に必要なpartial_cmpメソッドは以下のように実装します。 impl PartialOrd for S3Sequencer { fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { // バケット名が異なるシーケンサは比較できない if self.bucket_name != other.bucket_name { return None; } // オブジェクトキーが異なるシーケンサは比較できない if self.object_key != other.object_key { return None; } // 長い方に合わせて、短い方の末尾に0を追加して比較 let max_len = std::cmp::max(self.sequencer.len(), other.sequencer.len()); let self_sequencer = self .sequencer .chars() .chain(std::iter::repeat('0')) .take(max_len); let other_sequencer = other .sequencer .chars() .chain(std::iter::repeat('0')) .take(max_len); Some(self_sequencer.cmp(other_sequencer)) } バケット名およびオブジェクトキーが異なるシーケンサの比較に意味はないため、それぞれが異なる場合はearly returnでNoneを返します。 バケット名とオブジェクトキーが同じになっていることを確認できたらシーケンサの比較に入るわけですが、以下の順で処理をしていきます。 シーケンサの長さを比較して、長い方の長さを max_len に格納する max_len に合わせて、短い方のシーケンサの末尾に0を追加する 2で作成したシーケンサを辞書順で比較して、大小を返す PartialEqトレイトは以下のように実装します。 impl PartialEq for S3Sequencer { fn eq(&self, other: &Self) -> bool { self.partial_cmp(other) .map_or(false, |o| o == std::cmp::Ordering::Equal) } } PartialOrdトレイトのpartial_cmpメソッドを使って、シーケンサの比較結果が等しいかどうかを判定しています。 以上の実装により、S3イベントのシーケンサを比較できるようになりました。 データ一貫性を考慮したS3イベント処理の実装例 アーキテクチャ図 ここからは、シーケンサを用いてS3イベントの順序を保証する方法をサンプルコードを交えながら紹介します。 サンプルコードではS3バケットにアップロードされた画像ファイルをLambda上でグレイスケールに変換して、出力用のS3バケットに保存する処理を行います。 以下にアーキテクチャ図を示します。 ![サンプルコードのアーキテクチャ図。S3バケットにアップロードされた画像ファイルをLambda上でグレイスケールに変換して、出力用のS3バケットに保存する。DynamoDBを使ってロック処理を実装する。](/assets/blog/authors/uehara/2024-12-02-how-to-gurantee-s3-event-order/architecture.svg =600x) 入力画像用バケットに画像ファイルがアップロードされると、S3イベントを通じてLambda関数がトリガーされます。起動したLambda関数は、DynamoDBを見て処理中でないことを確認し、処理中フラグを立てて画像ファイルを処理します。処理が完了したら、処理中フラグを解除して次の画像ファイルの処理を待ちます。 作成・削除のイベントの通知の順序が逆転してしまうと、本来存在するはずの画像が誤って削除されるといった問題が発生します。 例えば、以下の流れで処理を行うと想定し実装したとします。 画像ファイルAが入力用バケットから削除される 画像ファイルAが入力用バケットに再度アップロードされる Lambdaが削除イベント(1に対応)を受信、画像ファイルAを出力用バケットから削除する Lambdaが作成イベント(2に対応)を受信、画像ファイルAを処理しグレースケールに変換して出力用バケットに保存する gantt title 同一オブジェクトに対して削除→作成した場合の期待するS3イベントの受信順序 dateFormat HH:mm:ss axisFormat %H:%M:%S section オブジェクトの削除 (1) オブジェクト削除 :done, cre1, 00:00:01, 1s (3) イベント受信・オブジェクトを削除 :done, cre2, 00:00:03, 1s section オブジェクトのアップロード (2) オブジェクトアップロード :done, cre1, 00:00:02, 1s (4) イベント受信・グレースケールに変換 :active, cre2, 00:00:04, 1s しかし、S3イベントでは3と4の通知順序が逆転する可能性があるため、以下のような流れになることがあります。 画像ファイルAが入力用バケットから削除される 画像ファイルAが入力用バケットに再度アップロードされる Lambdaが作成イベント(2に対応)を受信、画像ファイルAを処理しグレースケールに変換して出力用バケットに保存する Lambdaが削除イベント(1に対応)を受信、画像ファイルAを出力用バケットから削除する gantt title 同一オブジェクトに対して削除→作成した場合の実際に起こりうるS3イベントの受信順序 dateFormat HH:mm:ss axisFormat %H:%M:%S section オブジェクトの削除 (1) オブジェクト削除 :done, cre1, 00:00:01, 1s (4) イベント受信・オブジェクトを削除 :active, cre2, 00:00:04, 1s section オブジェクトのアップロード (2)オブジェクトアップロード :done, cre1, 00:00:02, 1s (3) イベント受信・グレースケールに変換 :done, cre2, 00:00:03, 1s この場合、入力用バケットには画像ファイルAが存在するにもかかわらず、出力用バケットにはグレースケール化した画像ファイルA'が存在しないという問題が発生します。 このような問題を防ぐために、S3イベントのシーケンサを使って排他処理を実装します。 加えてDynamoDBによって画像処理状況を管理させることも排他処理の実装には必要です。 DynamoDBの条件付き書き込みを使って、処理中フラグを立てることで、複数のLambda関数が同時に同じ画像ファイルを処理することを防ぎます。 今回のサンプルコードはGitHubに公開しています。以下のリンクからご確認ください。 (実行にはAWSインフラの構築が必要ですが、terraformコードにより容易に試せるようにしております) https://github.com/kinto-technologies/techblog-s3-sequencer-example Rustによるサンプルコードの実装 今回はRustを使ってLambda関数を実装します。Lambda関数の実装にはcargo-lambdaを使用するのが便利です。 https://www.cargo-lambda.info/ cargo-lambdaの詳しい使用方法については割愛します エントリーポイントの作成 cargo-lambdaで初期化コマンドを叩くと以下のようにmain.rsが自動的に生成されます。 cargo lambda init use lambda_runtime::{ run, service_fn, tracing::{self}, Error, }; mod handler; mod image_task; mod lock; mod s3_sequencer; #[tokio::main] async fn main() -> Result<(), Error> { tracing::init_default_subscriber(); run(service_fn(handler::function_handler)).await } handler:function_handler をLambda関数のエントリポイントとして指定しているため、 handler.rs に実装を記述します。 function_handler は以下のように実装します。 use crate::image_task::ImageTask; use aws_lambda_events::event::s3::S3Event; use lambda_runtime::{tracing::info, Error, LambdaEvent}; pub async fn function_handler(event: LambdaEvent<S3Event>) -> Result<(), Error> { // S3イベントをImageTaskに変換する let tasks: Vec<_> = event .payload .records .into_iter() .map(ImageTask::try_from) .collect::<Result<Vec<_>, _>>()?; // futures::future::join_allで実行するタスクを作成する let execute_tasks = tasks.iter().map(|task| task.execute()); // join_allで全てのタスクを実行・待機する // 実行結果をretに格納する let ret = futures::future::join_all(execute_tasks).await; // 実行結果をログに出力する for (t, r) in tasks.iter().zip(&ret) { info!("object_key: {}, Result: {:?}", t.object_key, r); } // エラーがある場合はエラーを返す if ret.iter().any(|r| r.is_err()) { return Err("Some tasks failed".into()); } // 正常終了 Ok(()) } S3イベントのベクタをImageTask構造体のベクタに変換します。変換方法はTryFromトレイトを実装しているため、try_fromメソッドを呼ぶだけで良いです。 ImageTask構造体のベクタを元に、画像処理タスクを作成します。 tokioクレートの join_all 関数を使って、全てのタスクを並列実行します。 3の join_all で帰ってきた結果をログに出力します。 エラーがある場合はエラーを返却し、Lambda関数を異常終了させます。 エラーがなければ正常終了します。 画像処理の実装 1で使用するImageTask構造体は以下のように定義されており、Lambdaの実行に必要な情報を保持しています。 #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(tag = "type")] pub enum TaskType { Grayscale, Delete, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ImageTask { pub bucket_name: String, #[serde(rename = "id")] pub object_key: String, pub sequencer: S3Sequencer, pub task_type: TaskType, pub processing: bool, } フィールド名 説明 bucket_name S3バケット名 object_key オブジェクトキー sequencer S3イベントのsequencer task_type タスクの種類を示す列挙体(Grayscale, Delete) processing 処理中フラグ 具体的な画像処理についてはImageTask構造体のexecuteメソッド内で実装します。 impl ImageTask { pub async fn execute(&self) -> Result<(), Error> { // 1. ロックを取得する let lock = S3Lock::new(&self).await?; // 2. タスクの種類に応じて処理を行う match self.task_type { TaskType::Grayscale => { // 画像をグレースケールに変換し、出力バケットに保存する let body = lock.read_input_object().await?; let format = image::ImageFormat::from_path(&self.object_key)?; let img = image::load_from_memory_with_format(&body, format)?; let img = img.grayscale(); let mut buf = Vec::new(); img.write_to(&mut Cursor::new(&mut buf), format)?; lock.write_output_object(buf).await?; } // 画像を出力用バケットから削除する TaskType::Delete => lock.delete_output_object().await?, } // 3. ロックを解放する lock.free().await?; Ok(()) } } S3のオブジェクトのデータ不整合が起きないように排他処理をかける タスクの種類に応じて処理を行う 元のバケットにファイルが追加された場合は、画像をグレースケールに変換し、出力用バケットに保存する ファイルが削除された場合には、出力用バケットからファイルを削除する 処理が終わったらロックを解放する ロック処理を実装する ロック処理を実装するため、S3Lock構造体を定義します。 pub struct S3Lock { dynamodb_client: aws_sdk_dynamodb::Client, table_name: String, s3_client: aws_sdk_s3::Client, input_bucket_name: String, input_object_key: String, output_bucket_name: String, output_object_key: String, } 具体的なロック取得処理はコンストラクタに実装します。 少しコードが長いですが、ざっくり言うと以下の通りです。 DynamoDBに書き込みが成功したらロックを取得できたとみなす。 書き込みに失敗した場合は2秒ごとにリトライする。 30秒以上ロックが取れない場合はタイムアウトする。 以下にロック処理のシーケンス図を示します。 sequenceDiagram participant ImageTask participant S3Lock participant DynamoDB ImageTask->>S3Lock: ロック取得 loop alt タイムアウト(30秒) S3Lock->>ImageTask: エラー返却(タイムアウト) end S3Lock->>DynamoDB: 処理状況取得 DynamoDB->>S3Lock: 結果返却 alt レコードが存在する場合 S3Lock->>S3Lock: シーケンサ比較 alt 自分自身が古い場合 S3Lock->>ImageTask: エラー返却(スキップ) else 自分自身が新しい場合 S3Lock->>S3Lock: ロック取得リトライ end else S3Lock->>DynamoDB: 条件付き書き込みでロック取得 DynamoDB->>S3Lock: 書き込み結果返却 alt 書き込み成功 S3Lock->>ImageTask: ロック取得成功 else 失敗 S3Lock->>S3Lock: リトライ end end end コンストラクタ内のコードは以下の通りです。 impl S3Lock { pub async fn new(task: &ImageTask) -> Result<Self, Error> { let table_name = std::env::var("DYNAMODB_TABLE_NAME").unwrap(); let output_bucket_name = std::env::var("OUTPUT_BUCKET_NAME").unwrap(); let require_lock_timeout = Duration::from_secs( std::env::var("REQUIRE_LOCK_TIMEOUT") .unwrap_or_else(|_| "30".to_string()) .parse::<u64>() .unwrap(), ); let interval_retry_time = Duration::from_secs( std::env::var("RETRY_INTERVAL") .unwrap_or_else(|_| "2".to_string()) .parse::<u64>() .unwrap(), ); let config = aws_config::load_defaults(aws_config::BehaviorVersion::v2024_03_28()).await; let s3_client = aws_sdk_s3::Client::new(&config); let dynamodb_client = aws_sdk_dynamodb::Client::new(&config); // ロックを取得する // 実行時間を計測する let start = Instant::now(); loop { // 30秒以上ロックが取れない場合はタイムアウトする if start.elapsed() > require_lock_timeout { return Err("Failed to acquire lock, timeout".into()); } // 強力な読み取り整合性を利用してDynamoDBからシーケンサを取得する let item = dynamodb_client .get_item() .table_name(table_name.clone()) .key("id", AttributeValue::S(task.object_key.clone())) .consistent_read(true) .send() .await?; // 取得したアイテムが存在する場合はシーケンサを比較する if let Some(item) = item.item { let item: ImageTask = from_item(item)?; if task.sequencer <= item.sequencer { // 自分自身が古いシーケンサの場合は処理する必要がないのでスキップする return Err("Old sequencer".into()); } // 自分自身が新しいシーケンサの場合は他の処理が終わるまで待機する if item.processing { warn!( "Waiting for other process to finish task, retrying, remaining time: {:?}", require_lock_timeout - start.elapsed() ); thread::sleep(interval_retry_time); continue; } } // DynamoDBに条件付き書き込みでロックを取得する // その際にレコードが存在していたらprocessingフラグがfalseの場合のみ書き込む let resp = dynamodb_client .put_item() .table_name(table_name.clone()) .set_item(Some(to_item(&task).unwrap())) .condition_expression("attribute_not_exists(id) OR processing = :false") .expression_attribute_values(":false", AttributeValue::Bool(false)) .send() .await; // 取得できたらループを抜け処理を続行する // 取得できなかった場合はロックが取れるまでリトライを繰り返す match resp { Ok(_) => break, Err(SdkError::ServiceError(e)) => match e.err() { PutItemError::ConditionalCheckFailedException(_) => { warn!( "Failed to acquire lock, retrying, remaining time: {:?}", require_lock_timeout - start.elapsed() ); thread::sleep(Duration::from_secs(2)); continue; } _ => return Err(format!("{:?}", e).into()), }, Err(e) => return Err(e.into()), } } return Ok(Self { dynamodb_client, output_bucket_name, s3_client, table_name, input_bucket_name: task.bucket_name.clone(), input_object_key: task.object_key.clone(), output_object_key: task.object_key.clone(), }); } } ロックを解除する処理は以下のように実装しており、processingフラグをfalseに更新することでロックを解除します。 impl S3Lock { pub async fn free(self) -> Result<(), Error> { // DynamoDBのロックを解放する // processingフラグのみを更新する self.dynamodb_client .update_item() .table_name(self.table_name) .key("id", AttributeValue::S(self.input_object_key)) .update_expression("SET processing = :false") .expression_attribute_values(":false", AttributeValue::Bool(false)) .send() .await?; Ok(()) } } S3オブジェクトを触るのにロックを強制させたいため、S3LockにS3のオブジェクトを操作するメソッドを生やしています^[S3Lockに生やすと再利用性が低くなりそうだなと感じますが、簡単のため同じ構造体に定義しておきます。もっと良いやり方がありそうですが...]。 impl S3Lock { pub async fn read_input_object(&self) -> Result<Vec<u8>, Error> { // S3オブジェクトを取得する let object = self .s3_client .get_object() .bucket(&self.input_bucket_name) .key(&self.input_object_key) .send() .await?; let body = object.body.collect().await?.to_vec(); Ok(body) } pub async fn write_output_object(&self, buf: Vec<u8>) -> Result<(), Error> { // S3オブジェクトを保存する let byte_stream = ByteStream::from(buf); self.s3_client .put_object() .bucket(&self.output_bucket_name) .key(&self.output_object_key) .body(byte_stream) .send() .await?; Ok(()) } pub async fn delete_output_object(&self) -> Result<(), Error> { // S3オブジェクトを削除する self.s3_client .delete_object() .bucket(&self.output_bucket_name) .key(&self.output_object_key) .send() .await?; Ok(()) } } 実際に動かしてみる サンプルコードを実際に動かしてみましょう。 グレースケールにしたい画像を用意しなければならないわけですが、今回は兵庫県立公園あわじ花さじき^[淡路島にある綺麗なお花畑です。私が撮影しました。 https://awajihanasajiki.jp/about/]の画像を使用します。 インフラ構築 まずはterraform applyしてAWSインフラを構築します。 GitHubレポジトリをクローンして、以下のコマンドを実行してください。 cd terraform # variables.tfやprovider.tfをよしなに修正しておく terraform init terraform apply ![サンプルコードのアーキテクチャ図。S3バケットにアップロードされた画像ファイルをLambda上でグレイスケールに変換して、出力用のS3バケットに保存する。DynamoDBを使ってロック処理を実装する。](/assets/blog/authors/uehara/2024-12-02-how-to-gurantee-s3-event-order/architecture.svg =600x) S3バケットに画像ファイルをアップロード インフラ構築ができたら、入力用のS3バケットに画像ファイルをアップロードします。 アップロードが終わるとLambda関数の処理が始まり、DynamoDBのテーブルにアイテムが追加されます。 処理が終わると出力用のS3バケットに画像ファイルが保存され、DynamoDBのアイテムの processing フラグがfalseになります。 出力用のS3バケットに画像が追加され、グレースケールに変換されていることが確認できます。 S3バケットから画像ファイルを削除 入力用バケットからオブジェクトが削除されると、出力用バケットからもオブジェクトが削除されます。 排他処理が効いているか確認 DynamoDBに追加されるアイテムの processing フラグがtrueになると、処理すべきS3イベントが飛んできたとしてもその処理は待機します。 この挙動を確かめるためにDynamoDBの processing フラグをわざとtrueにして、同じ名前のファイルをアップロードしてみます。 CloudWatch Logsを見ると、新たに発生したS3イベントが処理されずに他の処理の完了を待機していることがわかります。 DynamoDBの processing フラグをfalseに戻すと、処理が再開されます。 排他処理のおかげで、削除イベントとアップロードイベントがほぼ同時に発生しても処理の順序が保証されます。 まとめ 今回はS3イベントの順序を保証するためのシーケンサを利用した、画像処理のサンプルコードを紹介しました。 S3イベントの順序を保証するためには、シーケンサを利用してイベントの順序を比較する必要があります。 自分の趣味でRustでサンプルコードを実装してみましたが、他の言語でも同様の実装が可能なはずです。 ぜひ参考にしてみてください。
アバター
Introduction Hello! My name is Ren.M I work on developing the front end of KINTO ONE (Used Vehicle) . KINTO Technologies Corporation will be serving as the premium sponsor of JSConf JP 2024 , which will be held at the KS Building Kudansakaue in Tokyo on Saturday, November 23, 2024. ■ About JSConf JP 2024 JSConf JP 2024 is a Japanese JavaScript festival organized by the Japan Node.js Association. This will be the 5th JSConf event in Japan. Sponsor Booth Yo can visit our booth and take part in our JavaScript questionnaire! Those who answer the questions can spin the gacha and receive an exclusive novelty gift! Here are pictures of some of them! Paper clips Tote bag Sponsor Workshop In the workshop, we will give a presentation on "Building Vehicle Subscription Services In-House with Next.js and Reflections One Year Later (tentative title)!" Click the link below to learn more! (in Japanese) https://jsconf.jp/2024/talk/kinto-technologies/ We Are Hiring! At KINTO Technologies we’re looking for talented people to join our team! If you’re interested, let’s start with a casual meeting. Even if you're just a bit curious, don't hesitate to apply using the link below! https://hrmos.co/pages/kinto-technologies/jobs/1955878275904303141 Conclusion If you're interested, we'd love for you to visit our booth and join our workshop! We're excited to welcome you to the venue. We will be looking forward to seeing you there!
アバター