【小ネタ】TerraformでAWS WAFのログ取得設定を行う際の注意点

yappli
2022-06-02 01:33:27
[Terraform] [SRE] [AWS]
SREチームのはぶちです。 今回はTerraformからAWS WAFのログ取得設定を行う手順と、ちょっとした注意点について記事にしたいと思います。 はじめに AWS WAFのログの出力設定する際に条件を絞って出力したいケースがあると思います。 その際に、INFO以外の全てのログを取得したい場合は、ログフィルタの条件としてBLOCK、COUNTに加えてEXCLUDED_AS_COUNTも指定していないと取りこぼしてしまうことがあります。 ※詳細はクラスメソッドさんの記事で分かりやすく解説されているのでチェックしてみてください。 AWS WAFでカウントされたログ取りこぼしてませんか? | DevelopersIO しかし2022/6/1現在、EXCLUDED_AS_COUNTはマネジメントコンソールからは指定できず、AWS CLIやAPIからしか設定できません。 でも安心してください。従順なTerraformarの皆さんはTerrafromから簡単に設定することができます。 注意点 TerraformのAWSプロバイダーのバージョンが古いとバリデーションで怒られてしまうようです。 (v3.58.0ではだめでしたが、 terraform init upgrade などを実行して最新のAWSプロバイダーに更新すれば大丈夫です) $ terraform plan Error: expected logging_filter.0.filter.0.condition.2.action_condition.0.action to be one of [ALLOW BLOCK COUNT], got EXCLUDED_AS_COUNT on waf-stg.tf line 144, in resource "aws_wafv2_web_acl_logging_configuration" "example": 100: filter { terraformコードのサンプル 以下のような aws_wafv2_web_acl_logging_configuration を書くことで簡単にInfo以外のログを取得することができます。 wafやログ保存先(S3、CloudWatch Logs)のコードは省略しています。 使用バージョン: tfversion 1.1.9、hashicorp/aws v4.16.0 resource "aws_wafv2_web_acl_logging_configuration" "example" { provider = aws.useast1 log_destination_configs = [<example-arn>] resource_arn = aws_wafv2_web_acl.example.arn logging_filter { default_behavior = "DROP" filter { behavior = "KEEP" condition { action_condition { action = "COUNT" } } condition { action_condition { action = "BLOCK" } } condition { action_condition { action = "EXCLUDED_AS_COUNT" } } requirement = "MEETS_ANY" } } redacted_fields { single_header { name = "cookie" } } redacted_fields { single_header { name = "if-none-match" } } } さいごに 簡単ですが、TerrafrormでAWS WAFのログを取得する際の注意点について書いてみました。 少しでもどこかの迷えるTerraformarのお役に立てればと幸いです。 Terraformさーいっこう!

git-worktreeでmultirepoの開発体験を向上させる

yappli
2022-06-01 02:30:00
サーバーサイドエンジニアの @shuymn です。 2022年5月現在Yappliではサービスのソースコードをサービス単位でリポジトリとして分割するmultirepo*1スタイルでソースコードを管理しています*2。 この記事では、複数ブランチを行き来しながら開発をするときに困ったことと、それに対処する方法として利用したgit-worktreeの紹介に加えてmultirepoでの活用例を紹介します。 実行環境について 本記事に記載しているGitコマンドは以下のバージョンで動作確認をしています。異なるバージョンの場合、動作が異なる可能性がありますので実行前に公式のリファレンスをご確認ください。 $ git --version git version 2.36.1 困ったこと Yappliのソフトウェアエンジニアは常にそのとき所属しているプロジェクトで担当しているタスクだけに取り組むのではなく、Yappdate Dayやインシデント対応などで一時的にそれまで取り組んでいたタスクをストップして別のタスクに取り組むことがあります。 そのような時に良く使われるGitの操作として以下の2つがあります。 git stash : git-stash を使う git stash save <message> でstashエントリに名前を付けることができます git commit -am "wip" : あとで git reset HEAD~ する前提で適当にcommitする 絶対に git reset --hard HEAD~ というように --hard を付けて実行してはいけません これらのサブコマンドも便利ではありますが、どちらも異なるブランチの作業ディレクトリ*3をローカルに同時に存在させることはできません。そのため、一方のブランチで作業しているときに、そのままターミナルマルチプレクサ*4やエディターで別ブランチの内容を開いて作業することができません。 git-worktree では異なるブランチの作業ディレクトリをローカルに同時に存在させるためにはどうしたらよいでしょうか? 簡単に思いつくものとしては、同じリポジトリを別々のディレクトリにcloneするというものがあります。しかしそれらをまとめて管理することはGitコマンドに標準で提供されている機能の範囲では行うことができません。 そこでGit v2.5.0で追加されたgit-worktreeを使います。git-worktreeは複数の作業ディレクトリを管理するためのコマンドです。 add remoteに存在するブランチをローカルの特定のパスにcheckoutしたい場合は以下のように add コマンドを使います。 git worktree add <path> <branch> ローカルに既に同じ名前のブランチが存在する場合、もしくはローカルでは別名を付けているがupstreamブランチとしてremoteに同名のブランチが存在する場合*5はこのコマンドは失敗します。そのため作業途中でgit-worktreeに移動したくなった時は気合いでなんとかする必要があります。気合いの例を書いたのですが、思いのほか長くなってしまったので折りたたみます。 気合いの具体例 ※ここでは派生元をmain、作業ディレクトリのブランチをdevelopとします。 パターン1: ローカルでブランチを切ってから一度もコミットしていない場合 コミット前のすべての差分をファイルに出力します。 git diff HEAD > develop.patch mainブランチに移動します。 git switch main developブランチを削除します。 git branch -D develop 新たなworktreeを追加してブランチ名をdevelopとします。パスは適当です。 git worktree add ~/Works/awesome-app develop パッチファイルを新たに作成されたディレクトリに移動させます。一度もcommitしたことのないファイル*6がある場合はこのタイミングで移動させておくと良いです。 mv develop.patch ~/Works/awesome-app/ 新たに作成したディレクトリに移動した後、パッチを適用します。 cd ~/Works/awesome-app git apply develop.patch 以上です。 パターン2: ローカルでコミットしたことがある場合 パターン1と同じやりかたでコミット前の差分をパッチファイルにする(省略)。 そして、git format-patch を使ってコミットも含めたパッチをつくります。以下の例ではpatchesディレクトリにパッチファイルが出力されます。 // mainから派生させて一度もpushしたことが無い場合 git format-patch main -o patches // remoteにある特定のブランチ(origin/develop)をcheckoutした場合 git format-patch origin/develop -o patches mainブランチに移動し、ローカルのdevelopブランチを削除し、worktreeで再度developブランチを作成し、パッチファイルなどを移動させます(コマンドは割愛)。 この段階で「git diff で作成したパッチ」と「git format-patch で作成したパッチ」の2種類が存在しますが、この適用順は「git format-patch で作成したパッチ」→「git diffで作成したパッチ」の順番にします。後者については適用方法は前述の通りのため割愛します。前者については以下の通りです。 git am patches/* 以上です。このように、コミットしてしまったブランチを後からworktreeで復活させるのはまあまあ大変です。 remove worktreeを追加するコマンドを紹介したので、次は削除するコマンドを紹介します。 git worktree remove <path> もしそのworktreeでコミットしていない変更がある場合は実行に失敗しますので、安心してください。 multirepoでの活用例 次にmultirepoでの活用例を紹介します。 特定のタスクに取り組む時に、複数リポジトリを横断して開発を行うことがあります。そのような時に、自分は以下のようなディレクトリ構成となるようにしています。 branch-1 ├── repo-a ├── repo-b └── repo-c このようなディレクトリ構成にすることで、隣り合うディレクトリにあるリポジトリのブランチが統一され、開発がしやすくなります。また、ブランチ名とリポジトリ名が分かればworktreeのパスを決定することができるので、git-branchコマンドなどと組み合わせてremoteで削除済みのブランチのworktreeを削除するということもできるようになります。 さいごに git-worktreeについて基本のコマンド2つを紹介しました。紹介しなかったコマンドやオプションもいくつかありますので、気になった方やより発展的なユースケースを知りたい方は公式リファレンスのgit-worktreeの項目を確認してください。 Yappliでは全方面でソフトウェアエンジニアを募集中です。もしYappliに興味がありましたら是非カジュアル面談でお話しましょう! open.talentio.com *1:polyrepoという呼称もありますが、本記事ではmultirepoで統一します。 *2:異なるスタイルとして、1リポジトリで複数のサービスのソースコードを管理するスタイルをmonorepoがあります。 *3:Working Treeのこと *4:tmuxなど。最近ではiTerm2などのターミナルエミュレータの機能として存在することもあります。 *5:git checkout -b branch_b origin/branch_a というようにすると作れます(remoteがoriginの場合)。 *6:Untracked Files

Goのnet/httpパッケージを理解する

yappli
2022-05-16 09:16:09
はじめに こんにちは、サーバーサイドエンジニアの喜田です。 Goにはnet/httpというHTTP クライアントとサーバーの実装を提供してくれるパッケージがあります。 普段の開発でnet/httpパッケージを使っているのですがGoの経験がまだ浅いということもあり、仕様の理解が甘いなと感じていました。 そこで、本記事ではnet/httpパッケージが内部的にどんな処理をしているのか簡単に見ていき、理解を深めたいと思います! 簡単なサーバーを実際にたててみる まずは、簡単なサーバーをたててみます。 package main import ( "fmt" "net/http" ) func main() { h1 := func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "Hello from h1!\n") } h2 := func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "Hello from h2!\n") } http.HandleFunc("/", h1) http.HandleFunc("/h2", h2) if err := http.ListenAndServe(":8080", nil); err != nil { panic(err) } } コードを走らせるとサーバーが立ち上がるので、下記のリクエストを送ってみると値が返ってくるのが確認できます。 $ curl http://localhost:8080 Hello from h1! $ curl http://localhost:8080/h2 Hello from h2! 上記のコードで重要なのは、主にHandleFuncとListenAndServeの二つになります。 それらがどういうものなのか見ていきましょう! HandleFunc まず、概要を公式ドキュメントで確認します。 func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) HandleFunc registers the handler function for the given pattern in the DefaultServeMux. The documentation for ServeMux explains how patterns are matched. pkg.go.dev HandleFuncは与えられたパターンに対応するhandler関数をDefaultServeMuxに登録するもののようです。 上記のコードで言うと"/"などのpathが与えられたパターンでh1,h2などの関数がhandler関数ということになります。 では、DefaultServerMuxとはなんでしょうか? Handler DefaultServerMuxを説明する上でHandlerとは何かということを知る必要があるため、先にこちらの説明を少しだけします。 HandlerとはそれぞれのHTTPリクエストを捌くためのもので以下のinterfaceを満たす必要があります。 type Handler interface { ServeHTTP(ResponseWriter, *Request) } pkg.go.dev 非常にシンプルなものでServeHTTP(ResponseWriter, *Request)メソッドを実装した型であればHandlerとして扱えるようです。 DefaultServeMux DefaultServeMuxが定義されている箇所のソースコードを見てみましょう。 // DefaultServeMux is the default ServeMux used by Serve. var DefaultServeMux = &defaultServeMux var defaultServeMux ServeMux https://cs.opensource.google/go/go/+/refs/tags/go1.18.1:src/net/http/server.go DefaultServeMuxはデフォルトのServeMuxであり、ServeMuxとはHTTP requestのマルチプレクサーのことです。 つまり、DefaultServeMuxはpathに対応するHandlerにroutingを行うために必要なもののようです。 ここまでの情報で、HandleFuncが引数で受け取ったpatternにhandlerを登録しているものだと理解できました。 次に、ListenAndServeを見ていきます。 ListenAndServe 同様にまず、概要を確認します。 ListenAndServe listens on the TCP network address srv.Addr and then calls Serve to handle requests on incoming connections. ListenAndServeは引数で受け取ったTCPネットワークのアドレスであるsrv.Addrで通信をリッスンし、来たコネクションに対してリクエストを捌くためにServeメソッドを呼ぶもののようです。 実際に、ソースコードを見ていきます。一部抜粋しコメントを入れています。 func ListenAndServe(addr string, handler Handler) error { server := &Server{Addr: addr, Handler: handler} return server.ListenAndServe() } // http.ListenAndServeでServer.ListenAndServeを呼んでいる部分のコード func (srv *Server) ListenAndServe() error { if srv.shuttingDown() { return ErrServerClosed } addr := srv.Addr // addrが空だったら:httpアドレスを指定 if addr == "" { addr = ":http" } // tcpコネクションのリスナーを引数で渡しているアドレスで作成 ln, err := net.Listen("tcp", addr) if err != nil { return err } return srv.Serve(ln) } https://cs.opensource.google/go/go/+/refs/tags/go1.18.1:src/net/http/server.go 次に、Serveメソッドの中身を見ていきます。 一部抜粋 func (srv *Server) Serve(l net.Listener) error { baseCtx := context.Background() ctx := context.WithValue(baseCtx, ServerContextKey, srv) for { rw, err := l.Accept() connCtx := ctx c := srv.newConn(rw) go c.serve(connCtx) } } https://cs.opensource.google/go/go/+/refs/tags/go1.18.1:src/net/http/server.go まず、contextを作り、無限forループの中でnet.Conn型(コネクション) を待つためにAccept()を呼んでいます。 そして、newConn()リスナーから取得したnet.Conn型からhttp.conn型のstruct(新しいコネクション)が生成されます。 最後に、http.conn型のserveメソッドが新しいgoroutineで呼ばれています。 では、http.conn型のserveメソッドの中身を見ていきましょう。 一部抜粋 // Serve a new connection. func (c *conn) serve(ctx context.Context) { c.remoteAddr = c.rwc.RemoteAddr().String() ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr()) // HTTP/1.x from here on. ctx, cancelCtx := context.WithCancel(ctx) for { w, err := c.readRequest(ctx) serverHandler{c.server}.ServeHTTP(w, w.req) w.cancelCtx() w.finishRequest() } } readRequest()でリクエストをコネクションから読み込み、http.response型で受け取ります。 そして、serverHandlerのServeHTTPメソッドが呼ばれます。 一部抜粋 func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) { handler := sh.srv.Handler if handler == nil { handler = DefaultServeMux } handler.ServeHTTP(rw, req) } ServeHTTPでは先ほど見たようにhandlerがnilだった場合はDefaultServeMuxが代入され、Handler interfaceのServeHTTPが呼ばれています。 まとめると、ListenAndServeでは以下のことをやっているようです。 リスナーを作成し、コネクションを確立させリクエストを待ち受ける。 引数で受け取ったHandlerのServeHTTP()の呼び出し、nilだった場合はDefaultServerMuxのServeHTTP()を呼ぶ。 まとめ この記事ではHandleFuncとListenAndServeの内部でどんなことが行われているかを簡単に見てきました。 思った以上に様々なことをやっており、まだまだ触れらていないことも多いですが、その一部だけでもしれて楽しかったです!

SRE NEXT 2022でヤプリのセキュリティの取り組みについて発表しました

yappli
2022-05-16 02:41:51
ヤプリの望月です。 最近結婚10年を迎えましたが、スイートテンダイヤモンドという古来からある風習の圧力に屈していました。 話は変わりますが、先日2022-05-14,15に開催されたSRE NEXT 2022で、ヤプリのセキュリティの取り組みについて発表させていただきました。 speakerdeck.com 発表の概要 発表内容としては、これまでヤプリのSREが取り組んできたセキュリティの取り組みについてお話しさせていただいております。 現在ヤプリにはセキュリティエンジニアが在籍していません。 そういった状況のなか、SREに限らずIT(情シス)エンジニアやサーバーサイドエンジニアが、個々の強みを活かしながら会社全体としてセキュリティを推進しています。 open.talentio.com 振り返ってみると、セキュリティ対策が進んだきっかけの一つとして、2020-12の株式上場が挙げられます。 経営陣以下セキュリティというキーワードが社内でフォーカスされましたし、事業拡大に伴い多種多様なクライアントのセキュリティ要件が出てきていました。 専任のセキュリティエンジニアがいなくとも、試行錯誤しながら様々なセキュリティ対策を行ってきたヤプリの事例が、同じような悩みを抱える皆さんの活動に少しでも役に立てば幸いです。 正直きちんとセキュリティに取り組まれているSREさんから見ると、あたり前のことをやっているだけでしょという感想を持たれるかもしれません。 一方で我々が取り組んで感じたのは「SREにおけるセキュリティって何をどこまでやったら良いか分からず、最初の一歩が踏み出しづらかったり中途半端になったりしてしまう」ということです。 上場に迫られて急遽実施した様々な対策 セキュリティというキーワード先行で導入したものの活用できていなかった取り組みを改善した事例 セキュリティ対策のフレームワーク選定と自社へ適用の道筋 AWSアカウントが増えるなかでセキュリティにムラが生じた課題 みたいな実際の振り返り・失敗も紹介しておりますので、似たような悩みを抱えている方のヒントになればと思います。 感想 今回はCFPを出して、一般公募セッションを担当させていただきました。 はじめての経験で最初CFP出すか出さないか迷ったのですが、今ではやってみて良かったという感想しかありません。 セッションの開始直前までネガティブな反応が多かったらどうしようと変な緊張をしていたのですが、Q&AやTwitterでポジティブな反応もいただけてとても救われました。 コメントいただいた皆さまありがとうございます! セッションについてはオンライン開催で事前収録形式でした。 何度か収録し直して、時間調整や言葉のつっかえを減らせたことが良かったです。 (会心の出来だったのに、洗濯機の音が入っていてボツになったという悲しさも体験しました) 一方で事前に締め切りがあるので、けっこうタイトなスケジュールになったという裏もあります。 引き続き、機会をいただける限り様々な形で、ヤプリSREの取り組みを発表していければなと考えています。 最後に 今回SRE NEXT 2022に参加して、同じように課題に向き合い解決している仲間がたくさんいるんだと勇気をもらえました! あらためて自分達の活動を見直すヒントをたくさんいただけましたし、時間がかさなって視聴できなかった他のセッションもチェックしたいです。 (2022-05末くらいにアーカイブ配信があるそうです!) 最後に運営の皆さま・関係者の皆さま、素敵なイベントを開催していただきありがとうございました!! SREやSREに関わる皆さま、NEXTでまた会いましょう。

Go Conference 2022 SpringでPHPからGoへの移行について発表しました

yappli
2022-04-28 09:22:12
サーバーサイドエンジニアの森谷です。 Go Conference 2022 Springに"Go"ldスポンサーとして登壇してきましたので、その後日談記事になります! 登壇内容 「創業以来のPHPシステムが生み出した混沌をGoへの移行で乗り越えた話」について発表しました。 gocon.jp (このブログ記事の執筆時にはまだありませんが、いずれ上記GoConページにアーカイブも上がってくると思います。) ヤプリは2017年から約3年をかけてCMSの大規模刷新を行いました。 その際にPHPからGoへと言語も移行しましたが、静的型付け言語への切り替えということで、特にDBからの値の取得にチーム一同かなり苦戦した記憶があります。 その辺りの技術的な話だったり、また社内勉強会が最近いい具合に回っているのでその辺の共有だったりを発表してきました。 感想 人生初登壇がこの大舞台ということでかなり緊張して迎えた登壇でしたが、PHPからGoへの移行というトピックの関心がやはり高いというのもあってか、Twitterなどでも熱い反応を得られて非常に満足というか安心していますw またこの手の話をする際には、「移行前は◯◯という技術的負債がありました、だから移行しました」というストーリーで話をすることになりますが、これはともすれば「移行前は技術力がなかった」と誤解されそうだなと感じていました。 この点について「いやいやすごいシステムなんです」という話も発表の中でお伝えしたのですが、多くの共感などをいただけて嬉しかったなと思っています。 社内勉強会についてもいいリアクションをいただけました! 最後に 登壇準備にかなりの時間を割いたのですが、それもプロジェクトやサーバーチームのメンバーが僕の仕事をかなり肩代わりしてくれたためこなすことができたと思っています。 また、特にこちらからお願いしたわけでもないのに、ご好意で登壇資料の添削をtenntennさんにしていただいたりもしました。 その他、GoCon運営との調整を進めてくださった採用担当チーム含め、ご協力していただいた皆様、本当にありがとうございました。 今回のGoConを皮切りに、Goコミュニティへ会社としてどんどん貢献していけたらなと思っています!

Go Conference 2022 Springに"Go"ldスポンサーとして協賛・登壇します!

yappli
2022-04-22 07:34:17
サーバーサイドエンジニアの森谷です。 もうすぐGo Conference 2022 Springが始まりますね。 前回とはまた打って変わったセッションが並んでおり非常に楽しみです! gocon.jp ヤプリはGo Conference 2022 Springに"Go"ldスポンサーとして協賛しています!! ヤプリは創業の2013年からPHPを使っていましたが、2018年から段階的にGoを使い始め、今ではGoがメインと言えるほどに移行が完了しました。 そんなGoへの移行で苦労したことや、Goを使ってみての感想などをセッションで話す予定です。 2022/04/23 10:30〜 Track A (20分) 創業以来のPHPシステムが生み出した混沌をGoへの移行で乗り越えた話 gocon.jp 技術的に苦労した話や、社内勉強会「GoStudy」でのコードリーディング会の様子などのトピックを話す予定です。 興味を抱いていただけましたらご視聴いただけると嬉しいです! これからも企業としてGoコミュニティに貢献していきますので、よろしくお願いします! もしご興味がありましたら是非カジュアル面談でお話しましょう!! open.talentio.com

PHPerKaigi 2022 にダイアモンドスポンサーとして協賛しています!

yappli
2022-04-07 01:00:00
サーバーサイドエンジニアの田実です。 もうすぐ PHPerKaigi 2022 が始まりますね! 今回も面白そうなセッションばかりで非常に楽しみです。 phperkaigi.jp ヤプリは PHPerKaigi 2022 にダイアモンドスポンサーとして協賛しています!! ヤプリは創業期からPHPを使っているのですが、PHP系のイベントに協賛するのは初だったりします。 (軽い気持ちでスポンサーどうっすか?って打診したらダイアモンドスポンサーでいきましょう!となってフットワーク軽くて良い会社w ちなみに私もこちらのセッションで発表させていただきます! 2022/04/10 16:05〜 Track B レギュラートーク(20分) DBGpを使ってPHPのデバッガーをつくろう fortee.jp DBGpというマニアックな分野になりますがXdebug初心者から上級者(?)まで楽しめる内容かと思いますので是非視聴いただけると嬉しいです! これからも企業としてエンジニアコミュニティに貢献していきますので、よろしくお願いします! もしご興味がありましたら是非カジュアル面談でお話しましょう!! open.talentio.com

Goのsqlxパッケージを理解する

yappli
2022-04-01 05:01:45
サーバーサイドエンジニアの森谷です。 前回の記事に続いて、今回はsqlxパッケージについて見ていこうと思います。 簡単なおさらい 前回はdriverパッケージとsqlパッケージを読みました。 driver.Driver インターフェースを満たす型を実装し sql.Register でsqlパッケージに登録すると、 sql.Query 実行時にこのDriverに定義した処理に従った sql.Rows を得られることがわかりました。 しかし sql.Query で実現できる処理はここまでで、例えばSELECT結果を自前のUser構造体の各フィールドに当てはめるといった処理はこれ単体ではできません。 その処理を行なってくれるsqlxパッケージを今回は見ていきます。 sqlパッケージのScanとScannerについて sqlxパッケージの説明に入る前に、まずこれらについて見ていこうと思います。 sql.Query 単体では任意の型に値を詰めることは出来ないことを述べましたが、実現する方法自体は提供されています。 それが Rows.Scan メソッドと Scanner インターフェースです。 func (rs *Rows) Scan(dest ...any) error type Scanner interface { Scan(src any) error } https://cs.opensource.google/go/go/+/refs/tags/go1.18:src/database/sql/sql.go;l=3206-3293 https://cs.opensource.google/go/go/+/refs/tags/go1.18:src/database/sql/sql.go;l=395-416 Rows.Scan は対象のカラムの数だけdestを渡し、 Rowsごと(各レコードごと)・各カラムごとにループし それぞれの値がScan可能であればdestに詰める ことを行なっています。 (ちなみに、anyはGo1.18で導入された interface{} の型エイリアスです。既にsqlパッケージにはこのように1.18で新登場したコードが現れています。) この「Scan可能」の条件は大きく2つあり、1つ目は srcが string でdestが *string srcが string でdestが *[]byte srcが time.Time でdestが *time.Time etc... といった具合に、あらかじめsqlパッケージ内で実装されているsrc, destの型の組み合わせであることです。 string, []byte, bool, int系, float系など基本的な型はサポートされています。 (詳しくはScanのドキュメントもしくはswitch caseがゴリゴリに書かれているこちらのソースを参照ください。) 条件の2つ目は、destで渡した型が Scanner インターフェースを実装していることです。 上記で網羅されていない型の場合、次点の処理として if scanner, ok := dest.(Scanner); ok { return scanner.Scan(src) } が実行されます。 前者の一覧に構造体は含まれていないため、たとえば「json型のカラムを構造体に変換したい」といった場合には、その構造体に対し自前でScanメソッドを実装する必要があります。 また前述の通り、そもそもこのsqlパッケージのScanはカラムの数だけdestを渡さなければなりません。つまり、 rows.Scan(user.ID, user.Name, user.Email, user.CreatedAt, user.UpdatedAt) のようなコードを書く必要があります。 (念の為補足しますと、こちらはレコード1行に対するScanですので、複数行に対して実行するならば for rows.Next() {...} で包む必要もあります。) こういった面倒な部分を担ってくれているのがsqlxパッケージです。 ということで、sqlxパッケージの中身を見ていきましょう。 Open 今回もOpenから処理を見ていきましょう。 と言っても sqlx.Open は実にシンプルで、ほぼ sql.Open のラッパーです。 返却される sqlx.DB 型も sql.DB をラップした構造体になります。 func Open(driverName, dataSourceName string) (*DB, error) { db, err := sql.Open(driverName, dataSourceName) if err != nil { return nil, err } return &DB{DB: db, driverName: driverName, Mapper: mapper()}, err } type DB struct { *sql.DB // sql.DBのラッパーであることに注目 driverName string unsafe bool Mapper *reflectx.Mapper } https://github.com/jmoiron/sqlx/blob/92bfa368c21aafd33626444a47b2f99be2432623/sqlx.go#L261-L267 https://github.com/jmoiron/sqlx/blob/92bfa368c21aafd33626444a47b2f99be2432623/sqlx.go#L240-L247 Select DB.Select の処理は以下になります。 func (db *DB) Select(dest interface{}, query string, args ...interface{}) error { return Select(db, dest, query, args...) } func Select(q Queryer, dest interface{}, query string, args ...interface{}) error { rows, err := q.Queryx(query, args...) if err != nil { return err } defer rows.Close() return scanAll(rows, dest, false) } https://github.com/jmoiron/sqlx/blob/92bfa368c21aafd33626444a47b2f99be2432623/sqlx.go#L314-L318 https://github.com/jmoiron/sqlx/blob/92bfa368c21aafd33626444a47b2f99be2432623/sqlx.go#L668-L681 ざっくりSelectの仕事をまとめると、 受け取った Queryer インターフェースに従ってqueryを実行して Rows 型を構築し dest(これはsliceであるべき引数です)に対しscan処理を実行し値を嵌め込む の2つです。 順に見ていきましょう。 Queryの実行とRowsの構築 Select関数の方の第1引数では Queryer を受け取る作りになっています。 これは以下のinterfaceです。 type Queryer interface { Query(query string, args ...interface{}) (*sql.Rows, error) Queryx(query string, args ...interface{}) (*Rows, error) QueryRowx(query string, args ...interface{}) *Row } type Rows struct { *sql.Rows // sql.Rowsのラッパーであることに注目 unsafe bool Mapper *reflectx.Mapper started bool fields [][]int values []interface{} } type Row struct { err error unsafe bool rows *sql.Rows // sql.Rowsのラッパーであることに注目 Mapper *reflectx.Mapper } https://github.com/jmoiron/sqlx/blob/92bfa368c21aafd33626444a47b2f99be2432623/sqlx.go#L77-L82 https://github.com/jmoiron/sqlx/blob/92bfa368c21aafd33626444a47b2f99be2432623/sqlx.go#L572-L582 https://github.com/jmoiron/sqlx/blob/92bfa368c21aafd33626444a47b2f99be2432623/sqlx.go#L165-L172 DB型がこのQueryerを満たしていることを確認しておきましょう。 まずQueryについてですが、これは sql.DB が持っているメソッドです。 package sql func (db *DB) Query(query string, args ...any) (*Rows, error) { return db.QueryContext(context.Background(), query, args...) } https://cs.opensource.google/go/go/+/refs/tags/go1.17.8:src/database/sql/sql.go;l=1685-1692 sqlx.DB は sql.DB を埋め込みで保有していることを思い出してください。 よって、sqlx.DB 自体もQueryメソッドを持っているものとして振る舞うことができます。 Queryx, QueryRowxについてはxのサフィックスがついていることからわかるように、sqlxパッケージ側で定義されているメソッドになります。 func (db *DB) Queryx(query string, args ...interface{}) (*Rows, error) { r, err := db.DB.Query(query, args...) if err != nil { return nil, err } return &Rows{Rows: r, unsafe: db.unsafe, Mapper: db.Mapper}, err } func (db *DB) QueryRowx(query string, args ...interface{}) *Row { rows, err := db.DB.Query(query, args...) return &Row{rows: rows, err: err, unsafe: db.unsafe, Mapper: db.Mapper} } https://github.com/jmoiron/sqlx/blob/92bfa368c21aafd33626444a47b2f99be2432623/sqlx.go#L346-L361 これらも sqlx.Open のように、 sql.Query のラッパーかつ sql.Rows をラップした sqlx.Rows や sqlx.Row を返却するメソッドになっています。 つまるところ、 DBドライバー由来のQueryメソッドを実行し、諸々の情報を加えた上でラップした構造体を返す ということを行なっているだけです。 Rowsをdestにscanする この部分がsqlxパッケージの恩恵が大きい箇所の1つです。 Selectの最後で呼ばれていたscanAllを見ましょう。 ここのコードはreflectがふんだんに使用されたハードボイルドな内容になっているので、大幅に省略しつつコード中にコメントを差し込んで解説します。 // scanAllの第1引数が rowsi インターフェースになっていることや // 第3引数に structOnly というbooleanを受け取っている理由は、 // このscanAllが別の関数からも呼ばれているためです。 // が、今は第1引数に *Rows 型が渡されていることだけ理解し、 // さらに第3引数は無視して読み進めていただいて構いません。 func scanAll(rows rowsi, dest interface{}, structOnly bool) error { // reflectでゴニョゴニョ頑張っている部分。略。 // baseはdestが何の型のsliceかという情報(reflect.Type)。 // isScannableはboolを返し、baseが // * 構造体でないか // * sql.Scannerを実装しているか // * 公開フィールドを持っていない // 場合にtrueを返します。 scannable := isScannable(base) // いくつかのエラーチェック処理。略。 // scannableではない場合、つまりScannerを実装しておらず公開フィールドをもつ構造体の場合。 if !scannable { // reflectでゴニョゴニョ頑張ったりunsafeのケアを頑張っている部分。略。 for rows.Next() { // reflectでゴニョゴニョ頑張ってvalues []interface{} を整えている部分。略。 // sqlパッケージに定義されているScanを実行。 // ここが、 // `rows.Scan(user.ID, user.Name, user.Email)のようなコードを書かなければなりません` // と前述した部分の面倒臭さを肩代わりしてくれている箇所です! err = rows.Scan(values...) if err != nil { return err } // directは reflect.Indirect(reflect.ValueOf(dest))。 // 要するにdestのsliceにappendしているだけのコードです。 if isPtr { direct.Set(reflect.Append(direct, vp)) } else { direct.Set(reflect.Append(direct, v)) } } // scannableな場合。 } else { for rows.Next() { // baseがScannerインターフェースを満たしていれば、sqlパッケージ側でそれを用いてくれる。 vp = reflect.New(base) err = rows.Scan(vp.Interface()) if err != nil { return err } // destのsliceにappend if isPtr { direct.Set(reflect.Append(direct, vp)) } else { direct.Set(reflect.Append(direct, reflect.Indirect(vp))) } } } return rows.Err() } https://github.com/jmoiron/sqlx/blob/92bfa368c21aafd33626444a47b2f99be2432623/sqlx.go#L880-L989 まとめ 前回の記事とあわせて、 driver.Driver インターフェースとして何を実装すればsqlパッケージに登録できるのか sqlパッケージでのDriverの扱い方 sql.Scanner インターフェースで独自のScan処理を反映させられること sqlxパッケージにおけるScan処理の拡張 を見てきました。 これまで雰囲気でsqlxパッケージを利用していたので、それぞれのパッケージの仕事内容を整理できたのは面白かったです。 (Scanメソッド自体は業務コードで見知ってはいたのですが、sqlパッケージで定義されているinterfaceだとは知らず衝撃でした。) おまけ もはやsql, sqlxパッケージを調べようとした動機を忘れかけていますが、発端は「SELECT結果をモックしてScanの挙動を色々検証したい」ということでした。 sqlパッケージだけを読んでいた時点では、「わざわざdriverを実装しないと難しいのかな...」と思っていましたが、 func Select(q Queryer, dest interface{}, query string, args ...interface{}) error を実行すれば良いことがわかり、つまり単にQueryerを満たす独自の型さえ実装すればやりたいことが実現できそうです!

デザインシステム構築に向けた Figma API によるCSSカスタムプロパティの自動生成

yappli
2022-03-31 01:27:19
[フロントエンド] [UI/UX]
こんにちは。フロントエンドエンジニアの小林(baco16g)です。 この記事では、YappliのCMSにおけるデザインシステムを構築に向けた準備をしている話を、エンジニア視点でお伝えします。 デザインシステムが求められた背景 YappliのCMSは、2019年にシステム・デザインともに刷新されました。しかし、開発着手からは約4年の歳月が流れ、当初の実装ガイドラインはもはや形骸化し始めています。 実装ガイドラインが形骸化し、明確なルールが無い状態で異なるプロジェクトの実装が進んだ結果、実装されたプロダクトとデザインファイルに乖離が生まれてしまいました。例えば、下記のような乖離です。 TypographyやSpacingなどのデザイントークンの値が異なる デザイントークンの命名が、実装とデザインファイルが異なる デザインファイルに存在しないUIコンポーネントが実装には存在する TypographyやSpacingの乖離の一例 このような実装とデザインファイルの乖離により、アウトプットのデザイン自体の品質と、それを是正するためのエンジニアとデザイナー間のコミュニケーションコストが増してしまいます。 CMSの開発に新たなプロダクトデザイナーがジョインした事をきっかけに課題が表面化し、デザインシステムが求められるようになりました。 note.com まずはデザイントークンからSyncする デザイントークンとは、色、余白、行間、フォント、フォントサイズ、シャドウなどの最小単位のスタイルの情報を定数として定義したものです。UIコンポーネントは、これらのデザイントークンを組み合わせて構築されるため、デザイントークンはデザインシステムを構築する上で欠かせない要素です。 今回は「Figmaからデザイントークンをどのように書き出したのか」をお伝えします。 Figmaからデザイントークンをどのように書き出したのか Figma APIからJSON形式でFigma Stylesを取得して、CSSカスタムプロパティを自動定義する仕組みを作りました。 今回はFigma APIに型定義を加えた figma-api を使用しました。下記ではTypography一覧を取得する処理を抜粋して掲載しています。 www.npmjs.com import { Api as FigmaAPI, Node } from 'figma-api'; export const listStyles = async (token: string, fileId: string) => { try { const api = new FigmaAPI({ personalAccessToken: token }); // 指定したファイルIDのFigma Stylesを取得 const styles = await api.getFileStyles(fileId).then(data => data.meta?.styles || []); const nodeIds = styles.filter(style => style.style_type === 'TEXT').map(t => t.node_id); const typographies = await getTypographies( api, fileId, nodeIds ); return { typographies }; } catch (e) { return null; } }; /** * Figma StyleのNodeIDからNode情報を取得 */ const getTypographies = async ( api: InstanceType<typeof FigmaAPI>, fileId: string, nodeIds: string[] ) => { if (nodeIds.length === 0) return []; return api.getFileNodes(fileId, nodeIds).then(file => Object.entries(file.nodes) .map(node => [node[0], node[1]?.document]) .filter((node): node is [string, Node<'TEXT'>] => !!node[1]) .map(mapTextNode); ); }; /** * Nodeのスタイル情報をマッピングして返す */ const mapTextNode = ([nodeId, node]: [string, Node<'TEXT'>]) => { return { nodeId, styleName: node.name, fontFamily: node.style.fontFamily, fontSize: `${node.style.fontSize / 10}rem`, fontWeight: node.style.fontWeight, lineHeight: Math.round(node.style.lineHeightPercentFontSize || 100) / 100, }; }; 続いて、取得したFigma StylesをCSSファイルに出力します。 export const buildTypography = (typographies: Typography[]) => { const cssCustomProperties = typographies.reduce((acc, curr) => { const name = formatToStyleName(curr.styleName); return `${acc} --typography-${name}-font-family: "${curr.fontFamily}", sans-serif; --typography-${name}-font-size: ${curr.fontSize}; --typography-${name}-font-weight: ${curr.fontWeight}; --typography-${name}-line-height: ${curr.lineHeight}; --typography-${name}: ${curr.fontWeight} ${curr.fontSize}/${curr.lineHeight} var(--typography-${name}-font-family); `; }, ''); return formatScss(dedent` :root { ${cssCustomProperties} } `); }; const formatToStyleName = (s: string) => { // CSSカスタムプロパティ名で扱える文字列にフォーマットします } 最終的に出力されたファイルの抜粋がこちらです。 // ------------------------------------------------------ // THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) // ------------------------------------------------------ :root { --typography-body-01-long-font-family: 'Noto Sans JP', sans-serif; --typography-body-01-long: 400 1.6rem/1.5 var(--typography-body-01-long-font-family); } :root { // ------------------------------------------------------ // プリミティブトークン - 使用を推奨していません。エイリアストークンを使用してください。 // ------------------------------------------------------ --color-core-blue-04: #59bfee; --color-core-blue-05: #00a9e0; --color-core-blue-06: #35a6dc; --color-core-blue-07: #3892bc; // ------------------------------------------------------ // エイリアストークン - 基本的にはこちらの使用してください。 // ------------------------------------------------------ --color-alias-brand: var(--color-core-blue-05); --color-alias-primary-default: var(--color-core-blue-06); --color-alias-primary-focus: var(--color-core-blue-07); --color-alias-primary-hover: var(--color-core-blue-04); } // 適用例 p { color: var(--color-alias-primary-default); font: var(--typography-body-01-long); } 従来ではデザイントークンをSCSS変数で管理していましたが、Vueファイルのテンプレート内で利用する場合や将来的なダークモードの対応を見据えると、SCSS変数では柔軟な対応が難しいと判断し、今回を機にCSSカスタムプロパティへ変更しました。 また、Typographyは 一括指定プロパティのfontで利用することを想定しています。これは、MDN Web Docsの実装を参考にしました。 おわりに 冒頭でお伝えした通り、「デザインシステムを構築に向けた準備をしている話」なので、未だ実適用は完了していません。デザインファイルと実装で明確なデザイントークンを定義・運用することで、今後のプロダクトのデザイン品質向上と、大幅な開発の効率化が見込まれます。 まだまだ理想のデザインシステム構築を完了するまで多くの作業が残ってますが、プロダクトをより良くするために引き続き対応を進めていきます!

Goのsqlパッケージを理解する

yappli
2022-03-31 01:21:57
サーバーサイドエンジニアの森谷です。 弊社ではDBの一部にSQLiteを使っており、SQLiteからのSELECT結果を構造体にパースする部分の処理について調べごとをしていたのですが、その中で「SELECT結果をモックできたら楽に手元で色々確認できないかな」という疑問が湧いてきました。 ということで(?)sqlとsqlxパッケージを理解し、モックができるのかどうか見ていこうと思います! なお、sqlパッケージのみで既に巨大な記事になってしまったため、今回はsqlパッケージのみを対象とし、次回の記事でsqlxパッケージに触れようと思います。 本記事の内容について 触れること sqlパッケージにおいて各種ドライバーごとのクエリがどのように実行され解釈されるかを見ていきます。 触れないこと コネクションの管理やゴルーチン間のロックの管理にまで手を出すと記事のボリュームが5倍くらいになりそうなので、これらについては残念ながら割愛させていただきます。 まずはDriverの登録処理を追ってみる まず初めにmysqlドライバーを参考にコードを追ってみます。 各ドライバーを使用する際にはimport文にてpackageを指定します。 ただし、このpackageの関数などを我々が直接使用するわけではないため _ でimportします。 import _ "github.com/go-sql-driver/mysql" packageが初めてimportされた時、そのpackage内のinit関数が実行されます。 packageを直接使用しないのにimportする理由は、このinitを実行したい(逆に言うと、このinitさえ実行できれば全てが完結する)からです。 func init() { sql.Register("mysql", &MySQLDriver{}) } https://github.com/go-sql-driver/mysql/blob/bcc459a906419e2890a50fc2c99ea6dd927a88f2/driver.go#L83-L85 sql.Register は第2引数に driver.Driver を取り、sqlパッケージ直下の drivers map[string]driver.Driver 変数に追加していきます。 https://cs.opensource.google/go/go/+/refs/tags/go1.17.8:src/database/sql/sql.go;l=44-54 driver.Driver は 以下のinterface型です。 type Driver interface { Open(name string) (Conn, error) } https://cs.opensource.google/go/go/+/refs/tags/go1.17.8:src/database/sql/driver/driver.go;l=84-95 Openで返却されるdriver.Conn は以下のinterface型です。 type Conn interface { Prepare(query string) (Stmt, error) Close() error Begin() (Tx, error) } https://cs.opensource.google/go/go/+/refs/tags/go1.17.8:src/database/sql/driver/driver.go;l=230-251 さらに辿って、このPrepareで返却される driver.Stmt は以下のinterface型です。 type Stmt interface { Close() error NumInput() int Exec(args []Value) (Result, error) Query(args []Value) (Rows, error) } https://cs.opensource.google/go/go/+/refs/tags/go1.17.8:src/database/sql/driver/driver.go;l=326-358 このQueryが driver.Rows を返却することを覚えておいてください。 mysqlパッケージの例で整理すると、 初めにinit関数の中で登録していた MySQLDriver は driver.Driver を満たす、つまり Open(string) (driver.Conn, error) メソッドを実装している MySQLDriver.Open は driver.Conn を満たす mysqlConn 型を返す作りになっている mysqlConn.Prepare は driver.Stmt を満たす mysqlStmt 型を返す作りになっている という実装になっています。 (省略していますが、各interfaceの残りのメソッドについても同様です。) sqlパッケージのQuery処理を追う DBインスタンスの生成(Open) sql.Open は次の関数です。 func Open(driverName, dataSourceName string) (*DB, error) { // 略 driveri, ok := drivers[driverName] // 略 return OpenDB(dsnConnector{dsn: dataSourceName, driver: driveri}), nil } func OpenDB(c driver.Connector) *DB { // 略 db := &DB{ connector: c, // 略 } // 略 return db } https://cs.opensource.google/go/go/+/refs/tags/go1.17.8:src/database/sql/sql.go;l=799-833 (「触れないこと」で記載の通り、ロックやコネクション周りのコードは省略し今回触れたい部分のみ抜粋しています。) driversは先ほど触れた、sqlパッケージ直下に定義されているmapです。 つまり、Openによって我々がinit関数で追加したdriverを保持するDBインスタンスを得られたことがわかります。 DB.Queryメソッド sqlxパッケージにはSelect関数やメソッドが定義されていますが、sqlパッケージにはSelectはなくQueryが定義されています。 // Query executes a query that returns rows, typically a SELECT. // The args are for any placeholder parameters in the query. func (db *DB) Query(query string, args ...any) (*Rows, error) { return db.QueryContext(context.Background(), query, args...) } https://cs.opensource.google/go/go/+/refs/tags/go1.18:src/database/sql/sql.go;l=1722-1729 コメントに記載の通りこれがSELECTの実行を想定したメソッドとなっており、 sql.Rows を返却します。 sql.Rows の定義はこちらです(例によって着目したいフィールド以外は省略)。 type Rows struct { rowsi driver.Rows // 略 } https://cs.opensource.google/go/go/+/refs/tags/go1.18:src/database/sql/sql.go;l=2916-2935 このフィールド rowsi driver.Rows に注目してください。 こちらが先ほど触れた、 driver.Stmt.Query メソッドで返却されていた値の型です。 実際に sql.DB.Query の中身を辿っていくと driver.Stmt.Query の値が詰められていることを見ていきましょう。 DB.QueryContext が実行され func (db *DB) QueryContext(ctx context.Context, query string, args ...any) (*Rows, error) この内部では DB.query が実行されており、 func (db *DB) query(ctx context.Context, query string, args []any, strategy connReuseStrategy) (*Rows, error) この内部では DB.queryDC が実行されます。 func (db *DB) queryDC(ctx, txctx context.Context, dc *driverConn, releaseConn func(error), query string, args []any) (*Rows, error) queryDCの第3引数で渡されている dc *driverConn はDB構造体から取得した構造体で、内部に driver.Conn フィールドを持ちます。 要するに、Openする際に渡したdriverのConnを保有しています。 (Open時にセットした db.connector フィールドから driver.Conn を取得する部分の解説は割愛しますが、コードは下記になります。) https://cs.opensource.google/go/go/+/refs/tags/go1.17.8:src/database/sql/sql.go;l=1258-1383 DB.queryDC 内ではこの driver.Conn が driver.QueryerContext インターフェースを満たすかどうかで分岐が入ります。 driver.QueryerContext の定義はこちらです。 type QueryerContext interface { QueryContext(ctx context.Context, query string, args []NamedValue) (Rows, error) } 今回はひとまずシンプルなケースを追うことにして、渡した driver.Conn にこのメソッドが実装されていない場合の処理を見ていきましょう。 (この他にも、こうした「◯◯インターフェースを満たしていればそちらのメソッドを実行する」分岐は度々登場しますが、我々の driver.Conn や driver.Stmt にはオプショナルなメソッドは何も実装されていないものとして追っていくことにします。) DB.queryDC の中で以下の関数が実行され、 func ctxDriverPrepare(ctx context.Context, ci driver.Conn, query string) (driver.Stmt, error) { // 略 si, err := ci.Prepare(query) // 略 return si, err } と、冒頭で見た driver.Conn.Prepare が実行され driver.Stmt を得ます。 DB.queryDC に戻り、後続の処理を追うと以下の関数が実行され、 func ctxDriverStmtQuery(ctx context.Context, si driver.Stmt, nvdargs []driver.NamedValue) (driver.Rows, error) { // 略 return si.Query(dargs) } これまた冒頭で見た driver.Stmt.Query が実行され driver.Rows を得ます。 あとは sql.Rows で driver.Rows を包んで返却、といった流れになります。 長くなりましたがまとめると、DB.Queryの中身を追うと、(コネクション管理などのなんやかんやの処理に伴って)Open時に渡したdriverのConn, Stmtのメソッドを実行してその結果を返却している ことがわかりました! まとめ sqlパッケージのOpenからQueryの実行、そしてそこに渡すdriverパッケージの各種interfaceについてまとめました。 driver.Driver を満たす独自の型を定義し登録すればそれに従った sql.Rows を得ることはできるようです。 しかしsqlパッケージでは sql.Rows を返却するにとどまっており、これを任意の構造体に当てはめるといった処理は行なっていません。 次回はそれを行なっているsqlxパッケージを見ていく予定です。

YappliにおけるgRPC, Protocol Buffers運用について

yappli
2022-03-31 01:00:00
サーバーサイドエンジニアの田実です! Yappliでは各APIの通信にgRPC, Protobufを採用しています。 本記事ではYappliにおけるgRPC, Protobufの利用方法・運用方法についてご紹介します! 利用箇所 以下のAPI通信においてgRPCを利用しています。 用途 プロトコル クライアント サーバー ネイティブアプリ用API gRPC(gRPC-Gateway経由) アプリ Go Webフロントエンド用API gRPC-Web TypeScript Go サーバー間通信 gRPC Go Go gRPCだけではなくgRPC-Gateway, gRPC-WebとgRPC系をフル活用しています! 運用方法 全容はこんな感じになります。 それぞれのインターフェースを変更する場合は、開発者がprotoファイルを修正してprotoファイル用のリポジトリにpushします。 pushされるとCircleCIがhookされ、lintなどのチェックを行った後、protocでコードの自動生成を行います。 Goの場合は以下のように、全てのprotoファイルに対してprotocを実行してコードの自動生成を行っています。 for s in $(find ${protoファイルの場所} -type f); do protoc \ -I . \ -I repos/protobuf/src \ -I repos/grpc-gateway \ -I repos/googleapis \ --go_out=plugins=grpc:${出力先} ./${s} done protocによってgRPCクライアント・サーバーが自動生成された後は、gRPCクライアント・サーバー用のリポジトリにプッシュします。 CircleCIだと以下のようにしてCI上から特定のリポジトリに自動でコミット・プッシュしています。 cd {出力先} git add . git diff --cached | wc -l | grep -w 0 && exit 0 git commit -m "auto commit from ${CIRCLE_PROJECT_USERNAME}/{protoのリポジトリ}@${CIRCLE_SHA1}" git push origin ${CIRCLE_BRANCH}:${CIRCLE_BRANCH} これらの自動生成されたコードを利用元のコードからパッケージ・ライブラリとして取り込んで利用します。 例えばGoだと以下のようにしてインポートを行ってクライアント・サーバーのコードを動かしています。 import "github.com/{owner}/{gRPC クライアント・サーバーのリポジトリ}/xxx/yyy" アップデートしたい場合は以下のコマンドを叩いてgo.mod, go.sumを更新します。 $ go get -u "github.com/{owner}/{repo}@{ブランチ名}" ブランチごとにコード自動生成の処理が動いているので開発中はブランチ名を指定して取り込めるようになっています。 フロントエンドの場合はpackage.jsonで対象リポジトリのコミットハッシュを書き換えた後 npm install することで反映できます。 "devDependencies": { ... "{リポジトリ名}": "git+ssh://git@github.com/{owner}/{repo}#{commit_hash}", gRPC-Gatewayに関しては自動でクライアントの生成はしていないのですが、APIドキュメントの自動生成をしています。詳細は以下の記事を参照してください! tech.yappli.io Prototoolでフォーマットされてない場合はCI上でエラーになるようにしています。 具体的には以下のコマンドでフォーマットを行い、git diffで差分が発生していたらフォーマットされていないものと見なしてエラーを投げています。 $ prototool format -w ちなみにPrototoolは現在開発されておらず、代わりにBufを使うように推奨されているので今後置き換えていく予定(多分) まとめ YappliにおけるgRPC, Protocol Buffersの運用方法についてご紹介しました。 gRPCを採用する際の運用方法として参考にしていただければ幸いです!

BigQueryで異なる粒度の集計をまとめて実施する【Sexy Tech for You #11】

yappli
2022-03-29 03:48:07
データサイエンティストの阿部です。この1年間、毎週土曜日に8時間副業していました。結果、週休二日制のありがたみがわかりました。 さて、明日から使いたくなるデータハンドリング術を紹介する "Sexy Tech for You" の第11話を配信しました。例えばDAUとMAUを一括集計するなど、同一テーブルから粒度が異なる集計結果をまとめて得たいケースの対応方法について、3パターン紹介しました。以下のような集計結果がほしいイメージです。 集計粒度 日付 値 daily 2020-01-01 50 daily 2020-01-02 30 monthly 2020-01-01 80 3パターンいずれも同じ結果を得られますが、下記のようなメリット・デメリットがあると私は考えます。 動画中に記載したクエリや補足事項を本ブログに掲載しますので、少しでも皆様のお役に立てたら幸いです。 Youtube BigQuery クエリ BigQueryの一般公開データセットbigquery-public-data.chicago_taxi_trips.taxi_tripsで、DailyとMonthlyの件数をカウントする場合の例です。 パターン1:UNIONを用いる SELECT "daily" AS aggry_type, DATE(trip_start_timestamp) AS dt, COUNT(1) AS cnt FROM `bigquery-public-data.chicago_taxi_trips.taxi_trips` GROUP BY 1,2 UNION ALL SELECT "monthly" AS aggr_type, DATE_TRUNC(DATE(trip_start_timestamp), MONTH) AS dt, COUNT(1) AS cnt FROM `bigquery-public-data.chicago_taxi_trips.taxi_trips` GROUP BY 1,2 パターン2:ROLLUPを用いる SELECT CASE WHEN day IS NOT NULL THEN "daily" WHEN month IS NOT NULL THEN "monthly" ELSE NULL END AS aggr_type ,COALESCE(day, month) AS dt ,cnt FROM( SELECT DATE(trip_start_timestamp) AS day, DATE_TRUNC(DATE(trip_start_timestamp), MONTH) AS month, COUNT(1) AS cnt FROM `bigquery-public-data.chicago_taxi_trips.taxi_trips` GROUP BY ROLLUP(month, day) ) WHERE month IS NOT NULL ORDER BY 1,2 パターン3:CONCATとSPLITを用いる [オススメ] WITH tmp AS ( SELECT CONCAT(dt, "#", DATE_TRUNC(dt, MONTH)) AS dt, CONCAT("daily", "#", "monthly") AS aggr_type FROM( SELECT DATE(trip_start_timestamp) AS dt FROM `bigquery-public-data.chicago_taxi_trips.taxi_trips` )) SELECT SPLIT(aggr_type,"#")[SAFE_OFFSET(offset)] AS aggr_type, dt, COUNT(1) AS cnt FROM tmp,UNNEST(SPLIT(dt,"#")) AS dt WITH OFFSET AS offset GROUP BY 1,2 補足 ヤプリでは、アプリごとにユーザーの起動サイクルや用途が異なるため、複数粒度に対応できるテーブルを作成するケースによく遭遇します。その中で得た知見を今回まとめてみました。 パターン2で包含関係にない粒度同士の場合は、正確な値を得られません。今回の例だと、週や曜日をROLLUPに追加しても期待した結果にならないので気をつけましょう(クエリ自体は実行できます)。 パターン3でCONCATする際の接続詞は、元の値に含まれない文字であれば何でも大丈夫ですが、ここでは#としています。また本例でのaggr_typeのように、集計粒度と連動する値を作成したい場合は、集計粒度のカラムと同様にCONCATしたあとにOFFSETで値を取得することができます。 以上、Moblie Tech for All のヤプリがお送りする Sexy Tech for You でした!次回またお会いしましょう。

[Swift] 親クラスのプロパティへのアクセスを禁止したい

yappli
2022-03-28 06:04:09
[Swift] [iOS]
こんにちは、ヤプリの三縞です。 iOSアプリの開発を進める中で、クラスの外からそのクラスの親のプロパティへのアクセスを禁止したい状況がありました。 今回の記事では実際の事例を紹介しながら、どのように解決したのかを説明したいと思います。 実際にあった事例 APIと通信している間の読み込み中にアニメーションを表示する、UIViewを継承したLoadingViewというクラスを作成していました。 UIViewControllerのviewに addSubview() し、APIと通信する直前に startAnimating() メソッドを呼ぶという想定です。 このLoadingViewでアニメーション表示している間はUIViewControllerの画面操作をさせたくなかったため、 startAnimating() メソッド内でUIViewのプロパティである isUserInteractionEnabled を true にするという処理を行っています。 ところが、別の画面でLoadingViewが使用された際に、LoadingViewが表示されていても画面操作させたいという意図で以下のように isUserInteractionEnabled の値が変更されるコードが書かれました。 let loadingView = LoadingView() view.addSubview(loadingView) loadingView.isUserInteractionEnabled = false loadingView.startAnimating() startAnimating() の中で isUserInteractionEnabled = true としているため、これだと意図しないタイミングで値が変わってしまうことになります。 本来なら isUserInteractionEnabled の状態をクラス内部だけで管理したいのですが、親クラスであるUIViewのプロパティであるためprivateプロパティにすることができず外側からアクセスできてしまいます。 どうにかしてLoadingViewクラスにおける isUserInteractionEnabled プロパティをprivateであるかのような振る舞いにできないでしょうか。 やりたいこと UIViewのプロパティである isUserInteractionEnabled をUIViewを継承したクラスの外側から操作できなくし、クラス内部でだけコントロールしたい。 実現方法 isUserInteractionEnabled をoverrideし @available (*, unavailable) を指定すればクラスの外から操作できなくなります。 プロパティをoverrideする際はsetterとgetterを実装する必要がありますが、外からも内からも使用しないので適当に実装します。 クラス内部からUIViewの isUserInteractionEnabled へアクセスしたい場合は super.isUserInteractionEnabled のように super. を付けることでアクセスできます。 最終的なコード例 class LoadingView: UIView { @available (*, unavailable) override var isUserInteractionEnabled: Bool { set { } get { return false } } override init(frame: CGRect) { ... super.isUserInteractionEnabled = false } func startAnimating() { super.isUserInteractionEnabled = true ... } ... } 参考資料 https://stackoverflow.com/questions/36660635 環境 Xcode 13.2.1 Swift 5.4.2

ヤプリの開発体制について

yappli
2022-03-24 01:22:42
こんにちは、エンジニアの山田です。 前振り一切無しですが、弊社オフィスから徒歩3分のところにイケてるスタバが出来たらしいです。 仕事帰りのフラペチーノが楽しみですね ( ゚∀゚)レッツトッピング!! オフィスから徒歩3分の所にイケてるスタバがオープンしたと聞いたので偵察に来ました!もちろん電源とWiFi完備。これが新作のフラペチーノかー。欲を言えば50キロぐらい欲しいところですが、色んなトッピングもあるし、これはリモートワークが捗りそうだなあ! pic.twitter.com/S1YNO9uuye — Koichi Tsunoda | CFO @Yappli (@KoichiTsunoda) 2022年3月16日 さて、それはそれとして、ヤプリではノーコードでアプリを開発できるプラットフォームの Yappli を提供しています。プロダクト開発業務では主にこの Yappli への新機能開発や改修業務を行っていますが、今回は採用面接でよく聞かれるヤプリの開発体制について簡単にまとめてみました! 何を開発するのか決める 具体的に何を開発するのかについて、開発部内からの要件の他にトップダウンとボトムアップ双方向からの要求があります。トップダウンとしては経営層から、ボトムアップとしては現場を知るビジネスサイドからのリクエストになります。 経営層からは半期の区切りで長期的な戦略を実現する為の開発リクエストを、ビジネスサイドからはslackの開発アイディア投稿用チャンネル #yappli-ideabox に機能開発要望を投稿してもらって、開発企画部で精査し開発に着手します。 またビジネスサイドからの開発要求の場として #yappli-ideabox の他にも半年に一度「ヤプリク(ヤプリリクエスト)」なるコンペ形式での開発企画プレゼン会が行われています。ここで提案された企画を実現可否とバリューで採点し、特に優れた企画に賞が与えられて実際に機能開発が行われます。お題に沿った多数の企画が持ち込まれ、賑やかな雰囲気で企画提案会が催されています。 note.com 誰が開発するのか決める 開発内容が決定した後に、その機能開発に必要な人員をアサインしたプロジェクトチームを編成します。 サーバサイドエンジニアやフロントエンドエンジニア、アプリエンジニア、プロダクトデザイナー、QA、ディレクターなど必要な職種のメンバーが必要数アサインされます。プロジェクト毎に人数は異なり4人から8人規模プロジェクトまで様々あります。(※以下例) また現在は参加するプロジェクトについて、ある程度個々人の希望に沿ったアサインを行っています。 どう開発するのか決める プロジェクトの進行については、厳密な開発フローは定められておらず、プロジェクトの特性に応じた開発手法を採用してます。 例えば開発ゴールが明瞭なプロジェクトについてはウォーターフォール的な進め方を採用していたり、不確実性の高いプロジェクトについてはアジャイル風を採用してスプリントで進めたりもしてます。 定期的な開発状況のレポートや承認フローは共通で定められてますが、仕様決定や開発スケジュールの策定についての裁量はプロジェクトに大きく委ねられてます。 他、プロジェクト以外の開発 閑話休題、プロジェクト規模以外の、例えば個人が1日〜2日で完了するような作業についての話になります。プロジェクト作業をしながら片手間で作業にあたる事もありますが、組織としてこういった改善業務の実施を後押しする Yappdate day という取り組みを行っています。 隔週でこの Yappdate day を設けており、その日はプロジェクトの作業を行わず改善業務に全ての時間を割く日にしてます。 日常業務の自動化や細かなバグ修正、またエンジニアリングとしてはテスト拡充やリファクタなどを積極的に行うようにしています。 www.wantedly.com 他、インシデント対応について Yappli は24時間365日稼働を前提とするサービスなので、インシデント等の有事の際は迅速な対応が求められます。 現在はインシデントや運用専任のメンバー等は存在しません。基本的には全メンバーともプロジェクトでの機能開発を主としていますが、インシデント発生時にはプロジェクトより優先してこちらの対応業務にあたります。 まとめ ヤプリの開発開発について、簡単にご紹介させていただきました! 他にもヤプリの中の話に興味がありましたら、是非カジュアル面接にお越しください。 ( ゚∀゚) やっぷりヤプリ!

BigQueryのJSONデータで、同一のJSONキー名がある場合に全ての値を取得する【Sexy Tech for You #10】

yappli
2022-03-22 01:03:41
データサイエンティストの阿部です。今さらながら『劇場版 少女☆歌劇 レヴュースタァライト』を映画館で観たのですが衝撃的でした。 さて、明日から使いたくなるデータハンドリング術を紹介する "Sexy Tech for You" の第10話を配信しました。僕は「とりあえずJSON形式で突っ込んでおいてくれたらあとはよしなにやります」と言いがちなタイプですが、最近便利さに感心したBigQueryのJSON_QUERY_ARRAY関数について紹介しました。 動画中に記載したクエリや補足事項を本ブログに掲載しますので、少しでも皆様のお役に立てたら幸いです。 Youtube BigQuery クエリ 同一のJSONキーの数がレコードごとに異なる場合に対応できない例:オフセット値を指定する WITH input AS ( SELECT PARSE_JSON('{"id":1,"name":"Sexy","info":[{"key":"point","value":"100"},{"key":"stamp","value":"2"}]}') AS jd UNION ALL SELECT PARSE_JSON('{"id":2,"name":"Tech","info":[{"key":"point","value":"500"},{"key":"stamp","value":"5"},{"key":"coupon","value":"3"}]}') ) SELECT JSON_VALUE(jd, '$.id') AS id, JSON_VALUE(jd, '$.name') AS name, JSON_VALUE(jd, '$.info[0].key') AS key0, JSON_VALUE(jd, '$.info[0].value') AS value0, JSON_VALUE(jd, '$.info[1].key') AS key1, JSON_VALUE(jd, '$.info[1].value') AS value1 FROM input クエリが冗長な例:JSON_EXTRACT_ARRAY関数を用いる WITH input AS ( SELECT PARSE_JSON('{"id":1,"name":"Sexy","info":[{"key":"point","value":"100"},{"key":"stamp","value":"2"}]}') AS jd UNION ALL SELECT PARSE_JSON('{"id":2,"name":"Tech","info":[{"key":"point","value":"500"},{"key":"stamp","value":"5"},{"key":"coupon","value":"3"}]}') ), tmp AS ( SELECT JSON_EXTRACT_SCALAR(jd, '$.id') AS id, JSON_EXTRACT_SCALAR(jd, '$.name') AS name, ARRAY( SELECT JSON_EXTRACT_SCALAR(x, '$.key') FROM UNNEST(JSON_EXTRACT_ARRAY(jd, "$.info")) x ) AS key, ARRAY( SELECT JSON_EXTRACT_SCALAR(x, '$.value') FROM UNNEST(JSON_EXTRACT_ARRAY(jd, "$.info")) x ) AS value FROM input ) SELECT id, name, key, value[SAFE_OFFSET(offset)] AS value FROM tmp, UNNEST(key) AS key WITH OFFSET AS offset おすすめ例:JSON_QUERY_ARRAY関数を用いる WITH input AS ( SELECT PARSE_JSON('{"id":1,"name":"Sexy","info":[{"key":"point","value":"100"},{"key":"stamp","value":"2"}]}') AS jd UNION ALL SELECT PARSE_JSON('{"id":2,"name":"Tech","info":[{"key":"point","value":"500"},{"key":"stamp","value":"5"},{"key":"coupon","value":"3"}]}') ) SELECT JSON_VALUE(input.jd, '$.id') AS id, JSON_VALUE(input.jd, '$.name') AS name, JSON_VALUE(info.key) AS key, JSON_VALUE(info.value) AS value FROM input, UNNEST(JSON_QUERY_ARRAY(jd.info)) AS info 補足 ヤプリには、各アプリの構成情報をJSON形式で保持しているテーブルがあって、マスタとして活用しています。そして表現力の高いサービスを実現するために、そのテーブルでは、キーの個数が可変だったり、新たなキーが追加されたりするケースがあります。そうしたケースにBigQueryで対応しているうちに、上記のような知見を得ました。 また本稿をまとめるにあたって、末尾のブログを参照させて頂きました。どうもありがとうございました。 以上、Moblie Tech for All のヤプリがお送りする Sexy Tech for You でした!次回またお会いしましょう。 参考 BigQueryのネイティブJSON型がサポートされたことでどう変わったのか BigQuery で複数の配列をフラット化する:FLINTERS Engineer's Blog Bigquery - json_extract all elements from an array:Stack Overflow

SQLite3入門

yappli
2022-03-04 01:00:00
サーバーサイドエンジニアの田実です! YappliはSQLite3を使ったマルチテナントアーキテクチャを採用しています。 今回は、SQLite3の運用でよく使うコマンドやちょっとマニアックな仕様についてまとめてみました! CLIでクエリ結果を縦に表示したい MySQLでいうところの \G 、PostgreSQLでいうところの \x をしたい場合、 .mode line を使って表示モードを切り替えれます。 sqlite> .mode line sqlite> select * from hoge; id = test code = hoge description = fuga ちなみに指定できるモードは line 以外にも column list csv などがあります。 .mode MODE ?TABLE? Set output mode MODE is one of: ascii Columns/rows delimited by 0x1F and 0x1E csv Comma-separated values column Left-aligned columns. (See .width) html HTML <table> code insert SQL insert statements for TABLE line One value per line list Values delimited by "|" quote Escape answers as for SQL tabs Tab-separated values tcl TCL list elements 例えば、CSVエクスポートをしたい場合は以下のようなコマンドを実行します。 $ sqlite3 -header -csv {dbファイル} "SELECT * FROM xxx" スキーマやテーブル一覧を見たい CLIの .schema や .table コマンドを使ってテーブル情報を確認できます。 # 全テーブルのCREATE TABLE文を出力 .schema # テーブル指定 .schema hoge # テーブル一覧 .table JSON関数を使いたい JSON1拡張を組み込むとJSON関数が使えるようになります。 SELECT json_extract('{"foo":"bar"}', '$.foo'); -- => bar PostgreSQLの jsonb_array_elements のような配列操作も可能です。 SELECT distinct t.id, t.body FROM json_table AS t, json_each(t.body, '$.items') AS v WHERE json_extract(v.value, '$.foo') = 'bar'; 独自関数を使いたい SQLite3はアプリケーション側でDBファイルの操作を行うため、呼び出し元のプログラミング言語を使った関数を簡単に定義することができます。 PHP(PDO)だと以下のようにして独自関数を定義することができます。 <?php $db = new \PDO('sqlite::memory:'); $db->sqliteCreateFunction('hello', function($v) { return 'hello '.$v; }); $stmt = $db->query('SELECT hello("world");'); echo $stmt->fetch()[0]; // => hello world Go(mattn/go-sqlite3)だとこんな感じ。 package main import ( "database/sql" "fmt" sqlite "github.com/mattn/go-sqlite3" ) func main() { sql.Register("sqlite3_custom", &sqlite.SQLiteDriver{ ConnectHook: func(conn *sqlite.SQLiteConn) error { f := func(v string) string { return "hello " + v } if err := conn.RegisterFunc("hello", f, true); err != nil { return err } return nil }, }) db, err := sql.Open("sqlite3_custom", ":memory:") if err != nil { panic(err) } defer db.Close() var r string err = db.QueryRow("SELECT hello('world')").Scan(&r) if err != nil { panic(err) } fmt.Println(r) // => hello world } 比較演算子に正規表現を使いたい SQLite3には比較演算子のREGEXPがありますが、REGEXPを利用するには事前にユーザ側でregexp()の独自関数を定義する必要があります。 <?php $db = new \PDO('sqlite::memory:'); $db->sqliteCreateFunction('regexp', function($pattern, $value) { mb_regex_encoding('UTF-8'); return (false !== mb_ereg($pattern, $value)) ? 1 : 0; }); $stmt = $db->query("SELECT 'hello world' REGEXP '^hello.*'"); echo $stmt->fetch()[0]; // => 1 空き領域を開放したい 他のRDBMSと同様、大量のINSERT&DELETEを繰り返すと空き領域ができてしまいレコード数よりも多くの領域を取ってしまいます。 そこで、SQLite3でもバキュームの仕組みであるvacuumコマンドを使って空き領域を開放することができます。 $ wc -c test.db 38989824 test.db $ sqlite3 test.db 'vacuum' $ wc -c test.db 8192 test.db ジャーナルモード 障害復旧時に利用されるロールバックジャーナルに関するジャーナルモードを選択できます。 モード 説明 DELETE ロールバックジャーナルである -journal ファイルを作成しコミット後に -journal ファイルを削除します。 TRUNCATE DELETEモードとほぼ同じですが、コミット後は -journal ファイルを削除せずサイズ0のファイルにします。*1 WAL -shm -wal ファイルを作成し、コミット内容は -wal ファイルに反映されます。 WALの場合は実行速度が早くコンカレントな実行ができるのがメリットですが、本体+ -wal ファイルでDBの状態になるので扱い方に注意が必要です。*2 上記以外にもインメモリにロールバックジャーナルを保存する MEMORY やロールバックジャーナルを作成しない=アトミックな処理が行えない OFF というモードがあったりします(OFFモードの利用用途はあるんだろうか…w) ちなみに、YappliではSQLite3ファイルのコピーを簡単にするため、 WALではなくDELETE/TRUNCATEを採用しています。 PRAGMA journal_mode=TRUNATE; 読み込みモードについて SQLite3のファイルオープン時にクエリパラメータを指定することで、読み込みモードを指定することができます。 example.sqlite?mode=ro 以下のEFS移行では、SQLite3がReadOnly(mode=ro)でもファイルロックを行うため、DBの読み取りのパフォーマンスが落ちてしまう問題が発生していました。 tech.yappli.io こちらの場合、nolockパラメータを設定することでファイルロックを行わないようにしてパフォーマンスの改善を行いました。 ファイルオープン時に指定できるパラメータは以下に記載されています。 www.sqlite.org ジャーナルモードのようにPRAGMAで設定できるパラメータもあります。 www.sqlite.org まとめ SQLite3の基本的なものからちょっとマニアックな情報までご紹介しました! YappliではSQLite3をプロダクションに導入しており、今のところ大きな問題なく(?)運用できています。*3 この記事が皆様のSQLite3ライフに役立てば幸いです!w *1:DELETEモードのファイル削除よりは高速らしい *2:例えばバックアップを取る際に -wal ファイルも取っておく必要がある *3:入社する前はSQLite3?って思ったけど結構いけます

ヤプリのフロントエンドチーム@2022年を紹介します

yappli
2022-02-28 02:35:09
こんにちは、フロントエンドの山田です。 補足すると別記事にも登場する「サーバサイドの山田」と同一人物です。 現在はサーバサイドとフロントエンドを兼務してます。 tech.yappli.io さて、今回はフロントエンドの山田として、私達フロントエンドチームについて紹介させていただきます! フロントエンドチームについて 早速、フロントエンドチームのメンバーを紹介します。 Aihara … 2015年入社(全エンジニアの中でも最古参)。フロントエンド業務では主にCMSの新機能実装や設計を担当。最近はフルスタック領域にも興味あり。趣味は演奏、料理、ゲーム、ラジオ、飲酒。 Ykob ... 前職では広告制作会社でReact/Vue、WebGLなどを使用して、キャンペーンサイトの実装を担当。2019年にヤプリに入社して、フロントエンド領域のメンテナンスを主に担当し技術負債や課題解決などを牽引している。最近はa11yやDesignOpsに興味あり。趣味はランニングと推し活。 Kon ... 2021年末に加入、フロントエンド歴は3年でCMSのTypeScript移行を担当。組織のコミュニケーションに興味があり、リモートワークでも円滑に業務ができる体制づくりを試行錯誤するなど。趣味は多すぎて書ききれない。 Yamada ... 2018年入社、現在サーバサイドとフロントエンドのマネージャーを兼務。主にフロントエンドチームのマネジメント業務を担当。趣味は筋トレ、キーボードよりダンベルが好き。 2022年2月現在、フロントエンドチームは私を含めて4人でまだまだ少人数のチームです。 一応誤解の無いよう宣伝ですが、フロントエンドエンジニアは絶賛募集中です! 続いてフロントエンドチームの取り組みについての紹介ですが、2021年末から役割と責務の見直しを行っているので、その背景を含めて説明させていただきます。 フロントエンドチームの取り組み(これまでの) 時は2018年に遡り、ヤプリのフロントエンドは過去 PHP と jQuery で構築されていてメンテコストの高い技術負債となっていました。 これらを是正すべくCMS刷新プロジェクトが立ち上がり、バックエンドが PHP から Go へ、フロントエンドは jQuery から Nuxt へ刷新されました。 このCMS刷新プロジェクト発足時点ではフロントエンドチームは存在せず、全メンバーがフルスタックでバックエンドとフロントエンドの両方を担当していました。 ...が、サーバサイドメンバーは軒並みスタイルが不得意で、デザイン通りのマークアップを行う事が非常に苦手という課題感がありました。...苦手というより無理でした。 開発途中でフロントエンドに特化したメンバーがヤプリに加わり、CMS刷新プロジェクトに参加します。 フロントエンド領域全般の実装業務の他、サーバサイドメンバーのマークアップ不得手を補うために、主にコンポーネントの実装をしたり、マークアップ不要で画面実装が可能となるようグリッドシステムを導入したり、フロントエンド全般のテコ入れを行ったりしました。 その後、メンバーが増えてフロントエンドチームが発足した現在もフロントエンドメンバーはこのようなCMSフロントエンド領域の実装全般を主業務としてました。 フロントエンドチームの課題 直近の新機能開発におけるCMSの実装業務においても、当時と同じ責務分担で業務を行っていましたがその頃から状況は大分変わってきました。 実装ルールもある程度定着し、既存コンポーネントの組み合わせで新規の画面実装も可能となりフロントエンドエンジニア不在でも殆どのフロントエンド実装が可能となりました。 これによりフロントエンドとサーバサイドメンバー間のフロントエンド実装業務の差別化も減り、フロントエンドエンジニアが強みを発揮する機会は少なくなりました。 また、CMS刷新プロジェクト開始から既に数年が経っていて、刷新した Nuxt の基盤自体にも新たな課題や技術負債が出てきました。フロントエンドメンバーの数は少なく、通常業務でプロジェクトのフロントエンド実装全般を担当しているので、技術負債や課題解決などにリソースを充てる事が出来ない状況です。 重ねて、ある程度フロントエンドの実装作業もテンプレ化してきた事により「フロントエンドの技術力を伸ばしたい!」と考える人には魅力的な業務とは言い難い状況になりました。 これらにより、フロントエンドで抱える課題が積み上がってきてました。 プロジェクトでCMSのフロントエンド実装全般が主業務となっていて、UI改善や技術負債解消に手が回ってない。 フロントエンドエンジニアが意欲的にチャレンジ出来る業務や環境を提示出来ておらず、採用が難しい。 また、暗黙的に定着してきた実装ルールが言語化されておらず新入社員の導入コストが高い。 2021年末より、これらの課題に対して向き合い改善に向けて動き出す事にしました。 他社におけるフロントエンドチーム is フロントエンドチームを持つ組織はヤプリに限った話ではありません。 これらの課題解決を実現出来るフロントエンドチーム像を描くにあたり、他社フロントエンドチームの取組などを調べてみる事にしました。 この時参考にさせて頂いた記事をいくつか紹介させていただきます。(とても感謝です) フロントエンドの役割について 食べログのフロントエンドエンジニアってめっちゃ大変やねん・・・ クラウドワークスのフロントエンド活動を振り返る 2020 - クラウドワークス エンジニアブログ → 「フロントエンドエンジニア」というロールは存在するが、「プロダクトおよび開発人数が大規模」or「フロントエンドエンジニアが少数」の場合は、サポーターとしての振る舞いをしている。 Dev03_フロントエンドエンジニア - 株式会社iCARE 採用情報 | 株式会社プレイド → デザインシステムやDXなどの仕組みづくりにフォーカスするケースもありそう。 また、採用について A-1.BASE_フロントエンドエンジニア - BASE株式会社 ウェブアプリケーションエンジニア(フロントエンド) / 株式会社SmartHR → フロントエンドエンジニアを募集する場合は、リニューアル、リプレイスなどの単語が紐づくケースが多い。 ヤプリのフロントエンドチーム is 他社事例において大規模サービスを持ちながらもフロントエンドエンジニアが少ない組織はヤプリに限らず、その場合はサポーターやプロフェッショナルな領域の業務に専念する事が多いようです。 私達フロントエンドチームにおいてもサーバサイドメンバーが単独でCMSのフロントエンド実装が可能となっているので、 プロジェクトに限定せずに、フロントエンド開発全体のサポートや、プロフェッショナル領域に集中して取り組む事が出来ると考えました。 今後のヤプリのフロントエンドチームは「CMSのフロントエンド領域全般の業務に取り組むチーム」ではなく 「フロントエンドのプロフェッショナル領域の課題解決に取り組むチーム」 に転換して、課題の解決に注力する事に決めました。 この転換により以下の取り組みを実現したいです。 プロジェクトのフロントエンド実装を主業務とせず、実装ルールの言語化や、UI改善、技術負債の解消に注力する。 よりプロフェッショナルな領域にフォーカスする事により、フロントエンドエンジニアが意欲的にチャレンジ出来る業務や環境を用意し採用に結びつける。 ーとはいえ、では明日から早速フロントエンドメンバーは今のプロジェクトから離脱して課題対応にあたります!という訳には行かず...。 サーバサイドメンバーがフロントエンドの実装を担当する場面も多くなるので、まずは品質を維持出来るよう土台の整備が必要です。2022年1Qは以下のような内容に取り組んでいます。 もっと平易にフロントエンド実装が可能となるよう基盤の改善を行う。 新入社員の為の暗黙的実装ルールのドキュメント化をする。 コンポーネント実装等の業務についてはフロントエンドエンジニアへ依頼するフロー整備する。 フロントエンドチームの取組(これからの) 早々に役割/責務変更に伴う土台整備を完了し、本命のUI改善や技術負債解消、より先進的な土台作り等を進めていきたいと考えています。 具体的には以下のような取り組みを予定してます。 効率的なデザインフローを実現するためのデザインシステム構築 CMSの情報設計の見直しによるUI改善 CMSのアクセシビリティ対応によるUI改善 海外対応などを見据えた多言語対応 運用保守観点からのVueコンポーネントのTypeScript化 一部、外部委託部分のフロントエンド刷新 etc... ( ゚∀゚) やりたい事は沢山 まとめ 2022年前半は、フロントエンドチームとしての役割の転換期で現在その真っ最中です。 フロントエンドエンジニアがよりプロフェッショナルな業務に注力できる体制構築を行い、より良いプロダクト開発に繋がる土台を築いていきたいです。 そんな転換期ではありますが、一緒にプロダクトの成長を加速させていただけるフロントエンドエンジニアを積極募集しています!! 少しでも興味をお持ちいただけましたら、カジュアルにお話を聞きに来てくれると嬉しいです。お待ちしております。(meetyはじめました ^^ meety.net ( ゚∀゚) やっぷりヤプリ!

PHPのちょっとした静的解析をGo+GitHub Actionsで実施するようにしてみた

yappli
2022-02-18 09:29:59
[Go] [GitHub Actions] [PHP]
こんにちは、エンジニアの尾宇江です。 goでプルリク作ってリリース作業を改善してみた。のエントリーに引き続き、Goを使ったちょっとした改善をした話です。 最初に 書いたコードをcommitした後、「あープリントデバッグで仕込んでたvar_dump消し忘れた」ってことありますよね? あと、「設定ファイルのURLをexample.comのままにしてcommitしちゃった」ということも良くありますよね? 私は日常的にやらかしていました… 対策 気をつけても抜ける時は抜けるので、自動化しよう!! ということで、CIでチェックするようにしました。 対象となるリポジトリはPHP(Laravel)なのですが、今回はYappliのメイン言語のGoをつかってチェックすることにしました! 実装したコード プリントデバッグが残ってないかチェックするスクリプト package main import ( "bufio" "errors" "flag" "fmt" "os" "path/filepath" "strings" ) var directories = flag.String("directories", "", "directories to search. if you specify more than one, separate them with commas. eg. app,vendor") func main() { flag.Parse() if err := Main(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } func Main() error { find := false for _, v := range strings.Split(*directories, ",") { b, err := walk(v) if err != nil { return err } if b && !find { find = true } } if find { return errors.New("found a print debug, check the output above") } return nil } func walk(root string) (bool, error) { fmt.Printf("# root: %s\n", root) find := false err := filepath.Walk(root, func(path string, fi os.FileInfo, err error) error { if err != nil { return err } switch filepath.Ext(path) { case ".php": fmt.Printf("## path: %s\n", path) b, err := search(path) if err != nil { return err } if b && !find { find = true } } return nil }) return find, err } func search(path string) (bool, error) { file, err := os.Open(path) if err != nil { return false, err } fs := bufio.NewScanner(file) i := 0 find := false for fs.Scan() { i++ if isPrintDebug(fs.Text()) { fmt.Printf("- [ ] find print debug@%s:%d, %s\n", path, i, strings.TrimSpace(fs.Text())) find = true } } return find, fs.Err() } func isPrintDebug(s string) bool { for _, v := range []string{"print", "print_r", "var_dump", "var_export", "echo"} { if strings.Contains(s, v) { i := strings.Index(s, v) + len(v) // コメント行は無視する if strings.TrimSpace(s)[:2] == "//" { continue } // ユーザ定義関数は無視する if s[i:i+1] != "(" && s[i:i+1] != " " { continue } switch v { case "print_r", "var_export": // trueがないとNG return strings.Index(s[i:], ", true)") == -1 default: return true } } } return false } envファイルにexample.comが含まれていないかをチェックするスクリプト package main import ( "bufio" "fmt" "os" "strings" ) func main() { if err := Main(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } func Main() error { if err := checkNGWords(".env.local"); err != nil { return err } if err := checkNGWords(".env.prod"); err != nil { return err } return nil } func checkNGWords(name string) error { file, err := os.Open(name) if err != nil { return err } fs := bufio.NewScanner(file) i := 0 for fs.Scan() { i++ for _, v := range ngWords() { if strings.Contains(fs.Text(), v) { return fmt.Errorf("%s found on line:%d@%s", v, i, name) } } } return fs.Err() } func ngWords() []string { return []string{ "example.com", } } GitHub Actionsの設定 name: Search Print Debug on: push: jobs: setup: runs-on: ubuntu-latest steps: - uses: actions/setup-go@v2 with: go-version: ^1.17 - uses: actions/checkout@v2 search: needs: setup runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - run: go run tools/search_print_debug/main.go -directories=app,tests 今回はビルドせずに直接ファイルを実行しています。 config-checkerもrunの部分が、- run: go run tools/config-checker/main.go となるだけなので、詳細は割愛します。 ディレクトリ構成 $ tree -a -P 'search_print_debug.yml|check_config.yml|main.go' --prune . ├── .github │ └── workflows │ └── search_print_debug.yml └── tools ├── config-checker │ └── main.go └── search_print_debug └── main.go まとめ という感じで、簡易的ではありますがvar_dumpの消し忘れや、残しておきたくない設定値のチェックの自動化ができるようになりました! ちょっとした改善ですが、誰でもやりかねないミスをチェックしてくれるということで、 導入後は結構な安心感を生み出してくれています!

goでプルリク作ってリリース作業を改善してみた。

yappli
2022-02-15 04:54:18
こんにちわ、エンジニアの山田です。 2022年始まって1ヶ月ですが、これだけWeb3がもてはやされると、自分もヤマダ2ぐらいにはならないとヤバいのではという危機感を覚えますね。 これだけWeb3がもてはやされると、自分もツノダ2ぐらいにはならないとヤバいのではという危機感を覚えている — Koichi Tsunoda | CFO @Yappli (@KoichiTsunoda) 2022年2月6日 どういうことだろう(´・ω・`)ゞ さて、今回はヤプリのサーバサイドのリリース作業についての説明と、ちょっとした改善をした話を書きたいと思います。 サーバサイドのリリース作業 現在サーバサイドのリリースは週単位で構成されています。 毎週金曜にチームでリリース内容の共有を行い、翌月曜〜火曜にステージング環境でリグレッションテストを行い水曜にリリースされます。 デプロイ作業自体はCircleCIのCI/CD環境が構築されてます。 masterブランチがステージング環境に、productionブランチが本番環境に紐付いており各環境へのデプロイ作業はブランチのマージを引き金に自動で処理されます。 また、masterやproductionブランチへのマージは1名以上のapproveが必須となっているので、うっかりマージは無いようになってます。 最近面倒なこと サーバサイドはモノリス思考に寄ったリポジトリが構成されており基本的には少ないリポジトリに集約されてました。 が、最近は配信基盤やデータ集計基盤など各種サービス郡に沿ったリポジトリが増えてきており、結果的にリリース管理する対象も増加しました。 毎週リリース作業では、各リポジトリ毎に以下の作業が発生します。 1. master→productionへのプルリクを作成する。 2. プルリクの確認URLを別担当に共有してapproveをもらう。 3. マージする。 特別面倒な作業ではありませんが、多くのリポジトリを対象に真心込めて行うのはとても面倒です。出来る部分をサクッと改善することにしました。 改善してみた サクッと改善ということで、簡単に自動化できそうな以下作業に絞ったスクリプト作成をゴールにします。 1. リリース対象の全リポジトリの master→production へのプルリクを作成する。 2. 確認用の各リポジトリのプルリクURLを作成する。 内部で利用する目的なので「頑張らない」を信条にヤプリの主言語たるGoで簡単なスクリプトを作ります。 今回使うパッケージはこちら。 github.com/google/go-github/v42/github 1.まずはgithub tokenを用いてgithub client作成する。(github tokenは標準入力より取得したものを利用) func getGithubClient(ctx context.Context, githubToken string) *github.Client { ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: githubToken}) tc := oauth2.NewClient(ctx, ts) return github.NewClient(tc) } 2.github clientを用いて事前にチェック対象のリポジトリが存在する事を確認する。 // githubより、リポジトリ一覧を取得 func getRepositoriesByGithub(ctx context.Context, client *github.Client) ([]string, error) { // todo. 現状PerPage100の一回で足りるので手抜き実装 opt := &github.RepositoryListByOrgOptions{Type: "all", ListOptions: github.ListOptions{Page: 1, PerPage: 100}} repos, _, err := client.Repositories.ListByOrg(ctx, GithubOrgName, opt) if err != nil { return nil, err } var repositories []string for _, repo := range repos { repositories = append(repositories, *repo.Name) } return repositories, nil } // 対象のリポジトリがgithubに存在する事を確認 func checkRepositoriesExists(ctx context.Context, client *github.Client, repositories []string) error { repositoriesByGithub, err := getRepositoriesByGithub(ctx, client) if err != nil { return err } var notExistRepositories []string for _, repo := range repositories { isExist := false for i := range repositoriesByGithub { if repo == repositoriesByGithub[i] { isExist = true break } } if !isExist { notExistRepositories = append(notExistRepositories, repo) } } if len(notExistRepositories) > 0 { return errors.Errorf("list of no exists repositories:%s", notExistRepositories) } return nil } 3.各リポジトリ郡の master->production プルリクを作成する。 func makePRsMasterToProduction(ctx context.Context, client *github.Client, repos []string) error { base := "production" head := "master" title := fmt.Sprintf("reguler-%s", time.Now().Format("2006-01-02")) fmt.Println("\n====================") for _, repo := range repos { pr := client.PullRequests prBody, _, err := pr.Create( ctx, GithubOrgName, repo, &github.NewPullRequest{Title: github.String(title), Head: github.String(head), Base: github.String(base)}, ) if err != nil { if strings.Contains(err.Error(), "pull request already exists") { // Resource:PullRequest Field: Code:custom Message:A pull request already exists for ***. fmt.Println("pull request already exists:", repo) continue } else if strings.Contains(err.Error(), "No commits between") { // Resource:PullRequest Field: Code:custom Message:No commits between *** and ***. fmt.Println("no commits: https://github.com/" + GithubOrgName + "/" + repo + "/compare/" + base + "..." + head) continue } return err } fmt.Println(prBody.GetHTMLURL()) } fmt.Println("====================") return nil } 差分がないものはno commits、すでに存在する場合はpull request already exists と出力されるようにしました。 出力結果はこんな感じです。 > go run ./main/cli/github/make-release-pr please input the github token ************************************* (github tokenを入力) ==================== https://github.com/<組織名>/<リポジトリ1>/pull/1111 https://github.com/<組織名>/<リポジトリ2>/pull/2222 https://github.com/<組織名>/<リポジトリ3>/pull/3333 no commits: https://github.com/<組織名>/<リポジトリ4>/compare/production...master no commits: https://github.com/<組織名>/<リポジトリ5>/compare/production...master no commits: https://github.com/<組織名>/<リポジトリ6>/compare/production...master pull request already exists: <リポジトリ7> ==================== 意図通りの出力結果が得られたので、これで一個一個真心を込めてプルリクを作成する必要がなくなりました! まとめ 以上、ちょっとした日常のルーチン作業を楽にする改善を行ってみました。ヤプリでは隔週でYappdate Dayという日を設けていて、こういった改善業務を積極的に行っています。 ちょっとした改善で日常の工数削減や効率化は積もり積もって効果が出るので、地道な改善はとても大事ですね。( ゚∀゚) やっぷりヤプリ!

突撃!隣のリモートワーク環境

yappli
2022-01-27 07:01:48
プロダクト開発本部 インテグレーションエンジニアの尾宇江です。 職種も名前も珍しいので…オーイェーって覚えてください。 Yappliでもリモートワークを行っているのですが、「そういえばあの人とリアルであったこと一度もないかも」って状況も出てきました。 ということで、親睦を深めるために普段は見えないこだわりのデスクトップ環境を見せてもらいましたー インテグレーションエンジニア 尾宇江 まずは言い出しっぺの尾宇江のデスクから紹介です! 本人からのコメント 部屋に入るギリギリの大きさのデスクに肘置きの板を増設した省スペースデスク すっぽり収まるとコックピット感が出るのがお気に入りポイント。 足元には足裏EMSをおいて健康にも配慮 & Switchも常備してるので業務後は待ち時間無しで遊べる配慮もGoodだと自画自賛 改めて自分の写真を見ると、モノが多い…… しかも、ミニ四駆、遊戯王のデッキ、工具箱、おまけにSwitchと趣味のものに囲まれていて、なかなかの環境で仕事してるなーと驚愕です SREグループ 中原さん 本人からのコメント こだわりポイントは、配線が PC, Mac*2, Apple TV, BD Player をAVアンプとHDMI切替器ですべての切り替え可能で 5.1ch サラウンド再生もできるとこ。 アンプにスピーカーと羨ましい限りの環境! これだけ充実してると、平日も休日もデスクの前から離れずに過ごせそう さらっと綿棒があるところも、過ごしやすそうな雰囲気をアップさせている、とっても良いデスクでした! SREグループ 羽渕さん 本人からのコメント WFH時代のエチケットとしてマイクとオーディオインターフェイスは外せません。 オンオフをいつでも切り替えられるようにPS5はいつもそばに置いています。 椅子と机はとりあえずコスパ重視でチョイスしたものなので買い替えたいです。 リモート環境には一言ある羽渕さんのデスク さすがのマイクに、しれっと混ざるPS5と見習いたいポイントがたくさん 机から外側にMacを配置するというテクニックも見逃せない、とっても良いデスクでした! ITグループ 原口さん 本人からのコメント 机はコンパクトながら奥行き十分でかつあまりみない色合いが気に入ってます。 値段も4500円と激安なところもポイントです。 あとは衝動買いした曲面ディスプレイのおかげで以前よりも集中して作業できているとかいないとか 大きなモニタに明るいライト、そしてちゃんと整頓された書類という、大人のデスクという雰囲気 あまり見ない色合いという机も、全体の雰囲気にマッチしている、とっても良いデスクでした! 開発ディレクター 小野田さん 本人からのコメント ・MOFTで傾斜付け ・奥行きが狭いデスクでもKensingtonのアームでモニタとの距離確保(無理矢理ですがデスクの横につけて距離を稼いでいますww) ・癒しを設置しておきいつでもリフレッシュ シンプルにまとまったデスクですが、癒やしの圧がすごい! カーテンの色を暗めにすると目に良さそうとか、細かい事を考えられなくなるくらい癒やしの圧がすごい良いデスクでした! サーバグループ 武井さん 本人からのコメント ウルトラワイドモニターを導入したらQOLが上がった気がします。 分割キーボードはsphh jpというのを使っています。 ウルトラライドモニターに分割キーボードの組み合わせてMacBook2台配置とフルスペックの環境 ちゃんと観葉植物おいたり、採光もできそうだったりと、とっても過ごしやすそうな良いデスクでした! iOSグループ 三宅さん # 本人からのコメント ・肩こり軽減のための分割キーボード (なるべく広めに) ・トラックパッドへの移動が最小限になるように配置してます ・デスクにはケーブル通す穴あけています ディスプレイの裏で猫が寝がちです こちらも癒やしの圧がすごいデスク! そして分割キーボードは広めの配置にパームレストと癒やされること間違い無しの良いデスクでした! iOSエンジニアだと端末真ん中に置くことになるから配置大変そうって気づきも、画面だけでは気づかないので新鮮でした サーバグループ 山影さん 本人からのコメント 肩こり軽減させるために分割キーボード導入 Macはケーブル1本で ビデオ会議の照明と音へのこだわり 山影さんからはリモートワーク始めた当初のデスクトップ写真もいただきましたー 見比べると、HHKBから分割キーボードへの乗り換えが目立つのですが、それ以外にもモニタやカメラに証明とかなりのものがアップデートされているのに気づきました リモートワークでどうやって上手くやっていこうかという試行錯誤の結果が見えて参考になる、とっても良いデスクでした! サーバチーム 鬼木さん # 本人からのコメント 全ての無駄を削ぎ落とした最強のミニマリスト仕様 デスクが誰よりも綺麗な自信がある(というか何もない) シンプルなカッコよいデスクで、この写真が投稿されたときにはSlackが盛り上がりました 比較するものも無いので、このモニタってどれくらいの大きさだろうって混乱しちゃうくらいの美しさ マグカップの配置まで意図を感じる、とっても良いデスクでした! 佐野研究室 佐野さん # 本人からのコメント ・スタンディングでも作業ができるハイカウンター ・イレクターパイプで自作したモニターアーム ・Macはケーブル1本 Yappliを支える佐野研究室の佐野さん 鬼木さんのミニマリスト仕様を超える、ケーブル1本だけの超ミニマリスト仕様 見せたくないケーブルは自作したモニターアームに隠すというこだわりの詰まった、とっても良いデスクでした! SREグループ & テックブログ編集長 望月さん > 本人からのコメント 使わなくなったダイニングテーブルを流用したエコ設計 省スペースなミニマリスト仕様 子供たちが好き勝手に制作物を配置することで、さらに使えるスペース減 狭い方が落ち着く&集中して仕事できるのでむしろGood! 最後はSREチーム & テックブログ編集長の望月さん みんなにデスクの写真を見せてもらおうと思った時に求めてた通りの理想的なデスク! オンライン会議では見えてこないとっても貴重でほっこりする、とっても良いデスクでした! 感想 今回は「皆さんのデスク見せてほしいですー」と投稿したら、すぐにリアクションしてくれたメンバーのデスクを紹介しました! まだまだデスクが気になるメンバーがいるので、いずれまた「突撃!隣のリモートワーク環境」を開催できれば嬉しいなって思っております。 分割キーボード派の私は、こんなに分割キーボードユーザがいたんだとテンション上がりました。 そして、癒やしといえば猫だということに改めて気づきました!

gRPC-Gateway v2へのアップグレードで対応したこと

yappli
2022-01-24 01:00:00
[Go] [gRPC]
サーバーサイドエンジニアの田実です! YappliではネイティブアプリのAPIでgRPC-Gatewayを使って実装しています。 今回は、gRPC-Gatewayをv1からv2にアップグレードしたときに対応したことを紹介します! マイグレーションガイドはこちら↓ github.com 1. Goのパッケージ名を変更 - "github.com/grpc-ecosystem/grpc-gateway/runtime" + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" 2. MarshalarOptionを変更 + "google.golang.org/protobuf/encoding/protojson" - runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{OrigName: true, EmitDefaults: true}), + runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{ + MarshalOptions: protojson.MarshalOptions{ + UseProtoNames: true, + EmitUnpopulated: true, + }, + UnmarshalOptions: protojson.UnmarshalOptions{ + DiscardUnknown: true, + }, + }), UseProtoNames はフィールド名にprotoファイルの名前をそのまま使うか(true)、lowerCamelを使うか(false)を制御するパラメータです。 v1ではruntime.JSONPbのOrigNameフィールドに該当します。 EmitUnpopulated はゼロ値をJSONの値で返すか(true)、省略するか(false)を制御するパラメータです。 v1ではruntime.JSONPbのEmitDefaultsフィールドに該当します。 DiscardUnknown は定義されていないフィールドが送られたときの挙動を制御するパタメータで、trueの場合は無視します。 v1では runtime. DisallowUnknownFields() に相当します。 例ではruntime.JSONPbを使っていますが、これをラップしたruntime.HTTPBodyMarshalerを使ってもOKです。 &runtime.HTTPBodyMarshaler{ Marshaler: &runtime.JSONPb{ // ... } } HTTPBodyMarshalerはハンドラー側がapi.HttpBodyの型で返した場合、HttpBody.Dataがそのままレスポンスで返されます。これによってJSON以外やprotoファイルに依存しないレスポンスを返すことができます。 // Marshal marshals "v" by returning the body bytes if v is a // google.api.HttpBody message, otherwise it falls back to the default Marshaler. func (h *HTTPBodyMarshaler) Marshal(v interface{}) ([]byte, error) { if httpBody, ok := v.(*httpbody.HttpBody); ok { return httpBody.Data, nil } return h.Marshaler.Marshal(v) } grpc-gateway/marshal_httpbodyproto.go at 24434e22fb9734f1a62c81c4ea246125d7844645 · grpc-ecosystem/grpc-gateway · GitHub github.com 3. マッチルール変更の対応 あるURLに対してマッチするパターンが2つ以上ある場合、最後にマッチしたものが優先されます(WithLastMatchWins)。 v1ではデフォルトで最初にマッチしたものが優先されるので、もしこのパターンが存在する場合はprotoファイルを変更する必要があります。 例えば以下のようなprotoがあったときに、 /hoge/fuga にリクエストするとv1では GetHogeFuga が呼び出され、v2では GetHoge でidパラメータに fuga が入った状態で呼び出されます。 service HogeService { rpc GetHogeFuga(GetHogeFugaRequest) returns (GetHogeFugaResponse) { option (google.api.http) = { get: "/hoge/fuga" }; } rpc GetHoge(GetHogeRequest) returns (GetHogeResponse) { option (google.api.http) = { get: "/hoge/{id}" }; } } この場合、アップグレード時にrpcの定義順を並び替えるとアップグレード前と同じ挙動になります。 4. protoファイルでSwaggerのannotationを変更 Swaggerのannotationを使っている場合は以下のように名前を変更します。 - import "protoc-gen-swagger/options/annotations.proto"; + import "protoc-gen-openapiv2/options/annotations.proto"; - option (grpc.gateway.protoc_gen_swagger.options.openapiv2_swagger) = { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { 5. protoc実行オプションなどの修正 protocプラグインもv2にします。 - go get -u github.com/grpc-ecosystem/grpc-gateway/protoc-gen-swagger + go get -u "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2 protocで --swagger_out になっているところを --openapiv2_out に変更します。 - protoc --swagger_out=... + protoc --openapiv2_out=... また、googleapisは v1のgrpc-gateway/third_party 内にあったのですが v2だと https://github.com/googleapis/googleapis リポジトリにあるので、こちらも対応が必要です。 まとめ gRPC-Gatewayのv1からv2にアップグレードしたときに対応したことを紹介しました! 特にマッチルール変更はE2Eレベルのテストが無いと気付きづらいので注意が必要です 💦

Dropbox 上のファイルやフォルダを任意のサイトに埋め込む

yappli
2022-01-18 01:00:00
ITグループの原口です。 最近親子でトランポリンにハマり、面白そうなトランポリン施設を夜な夜なチェックしているトランポリンエンジニアをやっています。 はじめに Yappliでは外部との情報共有はDropboxの利用を推奨しています。 利用に際してファイルやフォルダを単純に共有するのは問題ありませんが、外部サイトに埋め込みたい場合には一手間必要となります。 本記事はその手順や利点、課題などをまとめています。 結論 外部サイトへの埋め込みにはEmbedderを利用する必要があります。 www.dropbox.com Embedderを利用することで Dropbox 上のファイルやフォルダのプレビューをインタラクティブに組み込むことができます。 また、その際には各ファイル・フォルダの共有リンク設定がそのまま適用されるため、Dropbox 全体のポリシーを適用した状態で利用ができます。 その他、埋め込みに使用するドメインをホワイトリストで登録するため、セキュアな運用が実現できるのが利点と言えます。 一方で課題ですが、実現したいことに対して実施すべき手順が多い点が挙げられます。 特にDropbox appは、慣れていないと一定のハードルになるため、DropboxのGUI操作だけで(Embedderをラップする形で)完結できる操作性の実現を今後期待したいところです。 手順 Embedderを利用するための手順を記します。 Step1. Dropbox app 登録 Embedderを利用するには、Dropbox Appが必要になります。 以下のURLにアクセスしてappを作成します。 URL:https://www.dropbox.com/developers/apps/create 通常のAppと違いEmbedderで利用する場合は App folder と Full Dropbox のどちらのAccess Typeを選択しても動作に影響はありません。 今回は最小権限とするため App folder を選択しています。 また、アプリ名はグローバルで一意である必要がありますので重複のない名称を設定し、Create appをクリックします。 続いてアプリのSettings タブにある Chooser / Saver / Embedder domains に実際に埋め込むサイトのドメインを登録します。 ここで登録されたドメインのみが埋め込みを許可される仕様です。 ローカルでテストする場合には localhostを入力して Add をクリックします。これでAppの設定は完了です。 Step2. 埋め込みコードの入手 App登録後に以下のEmbedderのサイトにアクセスすると、App用のEmbed codeが確認できますので、埋め込むサイトに貼り付けます。 URL:https://www.dropbox.com/developers/embedder また、その下には埋め込むコードのサンプルがありますので Anchor tag か JavaScript どちらかお好みのコードをコピーし埋め込むサイトに貼り付けます。なお、link urlはこの後の手順で発行したものに差し替えます。 Step3. 埋め込むコンテンツの共有リンクを設定 Dropbox上にある埋め込みたいコンテンツの共有リンクを発行します。共有リンクの設定はDropbox全体のポリシーに従い、必要に応じてダウンロード禁止などを設定しておきます。 発行したURLをStep2で入手したコードに埋め込み、以下のようなコードをローカルに保存します(以下はAnchor tagを使った例です)。 必要に応じてサイズの指定が可能ですが今回は省略しています。 <script type="text/javascript" src="https://www.dropbox.com/static/api/2/dropins.js" id="dropboxjs" data-app-key="アプリのキー"></script> <a href="共有リンクURL" class="dropbox-embed"></a> Step4. ローカルでの動作確認 Step3で作成したファイルをVSCodeの Liver Server 拡張機能などを使って展開しブラウザでアクセスします。 埋め込んだコンテンツが表示されればOKです。 marketplace.visualstudio.com Step5. 実際の埋め込み Step1で作成した Dropbox appのdomainsに、実際に公開するサイトのドメインを追加して動作を確認すればOKです。 まとめ 以上が Dropboxコンテンツを外部サイトに埋め込む手順のまとめとなります。 使用するコンテンツは画像の他に、Google スライドなども問題なく埋め込むことができました。 操作性の課題を挙げていますが、その他にも埋め込み画面にファイル名が必ず表示されたり、Dropboxロゴの非表示ができないなどデザイン面でのカスタマイズがもう少し柔軟にできると、利用シーンが広がるのではと思っています。 今後の機能拡張に期待したいと思います!

GitHub Actionsで特定のブランチへのプルリクだけレビュワーをランダムに抽選してみた

yappli
2022-01-17 01:08:23
あまり聞き慣れないインテグレーションエンジニアという職種の尾宇江です! 今回は、GitHub Actions と GitHub Teamsのauto assignmentで、特定のブランチに向けたプルリクだけレビュワーをランダムに抽選してみた件を紹介します。 やりたかったこと 「プルリクのレビューはランダムで振り分けたいけど、developブランチからstagingなど機械的にマージする時はレビュワーつけたくないなー」 事前に調べた内容と課題整理 GitHub Teamsに、auto assignmentという、チームメンバーに均等にレビューを割り当てるすごい機能がある! codeownersを使えば、プルリクがオープンされた時に自動的にレビュワーをつけることができる! @organization/team のように指定すれば、チームをコードオーナーに指定することもできる ↑の2つを組み合わせれば、ほぼコード書かずにレビュワーのランダム振り分けができるなって思ったのですが… コードオーナーではディレクトリやファイル名の指定はできるものの、ブランチの指定ができませんでした。 作ったもの ということで、GitHub ActionsでブランチをチェックしてからTeamにレビュー依頼をすることにしました。 以下のファイルを.github/workflows/request_reviewer.yml として保存しています。 今回は、feature/* や *_dev といったブランチから、delivery/* というブランチへのプルリクが作成された or ドラフトから レビューOKとなったタイミングで、developer というチームにレビューリクエストを投げています。 また後述の理由により、GitHubのREST APIを叩く際には、Actionsで最初から使える secrets.GITHUB_TOKEN ではなく、個人アカウントで発行したパーソナルアクセストークンをリポジトリの環境変数に保存して使っています。 name: Request a Reviewer on: pull_request: types: [opened, ready_for_review] branches: # ここで指定するのは、PRの入れ先となるブランチです - "delivery/*" jobs: assign: runs-on: ubuntu-latest # 入れ元となるブランチの指定をgithub.head_refを利用 # プルリクがドラフトだった場合はレビュー依頼しないように、pull_request.draft でチェック if: ${{ (startsWith(github.head_ref, 'feature/') || endsWith(github.head_ref, '_dev')) && github.event.pull_request.draft == false }} steps: - name: Auto Assign run: | curl -X POST \ -H "Accept: application/vnd.github.v3+json" \ -H "Authorization: token ${{ secrets.PERSONAL_ACCESS_TOKEN }}" \ -d "{ \"team_reviewers\": [\"developer\"] }" \ https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/requested_reviewers おまけ REST APIでパーソナルアクセストークンを使っている理由 -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ を使ってActionsからREST APIを実行した場合、以下のようなエラーとなってしまいました。 { "message": "Validation Failed", "errors": [ "Could not resolve to a node with the global id of 'xxxxxxxx'." ], "documentation_url": "https://docs.github.com/rest/reference/pulls#request-reviewers-for-a-pull-request" } 上記のエラーでハマったのですが… GitHub APIのバグじゃないかなって言ってるIssueを参考にパーソナルアクセストークンを発行して対応しました。 GitHub CLIを使わなかった理由 GitHub CLIを使うと - run: gh pr edit $NUMBER --add-reviewer @organization/developer env: GH_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} GH_REPO: ${{ github.repository }} NUMBER: ${{ github.event.pull_request.number }} のようにシンプルに書けていいなと思ったのですが… GraphQL: Your token has not been granted the required scopes to execute this query. The 'login' field requires one of the following scopes: ['read:org'], but your token has only been granted the: ['repo'] scopes. Please modify your token's scopes at: https://github.com/settings/tokens., Your token has not been granted the required scopes to execute this query. The 'name' field requires one of the following scopes: ['read:org', 'read:discussion'], but your token has only been granted the: ['repo'] scopes. Please modify your token's scopes at: https://github.com/settings/tokens., Your token has not been granted the required scopes to execute this query. The 'slug' field requires one of the following scopes: ['read:org', 'read:discussion'], but your token has only been granted the: ['repo'] scopes. Please modify your token's scopes at: https://github.com/settings/tokens. のようなエラーが出てしまいます。 こちらはGitHub CLIの既知のバグらしく次回のリリースで解消されるようなので、その時にはCLIで書き直そうと思いますー。 チームの指定方法 REST APIの場合は reviewers ではなくて、team_reviewers でチーム名を指定。 例 @organization/developer の場合は、developer だけ GitHub CLIの場合は、--add-reviewer に @organization/developer のように組織/チーム名という組み合わせで指定するという違いにもハマりました。

json.Unmarshalでmap[string]interface{}型にパースするときの注意点

yappli
2022-01-11 01:00:00
サーバーサイドエンジニアの田実です! 今回はGoの json.Unmarshal 関数で map[string]interface{} の型を指定したときに発生していた事象とその対策を併せて紹介します! なにが起きていたか 以下のように任意のキー、バリューを含むJSON文字列を map[string]interface{} 型でパースして、任意のバリューを文字列として取り出す処理がありました。 var res map[string]interface{} json.Unmarshal([]byte(`{"test": 1234567}`), &res) fmt.Printf("%v", res["test"]) 任意のバリューがstring, bool、小さいintの値ではうまくいったのですが、上記のように大きいintを含むJSONをパースしたときに、指数表記で文字列が表示されました(本当は 1234567 という文字列がほしい 1.234567e+06 なぜ発生したか どうやら、json.Unmarshalでinterface{}を指定すると数字はfloat64でパースされ、大きいfloat64を "%v" で表示すると指数表記で表示されるようです。 stackoverflow.com 確かにコードを読むと数値系はfloat64にパースされています。 func (d *decodeState) convertNumber(s string) (interface{}, error) { // ... f, err := strconv.ParseFloat(s, 64) if err != nil { return nil, &UnmarshalTypeError{Value: "number " + s, Type: reflect.TypeOf(0.0), Offset: int64(d.off)} } return f, nil } https://cs.opensource.google/go/go/+/refs/tags/go1.17.5:src/encoding/json/decode.go;l=847-852 対策 json.Unmarshalではなくjson.Decoderとdecoder.UseNumber()を使うと、json.NumberというstringのDefined typeでパースされます。 decoder := json.NewDecoder(bytes.NewReader([]byte(`{"test": 1234567}`))) decoder.UseNumber() err := decoder.Decode(&res); コードを読むと、convertNumberの関数の先頭の処理に、useNumberが設定されているとjson.Numberで返す処理が入っています。 // convertNumber converts the number literal s to a float64 or a Number // depending on the setting of d.useNumber. func (d *decodeState) convertNumber(s string) (interface{}, error) { if d.useNumber { return Number(s), nil } // ... } https://cs.opensource.google/go/go/+/refs/tags/go1.17.5:src/encoding/json/decode.go;l=844-846 このjson.Numberを "%v" で表示すると数値がそのまま文字列で表示されるので、これで対応できました!

npm v7におけるsudo run scriptがスーパーユーザーで実行されず、ハマった

yappli
2022-01-04 06:06:02
[JavaScript] [フロントエンド]
はじめに フロントエンドエンジニアの小林(baco16g)です。 2021年10月26日に、Node.js v16がアクティブLTS(Long Term Support)に移行しました。 目前でNode.js v14からマイグレーションする必要はありませんでしたが、npm v7以降への対応や Apple Siliconチップへの対応を鑑みると、早めに動作検証すべきだと考えて調査を始めました。 Yappli CMSのクライアントサイドは、CirlceCIジョブでNuxt.jsのStatic Site Generationを実行して、生成された静的ファイルをAmazon S3 バケットにアップロードして、Amazon CloudFrontで配信しています。さらに、BFF(backend for frontend)な領域にはGoを採用しています。 したがって、フロントエンド領域でNode.jsに依存している箇所はそれほど多くなく、調査自体は難なく終えました。終えたつもりでした。 SentryのReleaseが生成されない Yappli CMSのクライアントサイドでは、エラートラッキングにSentryを使用しています。具体的には@nuxtjs/sentryを使用しており、Nuxt.jsのStatic Site Generation時にReleaseの生成を行なっています。 docs.sentry.io しかし、Node.js v16へのマイグレーションコミット以降は、Releaseが突如として生成されなくなりました。SourceMapのデプロイ処理はRelease機能に依存しているため、このままでは検知したエラーの調査がしづらくなってしまいます。 ログを見たところ、CirlceCIジョブでエラーは発生しておらず、代わりに下記のWarningメッセージが表示されていました。 WARN Sentry release will not be published because "config.release" was not set nor it was possible to determine it automatically from the repository @nuxtjs/sentryを読んでみる どうやら、publishRelease.releaseが未設定の場合に出力されるメッセージのようです。ドキュメントによれば、config.releaseは自動解決されるため、本来ならば起こり得ないはずです。 export function webpackConfigHook (moduleContainer, webpackConfigs, options, logger) { ... // 省略 if (options.config.release && !publishRelease.release) { publishRelease.release = options.config.release } if (!publishRelease.release) { // We've already tried to determine "release" manually using Sentry CLI so to avoid webpack // plugin crashing, we'll just bail here. logger.warn('Sentry release will not be published because "config.release" was not set nor it ' + 'was possible to determine it automatically from the repository') return } ... // 省略 } sentry-module/hooks.js at 607511a4e4c6000fbea78e811249839d4a1f7272 · nuxt-community/sentry-module · GitHub さらに深ぼってみましょう。@nuxtjs/sentryでは、build:before と webpack:configという二つのNuxt Hooksを登録しています。今回のWarningは webpack:config hookで発生していますが、このhookではconfig.releaseを解決する処理が見当たりません。したがって、build:before hookで解決していると考えられます。 ...ありました。@sentry/cliのインスタンスを作成して、cli.releases.proposeVersionを実行しています。 export async function buildHook (moduleContainer, options, logger) { if (!('release' in options.config)) { // Determine "config.release" automatically from local repo if not provided. try { // @ts-ignore const SentryCli = await (import('@sentry/cli').then(m => m.default || m)) const cli = new SentryCli() options.config.release = (await cli.releases.proposeVersion()).trim() } catch { // Ignore } } ... // 省略 } sentry-module/hooks.js at 607511a4e4c6000fbea78e811249839d4a1f7272 · nuxt-community/sentry-module · GitHub catch節のIgnoreコメントから察するに、何かしらのエラーが発生し得るが、あえてハンドリングがされていないようです。どのようなエラーが発生しているかを可視化したいため、patch-packageを用いてエラーをthrowさせてみます。 その結果、下記のエラーメッセージを確認することができました。 error: Failed to load .sentryclirc file from the home folder. caused by: Permission denied (os error 13) thread 'unnamed' panicked at 'Config not bound yet': src/config.rs:85 home folder上の.sentryclircを開こうとしたが権限がなく、エラーが発生しているようです。 ということなので、@sentry/cliのコードも読む必要がありそうです。 @sentry/cliを読んでみる npmで公開されている@sentry/cliは、JavaScriptをハブにして、Rustで生成したバイナリファイルを実行するというのが大まかな流れのようです。 バイナリファイルの実行には、child_process.execFileを使用しています。uidやgidを指定していないため、ここに問題はなさそうです。 function execute(args, live, silent, configFile, config = {}) { const env = { ...process.env }; ... // 省略 return new Promise((resolve, reject) => { ... // 省略 childProcess.execFile(getPath(), args, { env }, (err, stdout) => { if (err) { reject(err); } else { resolve(stdout); } }); }); } sentry-cli/helper.js at 42a0e70abe582f2aaabc1e3d8d55186864f95dbf · getsentry/sentry-cli · GitHub 続いて、バイナリファイル側のコードを追いましょう。先ほどのエラーメッセージを出力している箇所を確認してみます。 dirs::home_dirで取得したパスに対してCONFIG_RC_FILE_NAMEをジョインして、そのファイルパスに対してfs::File::openを実行しています。 fn find_global_config_file() -> Result<PathBuf, Error> { dirs::home_dir() .ok_or_else(|| err_msg("Could not find home dir")) .map(|mut path| { path.push(CONFIG_RC_FILE_NAME); path }) } fn load_global_config_file() -> Result<(PathBuf, Ini), Error> { let filename = find_global_config_file()?; match fs::File::open(&filename) { Ok(mut file) => match Ini::read_from(&mut file) { Ok(ini) => Ok((filename, ini)), Err(err) => Err(Error::from(err)), }, Err(err) => { if err.kind() == io::ErrorKind::NotFound { Ok((filename, Ini::new())) } else { Err(Error::from(err) .context("Failed to load .sentryclirc file from the home folder.") .into()) } } } } sentry-cli/config.rs at 42a0e70abe582f2aaabc1e3d8d55186864f95dbf · getsentry/sentry-cli · GitHub docs.rs ここまでコードリーディングした限り、Sentry側に不穏な箇所は一つもありません。 原因はNode.jsもしくはnpmの可能性が高い whoami と echo $HOMEをしてみる まず、CircleCIジョブでは、スーパーユーザーでnuxt generateを実行していました。 sudo bash -c "SENTRY_DSN=$SENTRY_DSN SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN npm run generate" 「なぜスーパーユーザーとして実行しているのか」という疑問は持ちつつも、スーパーユーザーであれば尚更に権限エラーが発生するのは不可解です。 そこで、対象のスクリプトでwhoamiと$HOMEを取得してみると、下記のような結果になりました。 const os = require('os'); console.log('USER', os.userInfo().username); // circleci console.log('HOME', process.env.HOME); // /root スーパーユーザーでnpm scriptを実行したにもかかわらず、circleciユーザーとして実行されています。また、$HOMEは/rootになっています。なるほど。権限エラーが発生すること自体は納得です。 問題の原因はNode.js?それともnpm? 簡易的なデモリポジトリを作成して、下記のような4つのコマンドを実行してみました。 その結果、sudo npm run whoamiのみが期待値とは異なる結果になりました。したがって、原因はnpmにあると断言できます。 $ circleci config process .circleci/config.yml > process.yml $ circleci local execute -c process.yml --job my-job ====>> npm run whoami [USER] circleci [HOME] /home/circleci ====>> sudo npm run whoami [USER] circleci [HOME] /root ====>> node whoami.js [USER] circleci [HOME] /home/circleci ====>> sudo node whoami.js [USER] root [HOME] /root GitHub - baco16g/demo-npm-userdo npm/cliを読んでみる npm/cliのコードリーディングをして具体的な原因を探りましょう。 package.jsonのmainにはcli/index.jsが指定されているので、まずはこのファイルから見てみます。このファイルでは、さらに./lib/cli.jsをrequireでロードしています。 ./lib/cli.jsでは、コマンドライン引数を利用して、npm.execを実行しています。 module.exports = async process => { ... // 省略 const Npm = require('./npm.js') const npm = new Npm() ... // 省略 cmd = npm.argv.shift() ... // 省略 await npm.exec(cmd, npm.argv) cli/cli.js at d8aac8448e983692cacb427e03f4688cd1b62e30 · npm/cli · GitHub 次に./npm.jsのnpm.execを見てみましょう。./commands/${command}.jsで対象コマンドのファイルをrequireしていますね。 async cmd (cmd) { ... // 省略 const command = this.deref(cmd) ... // 省略 const Impl = require(`./commands/${command}.js`) const impl = new Impl(this) return impl } async exec (cmd, args) { const command = await this.cmd(cmd) ... // 省略 command.exec(args) } cli/npm.js at d8aac8448e983692cacb427e03f4688cd1b62e30 · npm/cli · GitHub 今回は、runコマンドを追いたいので、./commands/run-script.jsを見てみましょう。最終的にrunScriptを実行しています。 const runScript = require('@npmcli/run-script') async exec (args) { ... // 省略 return this.run(args) } async run ([event, ...args], { path = this.npm.localPrefix, pkg } = {}) { ... // 省略 for (const [event, args] of events) { await runScript({ ...opts, event, args, }) } } cli/run-script.js at d8aac8448e983692cacb427e03f4688cd1b62e30 · npm/cli · GitHub @npmcli/run-scriptを使用・実行しているので、そちらを見てみます。 const runScriptPkg = require('./run-script-pkg.js') ... // 省略 const runScript = options => { validateOptions(options) const {pkg, path} = options return pkg ? runScriptPkg(options) : rpj(path + '/package.json').then(pkg => runScriptPkg({...options, pkg})) } cli/run-script.js at d8aac8448e983692cacb427e03f4688cd1b62e30 · npm/cli · GitHub runScriptPkgでは、promiseSpawnでchild_procesを生成しています。spawnというキーワードから察するに、真相に近づいていそうです。 const promiseSpawn = require('@npmcli/promise-spawn') ... // 省略 const runScriptPkg = async options => { ... // 省略 const p = promiseSpawn(...makeSpawnArgs({ event, path, scriptShell, env: packageEnvs(env, pkg), stdio, cmd, stdioString, }), { event, script: cmd, pkgid: pkg._id, path, }) } ... // 省略 cli/run-script-pkg.js at d8aac8448e983692cacb427e03f4688cd1b62e30 · npm/cli · GitHub ついに原因と思われるコードを発見しました。process.getuidの返り値が0、つまりスーパーユーザーであれば、inferOwner.sync(cwd)を実行して得られたuidとgidを子プロセスに流しています。 const inferOwner = require('infer-owner') const promiseSpawn = (cmd, args, opts, extra = {}) => { const cwd = opts.cwd || process.cwd() const isRoot = process.getuid && process.getuid() === 0 const { uid, gid } = isRoot ? inferOwner.sync(cwd) : {} return promiseSpawnUid(cmd, args, { ...opts, cwd, uid, gid }, extra) } リリースノートを確認すると、v7.0.0-beta.0 (2020-08-04)に以下の記述がありました。 The user, group, uid, gid, and unsafe-perms configurations are no longer relevant. When npm is run as root, scripts are always run with the effective uid and gid of the working directory owner. この修正に関するIssueやPull Requestなどを見つけることはできませんでしたが、理由は納得はできます。もしも、実行対象のパッケージに悪意のあるスクリプトが含まれていた場合、スーパーユーザーの権限で実行するのは危険です。 結論 npm run generateをスーパーユーザーとして実行する必要がないと判断して、circleciユーザーで実行するように変更を加え、解消させました。 SENTRY_DSN=$SENTRY_DSN SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN npm run generate npmのドキュメントでは太古から以下の注意が記載されているため、npmのバージョンに依らず、sudoを利用したscriptの実行は避けるべきです。 Don't prefix your script commands with "sudo". If root permissions are required for some reason, then it'll fail with that error, and the user will sudo the npm command in question. docs.npmjs.com

CSSスプライトを使用して、簡単にぐるぐる動くアニメーションを実装しました。

yappli
2021-12-31 05:26:13
はじめまして。フロントエンドエンジニアの相原と申します。 普段はYappli CMSのUIを実装しております。 通常業務で大きく動くアニメーション等を実装する機会はあまり(と言うか殆ど)無いのですが今回久しぶりにそういった機会に恵まれたのでその辺りの事をお伝え出来ればと思います。 仕様のようなもの 内容的にはちょっと演出のある社内向けLPのような感じで、元々の仕様は以下の通り。 格子状に顔画像を配置する。 顔画像は数秒おきのタイミングでランダムに被りなく入れ替わる。 基本的に最初だけ読み込めば後は共有モニター等で表示しっぱなし。 さらにこれにアニメーションを追加しました。こんな感じで動きます。 内容はいいからサンプルという方はこちらからどうぞ。 https://jsfiddle.net/takuto_ex/oq3zay2u/ Yappli CMSではVue.jsのフレームワークであるNuxtで実装を行っているのですが、今回のLPも同じ環境で実装しました。 CSSスプライトを使うに至った経緯 今回のサンプルでは画像6枚なのですが実際のLPでは40秒周期で175px*175pxの画像を18枚同時に入れ替えます。 その度に画像読み込み時間が発生し、表示のチラつきがどうしても起こるのでコレは見た目的にイケてないぞ・・・と考えました。 さてどうしようかな・・・となった時に思い出したのがCSSスプライトです。なつかしい。 CSSスプライトは複数の画像を一枚の画像にまとめ、background-positionを使い表示するテクニックなのですが今ではあまり使われていません。 そもそも2010年代前半はまだHTTPリクエスト順にレスポンスが返ってくる時代で、アイコンやUI画像を多用するWebサイトでは表示が重くなってしまう問題を抱えていました。 なのでアイコンやボタン等UIパーツを1枚の画像にしてしまえばリクエストは1度で済みます。そのためCSSスプライトは重宝されていたのですが、2015年頃からHTTP/2にバージョンアップされ並行して複数のリクエストを同時に処理出来るようになりました。 今でもサイトの表示速度をカリカリにチューニングしなければならないような現場では使用されているようですが、そうで無い場合実装するには手間も多いため現在ではあまり使われなくなってしまいました。 自動で連結した画像とCSSを生成してくれたGulp等のタスクランナーも更新がかなり前に止まってしまいました。 さみしい。 ただ今回のように決まった画像郡から定期的に表示する画像を複数同時に入れ替えるような実装をする際はかなり有効かと考えます。 最初の読み込みさえ完了すれば基本的には表示しっぱなしのページですし、background-positionを使うならtransitionを使って演出する事が出来ます。 ちなみに今回ブログ用に作ったサンプルではStitchesというツールを利用して画像とcssを用意しました。手軽でうれしい。 http://draeton.github.io/stitches/ 画像はこんな感じ。 上記の顔写真は写真AC様にてAIにより生成されたものを使用してみました。すごすぎる。 AI人物素材(ベータ版)|写真素材なら「写真AC」無料(フリー)ダウンロードOK 若干の工夫 またCSSスプライトを使用した以外変わったところはあまり無いのですがちょっとした工夫をしています。 transitionをそのまま実装していると動きはランダムで派手になるのですがdurationが一定なためすべての画像がピタッと止まります。どうにもなんかイケてない気がするのでそれぞれのtransition-durationも1秒〜3秒でバラけるようランダムにしてみました。 ~ @for $i from 1 through 12 { .face-#{$i} { transition-duration: random(3) + s } } ~ SCSSだとrandomが使えるので、複数のtransition-durationを散らしたい時等は便利ですね。 ただ正直な話をすると実装してる最中SCSSのrandomを全く思い出せず、この記事用のサンプルを書いてる時に思い出しました。くやしい。 まとめ 通常の業務はYappli CMSのフロント実装がメインなため今回のような業務は結構珍しいのですが、たまにはこういった作業もフロントエンドエンジニアとして領域が広くなって良いかもしれません。 今回の内容が、何かしらの参考になりましたら幸いでございます。

ヤプリ初のアドベントカレンダー振り返り

yappli
2021-12-24 11:43:56
メリークリスマス! ヤプリのテックブログ編集長を担当している望月です。 近況としては、長女がサンタさんに高額なプレゼントをリクエストしましたが、コロナ下におけるサンタさん事情を説明して別のプレゼントにするための説得に注力しておりました。 この記事はヤプリ Advent Calendar 2021最終日の記事となります。 あらためてですが、今回ヤプリとして初めてアドベントカレンダーに挑戦しました。 テックブログ編集長として、この取り組みの振り返りをしたいと思います。 きっかけ 11月半ばくらいにエンジニアのマネージャー陣で話していたとき、「最近テックブログに力をいれているけど、アドベントカレンダーやらないの?」みたいな話が何名かのメンバーからあがっているとの話題が出ました。 私個人としては「今年はテックブログ定着の年、イベント系は来年くらいにできればいいな」くらいの感覚でいたため、執筆メンバーが集まるかが一番の懸念ポイントでした。 しかし話しているうちに「アドベントカレンダーやります! → x人しか集まリませんでしたorz → 来年再挑戦します!」というネタが稲妻のように降臨しました。 結果、失敗しても良いからやってみようという流れになりました。 ヤプリのこういったノリが好きです。 [後から見返すととても適当な議事録] 準備 まず必要となるのはアドベントカレンダーの仕組み(サービス/システム)です。 いくつか候補が出ましたが、今回はAdventarを選択しました。 テーマは特に決めずに、テック以外でもOKとしました。 その流れで、投稿先も会社のテックブログに限定しないことにしています。 (もはやTwitterのツイートでもいいのでは、という謎の意見も出ていました) これを機に「会社のテックブログはちょっと・・・」みたいな人も巻き込めたらという思いもありました。 ぞくぞく集う仲間たち 前述の通り、執筆メンバーが集まらない想定でいたため、意外にも日に日にカレンダーが埋まっていくのを驚きながら眺めていました。 直接お知らせしていない開発以外のメンバーがSNSの投稿を見て参加表明していただいたり、テックブログとは別に運営されている#times_yappliというメディアとコラボレーションしたり、ヤプリのバリューの一つである「チームドリブン」が存分に感じられた日々です。 余談ですが、この仲間が集う感が、アベンジャーズ/エンドゲームの1シーン(アベンジャーズ・アッセンブル)っぽくて心が熱くなりました。 今でも思い返すと実際に涙が出てきます。(アベンジャーズの方です) まとめ こうして当初の懸念を覆して成功に終わったヤプリのアドベントカレンダーですが、編集長的には色々と反省点もあります。 ・実際に開始してからの盛り上げができなかった 毎日投稿されていく記事が嬉しくて、基本的にはニヤニヤしながら読むだけでした。 社内のSlackで告知したり、投稿していただいた記事を取り上げてコメントをしたり、もっと何かできたのではと反省しています。 ・全社を巻き込まなかった アドベントカレンダーをやることについて、全社的にはあまり宣伝・告知などしませんでした。 開発以外でも賛同してくれるメンバーは絶対にいるので、もっとヤプリのポテンシャル(ちゃんとメンバー集まる)を信頼して最初から全社イベント感を出せば良かったです。 ・企画開始が遅かった 実際のところは、12/1を迎えた時点でまだ埋まっていない枠もありました。 もっと早くから開始して、皆さんに十分な準備時間を提供できれば良かったです。 最後に、結果的にはチャレンジして良かったなという感想しかありません。 また来年も実施するつもりなので、今年の反省点を改善しながら新たな挑戦をしていきます! おまけ 以下はアドベントカレンダーに投稿していただいた皆さんの記事へのリンクと簡単なコメントです。 気になった記事があったら、是非見てみてください! 2021年の五大感動本|阿部 昌利|note データサイエンティストの阿部さんによる感動した本の紹介。 前はとにかくジャンル問わずに本をたくさん買って読んでいたものの、最近は子育てなどを言い訳に離れてしまっていました。 でもこの投稿を見て、また本を読み始めました。 部署の垣根を超えたプロダクト改善イベント「ヤプリク」を新入社員がレポート #今日のヤプリ|#times_yappli|note 開発ディレクターの小野田さんによるヤプリク(プロダクト改善イベント)のレポート。 入社間もないうちに運営チームに抜擢されて、大成功に導いた凄腕の方です。 ヤプリは開発だけでなく全社でサービスを作っていることが感じられて、とても素敵なイベントです。 インターン生がプッシュ通知の配信時刻最適化に取り組んでみた! - Yappli Tech Blog データサイエンティストとしてインターンをしていただいた山本さんによるプッシュ通知の配信時刻と開封率の関連を調査結果。 仮説を元に実際のデータを分析して結論に導くプロセスが、とても興味深かったです。 「データ=事実」みたいな勝手な思い込みをしがちですが、「データの集計ミス」という観点には考えさせられました。 サーバーサイドエンジニアによるエンジニア採用の取り組み in 2021 - Yappli Tech Blog サーバーサイドエンジニアの田実さんによるエンジニア採用の取り組み紹介。 今年もっとも採用に成功したのがサーバーサイドエンジニア!成功までの道筋をまとめてくれています。 積極的に他チームにも展開していただき助かっているのですが、もらうばかりではなく返さないとと決意をあらたにしております。 賃貸マンションでGoogle Nest Doorbellを使ってみた - 24/7 twenty-four seven iOSエンジニアの岸川さんによるGoogle Nest Doorbellを使ってのまとめ。 私も賃貸でスマートホーム系の製品はあまり手を出していなかったのですが、意外とやり方次第で導入できるのだなと感じました。 「ビデオ会議中にクイック応答」などまさに最近のリモートワークに適した素敵なソリューションです。 Ultimate Hacking Keyboard の V2を買ったので自慢したい件 - Qiita インテグレーションエンジニアのoueさんによるキーボード自慢。 私は全くキーボードにはこだわりがないタイプなので、ハンガリーから通販してまで入手しようという情熱に驚いています。 またリンク先のマニュアルを見てみたら手首の角度まで指定があって、作り手のこだわりが感じられました。 ヤプリのサーバサイドで使われている技術について紹介してみた - Yappli Tech Blog サーバーサイドエンジニアの山田さんによるサーバーサイドで使っている技術の紹介。 ヤプリがPHPからGoに移り変わっていった背景とともに、様々な技術の使いどころを説明しています。 また数年後にはもしかしたら大きく変わっているのかな、なんて妄想しながら読んでいました。 健康な体を求めて ver2021.|kato_LoveBlackCoffee|note iOSエンジニアのkatoさんによる身体の健康への取り組み。 私は健康診断の結果から目を逸らしがちなので、きちんと向き合っている姿勢が眩しいです。 アプリエンジニアらしく、様々なアプリを組み合わせて継続されている点が見習いたいところです。 Go標準パッケージのコードリーディング会でチームが強くなった話 - Yappli Tech Blog サーバーサイドエンジニアの森谷さんによるGo社内勉強会の紹介。 コードをちゃんと読まなければと思いつつ、業務スピード等を優先して流し読みしてしまいがちな人(私)にちょうど良いイベントです。 初期の勉強会に参加して言っていることが分からず挫折したトラウマがありますが、勇気を出してまた参加してみよう・・・かな? 本気でサバゲをしてたら、なぜかアプリまで作ってた話 - Yappli Tech Blog インテグレーションエンジニアのわたなべさんによるサバゲ部の活動。 勝手なイメージですが、広大な敷地を借りてやるのかと思いきや、普通に都内の施設でできるんですね。 またアプリを作った話を絡めてテックブログっぽく記事を仕上げていただくという熟練の技が光ります。 Firebase Management APIを使ってFirebaseプロジェクトの登録をする方法 - Qiita Androidエンジニアの @k-furuya さんによるFirebase Management APIを使ってのまとめ。 ヤプリは多くのアプリを取り扱う関係上、こういったAPIなどを活用する場面が色々あります 。 私は自動化大好きおじさんなので、おそらくそのうち使うことになるでしょう。 ユニバーサルリンク機能についての備忘録 iOSエンジニアのコガさんによるユニバーサルリンク機能の調査結果。 恥ずかしながら初めて知った機能ですが、ユニークなhttpのURLを元に異なる挙動となるのは面白いですね。 こういった新たな発見があるのも自社テックブログの良いところです。 新卒採用イベントに続々参加しています! - Yappli Tech Blog サーバーサイドエンジニアの松川さんによるエンジニア新卒採用の取り組み紹介。 ヤプリでついに始まった新卒採用、プロダクトのデモは本当にいつも反応が良いんですよね。 私も学生さんとお話しさせていただく機会が増えていますが、とてもしっかりした考えの方が多く反対に刺激をもらっています。 Zapierを使ってローマ字チェックを自動化する - Yappli Tech Blog コーポレートエンジニアの原口さんによるZapierを使った自動化の紹介。 Zapierでコードを書けるのは知っていましたが、まさかnpmも使えるとは驚きです。 もはやZapierマスターと化している頼りになる存在です。 Terraform S3上のRemote Stateのリソースを別階層にお引越しする - Yappli Tech Blog SREの羽渕さんによるterraformのRemote State移行のTips。 私もテックブログ編集長をやるかたわらterraformを触ることもあるので、すごいよく分かるシーンです。 うまくメンテナンスしながらインフラコード化を推進しようとあらためて決意しました。 自宅環境で Log4j 脆弱性対応を行う - Yappli Tech Blog プロダクト開発本部長の佐藤さんによるLog4jの脆弱性を家庭で行おうという試み。 もちろんヤプリでも調査・対応を行なっていましたが、本部長宅でもこんな戦いが発生していたとは思いもよりませんでした。 通常は自分が関わっている仕事だけに目が行きがちですが、プライベートで利用しているものも意識する視野の広さが勉強になりました。 非IT系アラフォーサラリーマンがITベンチャーで過ごした3年間とは? #ヤプリアドベント 2021 Day17|島袋孝一*しまこ*|note 非IT人材なアラフォーサラリーマン島袋さんによるヤプリの思い出。 マーケティング、需要と供給の間にあるギャップを埋めるコミュニケーションの大切さについて考えさせられました。 本筋とは関係ないですが「定年まで1社で勤め上げた親の背中を」のくだりはすごい分かります。 機械学習で会議室アプリを触らずに操作したい! - Yappli Tech Blog AndroidエンジニアのはやしさんによるCore ML/Create MLを試してのまとめ。 こちら実際にデモを見せていただいたのですが、(本人ということもあり)すごい精度が高く認識できており興奮しました。 組み込み先のタブレットのカメラが覆われているというトラブルに負けず、ぜひ導入してほしいところです。 新卒で入社してから読んでみた本を並べてみた。 - Yappli Tech Blog サーバーサイドエンジニアの加納さんによる書籍購入補助制度の活用紹介。 周りのベテランエンジニアに負けないスピードで成長されているのは、地道な積み重ねによるのですね。 自身のフェーズ・環境に合わせて制度を活用されている姿を見るととても嬉しいです。 非エンジニアが開発本部のLT大会に潜入してきた! #今日のヤプリ|#times_yappli|note コーポレートPRのちみんさんによるLT大会の潜入記事。 最近ちょくちょくエンジニア以外の方もLT大会に参加していただけるようになりました。 エンジニアとしても自分たちを知ってもらえたり親近感を持ってもらえると嬉しいので、ぜひこの流れが広がるといいなあ。 Swift Playgrouds 4で遊んでみた! - ブログ - SCRIBLOG.com ヤプリのCTO佐野さんによるSwift Playgrouds 4のお試しまとめ。 私みたいな人間はiPadで開発する必要あるの?と遠ざけてしまうところ、しっかり新しいものを受け入れて実践された上で評価される姿勢が勉強になります。 後半はヤプリエンジニアみんなに読んでもらいたいなあ。 また「最近何をしているのかがわからない最たる人」とは自分は思っていても言えなかったので、誰が言ったのが気になります。 Continuous Corner Curve で丸みを感じる角丸を作る iOSエンジニアの三宅さんによるContinuous Corner Curveの調査記事。 比較してみないと分からないような違いではありますが、こういったところにAppleの思想みたいなものが感じられますね。(知ったような口) おじさんの衰えた目で差分確認の画像をじっと見ていたら、ゲシュタルト崩壊みたいな気分になりました。 Goのin-memory cache packageについて調査してみた - Yappli Tech Blog サーバーサイドエンジニアの鬼木さんによるGoのin-memory cacheの各パッケージまとめ。 新たな仕組みを導入するにあたって、きちんと必要な観点を洗い出して調査・比較していただけていると安心できますね。 9日目の森谷さんの記事でもあるように、ソースコードを読む文化が定着してきた良い例だと思います。

GoでS3 Batch Operationsを用いたS3間大量コピーを実現する

yappli
2021-12-24 02:19:24
[Go] [AWS]
サーバーサイドエンジニアの森谷です。 業務の中でS3からS3への大量オブジェクトコピーを行う必要が生じたのですが、これが思いの外簡単には実現できず、またGoから利用している記事もあまりなかったため今回はその実装などについて備忘録的にまとめようと思います。 S3 Batch Operationsとは S3 Batch Operationsとは、任意のS3オブジェクトのリストに対して一括でバッチ処理を行うことができる機能です。 docs.aws.amazon.com サポートされている操作は オブジェクトのコピー オブジェクトの復元 オブジェクトタグの置換・削除 Lambdaの呼び出し ACLの置換 etc... などがあります。 今回はオブジェクトのコピーをGoから実行する方法を解説していきます。 S3 Batch Operations自体の使い方 S3 Batch Operations自体の使い方については、以下の記事で大変詳しく解説されているためここでは説明を割愛させていただきます。 dev.classmethod.jp 実行の流れに沿って簡単に概要をまとめます。 対象オブジェクトの一覧が記載されたオブジェクトを作成し、S3の任意のバケット内に配置する 一覧オブジェクトには以下のどちらかの形式を使用 S3 Inventory 機能により生成されるmanifest.json並びにdataオブジェクト 参考: https://dev.classmethod.jp/articles/s3-inventory-reinvent/ csvファイル Bucket, Key (, VersionID) の一覧 https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/batch-ops-create-job.html#specify-batchjob-manifest ジョブを作成 ジョブ自体を作成するための権限と、各オペレーションに必要な権限が求められる 詳しくは後述 ジョブを実行 ジョブ作成とジョブ実行で権限が異なるため追加で設定 詳しくは後述 S3間コピーでBatch Operationsを使用するに至った経緯 Batch Operations云々といった話の前段として、そもそもGoでS3間の大量コピーを容易には実現できなかった背景があるため簡単に触れようと思います。 github.com/aws/aws-sdk-go/service/s3 パッケージを除くと、まずCopyObject関数を見つけることができます。 https://pkg.go.dev/github.com/aws/aws-sdk-go/service/s3#CopyObject こちらはオブジェクト1つに対するコピーは提供しているのですが、複数オブジェクトをコピーしようとするとその数だけリクエストする必要が生じます。 「そもそもs3 syncコマンド的な関数は提供されていないのか?」と思って探してみると、こんなexampleが見つかります。 https://github.com/aws/aws-sdk-go/blob/main/example/service/s3/sync/sync.go こちらは github.com/aws/aws-sdk-go/service/s3/s3manager パッケージを使用しています。 BatchUploadObjectなどの構造体があり、「名前的にこの辺の諸々で実現できそうでは?」と一瞬期待をするのですが、こちらはGoからS3へのアップロードもしくはダウンロードしか行えず、無駄にGoを経由しなければなりません。 当然その分時間もかかるため、少量のオブジェクトならばこちらでも問題ないのかもしれませんが、コピーに数十秒、数分かかるようですと他の手法を探したくなります。 こういった経緯でBatch Operationsを使ってみることにしました。 s3control packageからBatch Operationsを操作する 今回は対象リストをcsvで管理し、既にS3にアップロードされているという前提で話を進めます。 ジョブの作成 まずジョブを作成するコードは以下になります。 package main import ( "fmt" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3control" ) func main() { sess, _ := session.NewSession() // Configの設定は省略。これがジョブ参照・作成・実行権限を持つ必要がある client := s3control.New(sess) input := &s3control.CreateJobInput{ AccountId: aws.String(""), // AWS Account ID Manifest: &s3control.JobManifest{ // 対象リストオブジェクトの設定 Location: &s3control.JobManifestLocation{ ETag: aws.String(""), // オブジェクトのETag ObjectArn: aws.String(""), // オブジェクトのArn。 `arn:aws:s3:::bucket/key` の形式 }, Spec: &s3control.JobManifestSpec{ Format: aws.String(s3control.JobManifestFormatS3batchOperationsCsv20180820), // フォーマットを指定する定数。今回はCSV(バージョンIDなし)を指定 Fields: []*string{aws.String("Bucket"), aws.String("Key")}, // CSVのフィールド }, }, Operation: &s3control.JobOperation{ // 操作の指定 S3PutObjectCopy: &s3control.S3CopyObjectOperation{ // 今回はオブジェクトのコピーを指定 AccessControlGrants: []*s3control.S3Grant{ // アクセスコントロールリストを設定 { Grantee: &s3control.S3Grantee{ Identifier: aws.String(""), // オブジェクト所有者のID TypeIdentifier: aws.String(s3control.S3GranteeTypeIdentifierId), // 識別方法を指定する定数。今回はID }, Permission: aws.String(s3control.S3PermissionFullControl), // 権限を指定する定数。今回は読み書き両方持たせる }, }, MetadataDirective: aws.String(s3control.S3MetadataDirectiveCopy), // メタデータの扱いを指定する定数。今回は既存メタデータのコピー StorageClass: aws.String(s3control.S3StorageClassStandard), // ストレージクラスを指定する定数。今回はスタンダード TargetResource: aws.String(""), // コピー先バケット。`arn:aws:s3:::bucket` の形式 TargetKeyPrefix: aws.String(""), // コピー先Prefix }, }, Priority: aws.Int64(10), // ジョブの優先度。S3コンソールからジョブを作成した場合のデフォルト値は10 Report: &s3control.JobReport{ // ジョブの実行後のレポート出力設定 Enabled: aws.Bool(true), // レポート出力は任意で実行可能。今回は実行 Bucket: aws.String(""), // 出力先バケット。`arn:aws:s3:::bucket` の形式 Prefix: aws.String(""), // 出力先Prefix Format: aws.String(s3control.JobReportFormatReportCsv20180820), // レポート形式を指定する定数 ReportScope: aws.String(s3control.JobReportScopeAllTasks), // レポートのスコープを指定する定数 }, RoleArn: aws.String(""), // バッチ操作に必要な権限を持つRoleを指定。今回は対象オブジェクトのコピー権限を持つ必要がある } out, _ := client.CreateJob(input) fmt.Println(out.JobId) // ジョブIDのみがCreateJobの結果として返ってくる } はい。なかなかの項目数ですね。 個人的に感じたBatch Operationsを扱う最大の難関はこの設定項目の多さと権限周りの複雑さです。 項目はドキュメントを追うだけではなかなか理解が難しいので、一度S3コンソールからジョブを作成してみた後に、画面のどの項目がどのフィールドに対応しているのかを整理していくのが近道だと思います。 ジョブの確認 CreateJobの結果はJobIDのみが返却されます。 これはジョブのステータスが刻々と変化するためで、ジョブの情報を取得したい場合は都度都度 client.DescribeJob でJobIDを渡してリクエストすることになります。 job, _ := client.DescribeJob(&s3control.DescribeJobInput{ AccountId: aws.String(""), JobId: out.JobId, }) jobの主要な中身であるJobDescriptor構造体は次のとおりです。 https://pkg.go.dev/github.com/aws/aws-sdk-go/service/s3control#JobDescriptor 項目数が多いためStatusフィールドにのみ着目しますが、以下のenumで定義されています。 package s3control const ( JobStatusActive = "Active" JobStatusCancelled = "Cancelled" JobStatusCancelling = "Cancelling" JobStatusComplete = "Complete" JobStatusCompleting = "Completing" JobStatusFailed = "Failed" JobStatusFailing = "Failing" JobStatusNew = "New" JobStatusPaused = "Paused" JobStatusPausing = "Pausing" JobStatusPreparing = "Preparing" JobStatusReady = "Ready" JobStatusSuspended = "Suspended" ) CreateJobの直後、ステータスは Preparing を返します。 ジョブ作成が完了すると Suspended となり、ネクストアクションであるジョブの実行が可能となります。 ジョブの実行 ジョブを実行するコードは以下の通りです。 out2, _ := client.UpdateJobStatus(&s3control.UpdateJobStatusInput{ AccountId: aws.String(""), JobId: out.JobId, RequestedJobStatus: aws.String(s3control.JobStatusReady), }) ジョブのステータスを Ready に更新することがジョブを実行することとなります。 こちらは Active, Completing の状態を経て完了後に Completed になります。 権限周りでハマったこと すでに何度か触れてきたように、Batch Operationsの実行には ジョブに関する権限 操作に関する権限 の2種類の権限が必要となります。 今回のコードの例で言うと前者は s3:DescribeJob, s3:CreateJob , s3:UpdateJobStatus の権限が必要です。 後者はオブジェクトコピーのため s3:PutObject 周りの権限が必要です。 docs.aws.amazon.com まとめ 各要素の解説については公式ドキュメントや有名どころのテックブログ等に記事がありましたが、全体の体系を把握するのには手間取りました。 またジョブ作成部分の実装もなかなか難航し、HTTPステータスコード程度の情報しか返してくれないため、マニフェストの指定が悪いのか、どこかの権限が足りていないのか、あるいはジョブのその時のステータスと命令ステータスが噛み合っていないのか、デバッグには苦労しました。 (「AWSぜんぜんわからない。俺たちは雰囲気でIAMを利用している」な理解度だったこともあり、SREチームには大変お世話になりました。) こうした問題の切り分けのために、一度S3コンソール上でジョブ作成を成功させておき、それをGoから DescribeJob して適切なフィールドの中身を確認するのもおすすめです。

Goのin-memory cache packageについて調査してみた

yappli
2021-12-24 01:17:58
サーバーサイドエンジニアの鬼木です! 今回はGoのin-memory cache packageについて調べてみた記事になります。既存機能の拡張でin-memory cacheを使う必要があり調査したことが背景としてあります。 以下について比較、調査しました。 github.com/allegro/bigcache github.com/coocood/freecache github.com/hashicorp/golang-lru github.com/dgraph-io/ristretto また今回の拡張にあたってはcacheの有効期限を設定できるかという点が重要であり、その観点についてまとめたものが以下になります。 TTL: 各Itemのcache保存後に有効期限切れするまでの時間の一律設定可否 TTL(item単位): cache保存時に保存するitemごとに有効期限切れするまでの時間の設定可否 size limit: byte単位など、使用するmemoryのsizeでのlimitの設定可否 item length limit: Itemの数によるlimitの設定可否 ※(3、4については設定できるものはlimitを超えた場合は古いitemが期限切れ or 削除済みとなり、新しいItemの保存領域を作る) package TTL TTL(item単位) size limit item length limit bigcache ○ × ○ ○ freecache × ○ ○ × golang-lru × × × ○ ristretto × ○ ○ ○ github.com/allegro/bigcache 全てのItem一律のTTLが設定できるcacheです。 有効期限以外にもcacheのcleanのIntervalなど様々なconfigurationを設定できますがminimumではTTLの設定だけで初期化できるので高機能かつ扱いやすいinterfaceで、有効期限の設定の面でユースケースに一番適していたので今回はこちらを採用しました。 package main import ( "fmt" "github.com/allegro/bigcache/v3" ) func main() { eviction := 5 * time.Minute config := bigcache.DefaultConfig(eviction) config.HardMaxCacheSize = 128 //MB cache, err := bigcache.NewBigCache(config) if err != nil { panic(err) } key := "key" val := []byte("val") cache.Set(key, val) if entry, err := cache.Get(key); err == nil { fmt.Println(string(entry)) } cache.Delete(key) } github.com/coocood/freecache ItemごとのTTLが設定できるcacheです。 指定できる設定項目としてはcache全体のsize limitとItemごとのTTLだけですが、その分シンプルで扱いやすくItemごとの有効期限が異なるような複数の用途でin-memory cacheを使用するケースに適しているpackageです。 package main import ( "fmt" "github.com/coocood/freecache" ) func main() { cacheSize := 100 * 1024 * 1024 cache := freecache.NewCache(cacheSize) key := []byte("key") val := []byte("val") expire := 60 // expire in 60 seconds cache.Set(key, val, expire) if got, err := cache.Get(key); err == nil { fmt.Printf("%s\n", got) } cache.Del(key) } github.com/hashicorp/golang-lru 有効期限の設定がitemのlengthのみで時間やbyte単位でのlimit設定を持たないcacheです。 interface{}型のSetが可能で、保存するItemのsizeが一定でOOMの心配がない場合に一番シンプルに実装できるpackageです。 package main import ( "fmt" lru "github.com/hashicorp/golang-lru" ) func main() { length := 128 l, err := lru.New(length) if err != nil { panic(err) } key := "key" val := "val" l.Add(key, val) if v, ok := l.Get(key); ok { fmt.Println(v.(string)) } l.Remove(key) } github.com/dgraph-io/ristretto DgraphというGraphQL databaseのcontention-free cacheとして実装されたcacheで、他のcache packageに比べてパフォーマンスが高いことが特徴となっています(READMEにはHit RatiosやThroughputの他のpackageとの比較のベンチマークが載っています)。 ItemのSet時に呼び出し側でCostを引数に指定するやり方が少し特殊で運用難易度が上がる印象がありましたが、正しく運用できれば高いパフォーマンスを発揮できるpackageです。 package main import ( "fmt" "github.com/dgraph-io/ristretto" ) func main() { cache, err := ristretto.NewCache(&ristretto.Config{ MaxCost: 1 << 30, // maximum cost of cache (1GB). }) if err != nil { panic(err) } // 1 → Cost cache.Set("key", "val", 1) // wait for val to pass through buffers cache.Wait() if val, found := cache.Get("key"); found { fmt.Println(val) } cache.Del("key") } おまけ:bigcacheとfreecacheの内部的な仕組みについて bigcacheはGo1.15以降のバージョンでkey, valueがポインタでないmapではGCはそれらを省略するようになった仕様を利用してmap[uint64]uint32と単一のbyte sliceでデータを管理しています。SetしたItemの内容は全て単一のbyte sliceに保持し、mapにhash化されたkeyとbyte sliceから値を取得するための情報を保持しています。この仕組みによりGC時には単一のpointerであるbyte sliceの方のみを見るのでGCのオーバーヘッドを抑えられています。(参考:https://github.com/allegro/bigcache#how-it-works) freecacheもmapのポインタの数の加速度的な増加を抑えるため、データは256個のシャードに分けて保存しており、各シャードに2つのpointerを持つのでどんなにentryを保存してもmapのpointerの数は512を超えることはなくこちらもGCのオーバーヘッドを抑えています。(参考:https://github.com/coocood/freecache#how-it-is-done) このようにapplication内でリクエストスコープを超えて一定時間以上使用されるmapを使用するケースにおいて、mapに多くのpointerを持たせるとGCのオーバーヘッドが高くなるのでin-memory packageの実装においてその点の考慮が必要なことは個人的に興味深かったポイントでした。 まとめ in-memory cache packageの内部的な仕組みに興味を持つとGoの仕組みに詳しくなれて面白いなと思いました! ヤプリではGoの標準packageのソースコードリーディングなども行っており、Goのpackageのソースコードを読むのが好きな方、お待ちしております!

Selenium × ECS × APMツールで作る管理画面ログインhealthcheck

yappli
2021-12-22 04:14:38
サーバーサイドエンジニアの鬼木です! 今回はYappliのCMSにログインできるかどうかを確認、通知するhealthcheck機構を導入した記事になります。 とあるサービス障害で一時的にCMSのログイン認証機構に障害が発生し、標準的なTCPレベルのhealthcheckでは検知出来ず初動が遅れた背景から今回の実装に至りました。 上記のようなインシデントの対策として、サービスの入り口であるログイン画面の表示からログインし、ログイン後の最初のページを表示までを確認するhealthcheckをSeleniumで実装しました。 こちらのhealthcheckは10分おきに実行され、その実行ログをDatadogで収集、異常を検知した際にSlackに通知する仕組みになっています。全体構成は以下のようになります。 application appicationは言語はPythonでSeleniumを使用してログインの確認を行いました。実装は以下のようになります。 import os import ast import time import boto3 import base64 from selenium import webdriver from selenium.webdriver.chrome.options import Options from botocore.exceptions import ClientError sleepSec = 10 retry = 2 secret_name = os.environ['ENV'] + "-cms-healthcheck" region_name = "ap-northeast-1" def getChromeOption(): chrome_options = Options() chrome_options.add_argument("--no-sandbox") chrome_options.add_argument("--headless") chrome_options.add_argument("--disable-dev-shm-usage") chrome_options.add_argument("start-maximized") chrome_options.add_argument("enable-automation") chrome_options.add_argument("--disable-infobars") chrome_options.add_argument('--disable-extensions') chrome_options.add_argument("--disable-browser-side-navigation") chrome_options.add_argument("--disable-gpu") chrome_options.add_argument('--ignore-certificate-errors') chrome_options.add_argument('--ignore-ssl-errors') prefs = {"profile.default_content_setting_values.notifications" : 2} chrome_options.add_experimental_option("prefs",prefs) return chrome_options for num in range(retry): try: session = boto3.session.Session() client = session.client( service_name='secretsmanager', region_name=region_name ) get_secret_value_response = client.get_secret_value( SecretId=secret_name ) if 'SecretString' not in get_secret_value_response: raise Exception("SecretString is not contined in get_secret_value_response") secret = ast.literal_eval(get_secret_value_response['SecretString']) chrome_options = getChromeOption() driver = webdriver.Chrome(options=chrome_options) driver.delete_all_cookies() driver.get('https://xxxxxxxx') # 管理画面URL driver.find_element_by_name('email').send_keys(secret['healthcheck_user_email']) driver.find_element_by_name('password').send_keys(secret['healthcheck_user_password']) driver.find_element_by_id('gtm-login-button').click() time.sleep(sleepSec) cur_url = driver.current_url # url遷移チェック if cur_url != 'https://xxxxxxxx': # ログイン後URL raise Exception("transition to dashboard failed") time.sleep(sleepSec) # 表示チェック appTitle = driver.find_element_by_class_name('CmsAppHeaderAppList-linkName') if appTitle.text != 'SRE検証アプリ': raise Exception("check app title failed") driver.quit() break except Exception as e: if num < retry - 1: continue raise e print("True") 以下でコードの解説をしていきたいと思います! 解説 secret_name = os.environ['ENV'] + "-cms-healthcheck" region_name = "ap-northeast-1" # 略 for num in range(retry): try: session = boto3.session.Session() client = session.client( service_name='secretsmanager', region_name=region_name ) get_secret_value_response = client.get_secret_value( SecretId=secret_name ) if 'SecretString' not in get_secret_value_response: raise Exception("SecretString is not contined in get_secret_value_response") secret = ast.literal_eval(get_secret_value_response['SecretString']) # 略 今回はログインの確認をするためのtest用のアカウント情報をAWS Secrets Managerを使用して管理、取得を行っています。 機密情報の取り扱い方に関しては色々なやり方があると思いますが、扱いやすいのと同時にこのようなサービスが用意されていることでcredential情報をケースによってS3に置いたり他のサービスに置いたりと分散しないなど管理方法についてのコストも下げられるメリットがあるなと思いました。 driver.delete_all_cookies() driver.get('https://xxxxxxxx') # 管理画面URL driver.find_element_by_name('email').send_keys(secret['healthcheck_user_email']) driver.find_element_by_name('password').send_keys(secret['healthcheck_user_password']) driver.find_element_by_id('gtm-login-button').click() time.sleep(sleepSec) cur_url = driver.current_url # url遷移チェック if cur_url != 'https://xxxxxxxx': # ログイン後URL raise Exception("transition to dashboard failed") time.sleep(sleepSec) # 表示チェック appTitle = driver.find_element_by_class_name('CmsAppHeaderAppList-linkName') if appTitle.text != 'SRE検証アプリ': raise Exception("check app title failed") driver.quit() break except Exception as e: if num < retry - 1: continue raise e print("True") retry時にfreshな状態でSeleniumを実行したいため、delete_all_cookiesでcookie情報の初期化を行っています。また、ブラウザを介したE2Eテストなどを行うときの注意点でもありますが、遷移時にはsleepを入れて遷移を待つようにしています。 またこのapplicationは高頻度で実行されるためネットワークやその他の要因などで処理がエラーになって誤検知になってしまう可能性を考慮し、全体の処理を2回までretryするようにしています。 ecosystem 上記のコードをECSのScheduling Taskで10分おきに実行し、applicationから出力されたログをDatadogが収集し、それをもとにSlackに通知するようにしています。 Yappliでは多くのapplicationのログをDatadog、New Relicで収集しています(現在DatadogからNew Relicに移行中)。今回は簡易的なhealthcheckなこともあって初めはapplicationからslackへのnotifyを行うことを考えていましたが、その責務をDatadogで受けることでもう少し複雑なapplicationでは閾値の変更などの検知のルールをapplicationのdeploy無しに柔軟に対応することができますし、処理のfailだけでなく監視の仕組み自体が止まっていることなどの検知も可能になります。 まとめ SeleniumとECSとDatadogを使ったhealthcheck機構について紹介しました。 一度こういった仕組みを作れば移行は簡易的に実装、追加できますし、特に実際の処理を書くSeleniumは扱いやすく、真新しい技術ではありませんがその分ナレッジも多いのでこういった単純なhealthcheckでは確認できないケースのアラートも億劫になることなく開発、導入できるのが便利だと感じました!エコシステムの面からもサービスの品質向上を図っていきたいです。