TECH PLAY

フォルシア

フォルシア の技術ブログ

241

FORCIAアドベントカレンダー2020 20日目の記事です。 こんにちは、旅行プラットフォーム部の島本です。現在B2C向けの新サービス立ち上げを企てています。 新規事業立ち上げのプロセスの一つにプロトタイプ作成があります。 フォルシアには社内製のWEBアプリケーションフレームワーク(2019年に 新フレームワークを開発 しています)があるのですが、プロトタイプ作成のような信頼性よりスピード性重視の場合には、オーバースペック感があります。 一方で、世の中にはコマンドをいくつか実行するだけでWEBアプリを立ち上げられる便利なツールもあります。普段私が扱っているNode.jsベースのものだとこれらが挙げられます。 create-next-app create-react-app しかし、商用化を見据えると、今後すべて作り変えるであろうプロトタイプといえど、なるべく自社フレームワークに近い構成で開発したい気持ちもあります。 そこで下記の要素を取り入れたプロトタイプ用WEBアプリのベースをさくっと作ってみることにしました。 Next.js + Expressのカスタムサーバの構成 TypeScriptで開発 Backends For Frontends(BFF)構成っぽくする DB参照も有り(フォルシアではPostgreSQLを利用することが多いため pg-promise を利用) このような構成になります。 プロジェクトの作成 npx create-next-app [project-name] cd [project-name] ※ [project-name]をnext_prototypeとして作成する TypeScriptで開発するための設定 npm install -D typescript @types/react @types/react-dom @types/node mv pages/index.js pages/index.tsx mv pages/_app.js pages/_app.tsx pages/_app.tsxを編集しTypeScript化。 import { AppProps } from 'next/app' const MyApp = ({ Component, pageProps }: AppProps) => { return ; } export default MyApp 起動できることを確認。 npm run dev 起動後、下記のtsconfig.jsonが作成される。 { "compilerOptions": { "target": "es5", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, "skipLibCheck": true, "strict": false, "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve" }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx" ], "exclude": [ "node_modules" ] } Expressのカスタムサーバを導入 Next.jsはデフォルトではパスと一致するpagesディレクトリ配下の各ファイルにルーティングされます。 このルーティングに独自実装を組み込みたい場合にカスタムサーバを利用します。 例えば、特定のパスの場合のCookie操作やリダイレクト処理の実装などが挙げられます。 参考: https://nextjs-ja-translation-docs.vercel.app/docs/advanced-features/custom-server npm install express npm install -D @types/express mkdir server touch server/index.ts touch tsconfig.server.json server/index.tsを編集。 import express, { Request, Response } from "express"; import next from "next"; const dev = process.env.NODE_ENV !== "production"; const app = next({ dev }); const handle = app.getRequestHandler(); const port = process.env.PORT || 3000; app.prepare().then(() => { const server = express(); server.all("*", (req: Request, res: Response) => { return handle(req, res); }); server.listen(port, (err?: any) => { if (err) throw err; console.log( `> Ready on localhost:${port} - env ${process.env.NODE_ENV}` ); }); }); tsconfig.server.jsonを編集。 // for Next custom-server { "extends": "./tsconfig.json", "compilerOptions": { "module": "commonjs", // Next.jsとExpressの両方を連携させるために、commmonjsを利用 "outDir": "./dist", "noEmit": false, // Next.jsはBebelを使用してTypeScriptをコンパイルするので、TSコンパイラはjsを出力しない。設定を上書きする。 }, "include": ["./server"] } package.jsonのscriptを書き換える。 "scripts": { "dev": "tsc -p tsconfig.server.json && node ./dist/index.js", "build:next": "next build", "build:server": "tsc -p tsconfig.server.json", "start": "NODE_ENV=production node dist/index.js" }, 起動できることを確認。 npm run dev pg-promiseを用いてDataBaseを参照できるようにする PostgreSQLのインストールは割愛します。 npm install pg-promise @types/pg-promise touch modules/database.ts modules/database.tsを編集。 import pgPromise from "pg-promise"; const pgp = pgPromise({}); const config = { db: { // 設定項目: https://github.com/vitaly-t/pg-promise/wiki/Connection-Syntax host: "127.0.0.1", port: 5432, database: "mydb", user: "user", password: "password", max: 10, // size of the connection pool query_timeout: 60000 // 60sec } }; export const sqlExecuter = pgp(config.db); Next.jsのdevelopment modeでの起動中にソースを修正してrebuildが走るとpg-promiseの下記のWarningが発生します。 WARNING: Creating a duplicate database object for the same connection. production mode では問題ありませんが、気になる場合は Singleton pattern でコネクションを再利用するよう実装することで回避できます。 詳しくはこちら: https://github.com/vercel/next.js/discussions/18008 DBから取得したデータを返すAPIのエンドポイントを追加 Next.jsでは pages/api 配下のファイルが、 /api/* でアクセス可能なAPIエンドポイントとして扱われるため、ファイルを配置するだけでAPIエンドポイントを作成できます。 参考: https://nextjs.org/docs/api-routes/introduction touch pages/api/data.ts pages/api/data.ts を編集。 import { sqlExecuter } from "../../modules/database"; export default async (req: any, res: any) => { const data = await sqlExecuter.any( "select 'DB参照したデータ' as any_column" ); res.status(200).json({ data }); }; APIをfetchして取得したデータを画面に書き出す npm install axios touch modules/request.ts modules/request.tsを下記の通りに編集。 import axios from "axios"; const serverSideBaseURL = "http://localhost:3000/api"; const clientSideBaseURL = "http://localhost:3000/api"; const requestInstance = axios.create({ baseURL: serverSideBaseURL }); const clientRequestInstance = axios.create({ baseURL: clientSideBaseURL }); export const getRequestInstance = (isServerSide: boolean) => { if (isServerSide) { return requestInstance; } return clientRequestInstance; }; ※Next.jsのgetInitialPropsの処理はサーバ側・クライアント側のいずれでも実行されるため、APIのパスが変わる場合の考慮が必要です。 pages/index.tsxを編集。 import { NextPage } from 'next' import { getRequestInstance } from "../modules/request"; const Page: NextPage = ({ data }) => { return data.map( (d: any, index:number) => <div>{index}番目のデータ: {d.any_column}</div> ) } Page.getInitialProps = async (ctx: any) => { const request = getRequestInstance(Boolean(ctx.req)); const res = await request.get("data").then(res => res); return res.data; } export default Page ブラウザで http://localhost:3000 にアクセスし画面にDB参照して得られたデータが表示されることを確認。 おしまい。 終わりに いかがだったでしょうか?APIやDBのクライアントもインストールするだけですぐに使えて便利な世の中ですね。今回はAPIのレスポンスをそのまま書き出すまでで終わりましたが、 Material UI などのコンポーネントライブラリを使うことで画面の作成も簡単にできます。 私が開発中の新サービスはいずれ公開できればと思います。
アバター
FORCIAアドベントカレンダー2020 20日目の記事です。 こんにちは、旅行プラットフォーム部の島本です。現在B2C向けの新サービス立ち上げを企てています。 新規事業立ち上げのプロセスの一つにプロトタイプ作成があります。 フォルシアには社内製のWEBアプリケーションフレームワーク(2019年に新フレームワークを開発しています)があるのですが、プロトタイプ作成のような信頼性よりスピード性重視の場合には、オーバースペック感があります。 一方で、世の中にはコマンドをいくつか実行するだけでWEBアプリを立ち上げられる便利なツールもあります。普段私が扱っているNode.jsベースのものだとこれ
アバター
FORCIAアドベントカレンダー2020 19日目の記事です。 検索プラットフォーム事業部の小海です。技術部教育チームにも所属し、新入社員研修に関わっています。 今年の新入社員研修はコロナの影響を受け、すべての研修をオンラインで行うこととなりました。 今回は、オンライン研修を行うにあたって技術部教育チームが実践したことを紹介します。 新入社員研修 フォルシアの新入社員研修では通常、入社1ヵ月は基礎研修を実施し、社会人としての土台をしっかりと学びます。その後1ヵ月はジョブローテーションを行い、配属予定の部署以外の仕事を経験し、社内全体を理解していきます。 最初の2ヵ月の研修については こちらの記事 をご覧ください。 基礎研修・ジョブローテーションが終わった後、技術基礎研修としてHTML・CSS・JavaScript・SQLなどをWEB教材や先輩社員による技術講義で学んだのち、名簿アプリ研修で検索アプリの全体像を掴みます。その後、仮配属で実際の業務を先輩社員と一緒に1ヵ月ほど経験してから本配属となります。 技術部新入社員研修については こちらの記事 、名簿アプリ研修については こちらの記事 をご覧ください。 今年起こったこと 通常は新入社員研修を2ヵ月実施したのちに技術部新入社員研修となるため、技術部教育チームは6月からの研修に向けて準備を進めていました。しかし、コロナの影響で新入社員研修はすべてオンライン研修となりました。 基礎研修は外部研修なども多く、ジョブローテーションもオンライン研修の準備が整っていなかったため、6月から予定していた技術教育を急遽4月から行うこととなりました。 今年の新入社員のインタビュー記事もあります。新入社員目線からの声は こちらの記事 をご覧ください。 スケジュールが大きく変わり、試行錯誤の中で取り組んだオンライン研修でフォルシアの技術部教育チームが実践したことは以下の4点です。 1) 積極的にコミュニケーションを フォルシアは新卒採用時に人柄を重視しており、プログラミングの経験が浅くても採用する方針を採っています。 新入社員歓迎会や基礎研修等の実施が難しい中での研修となったため、新入社員のプログラミング経験や人柄を理解してから研修に臨むために下記を実践し、積極的にコミュニケーションを取るよう心掛けました。 新入社員研修前ヒアリング 研修初日に技術部教育チームと新入社員と1対1でのヒアリングを設定しました。研修に取り組む前に、プログラミング経験だけではなく社会人生活や在宅環境などの懸念を聞くことで、研修全体の満足度向上に繋げることができました。 コミュニケーションセッション 研修開始3週間は新入社員・技術部教育チーム・技術部2年目の先輩社員で話す場を用意しました。週に3回30分間、5名程度のグループに分け、毎回組み合わせを変えて設定しました。 小さな疑問から抱えている課題など、実際に研修を受けている新入社員の声を聴くことで、研修内容の再検討や追加講義など臨機応変に対応することができました。 KPTふりかえり会 基礎研修が終わり仮配属となると新入社員1人1人がそれぞれ違う業務に取り組みます。仮配属期間中は週に1回1時間を使って、技術部教育チーム2名と新入社員とでKPT法を用いた振り返り会を設定しました。 それぞれの学びや課題を共有することで、学びをより深め、他者の学びを吸収し、課題の解決策の道標とすることを目的としています。オンラインでは Google WorkspaceのJamboard を使用しました。 2) 余った時間の有効活用 新入社員はプログラミング経験に差があるため、技術課題では進捗に大きな差が生まれることがあります。 オンライン研修では進捗が見えにくいためフォローが難しく、新入社員が手持ち無沙汰になってしまうことがあります。課題が早めに終わってしまった新入社員のために、下記の内容を事前にまとめておくことで時間を有意義に使ってもらうことができました。 過去の全体会議資料 先輩社員の自己紹介 情報管理ツール「esa」 にある先輩社員が書いたおすすめ記事 業務に役立つ書籍 3) オンライン研修のメリットを活かす オンライン研修のメリットを活かすため、下記のことに取り組みました。 先輩社員による技術講義の聴講 フォルシアは独自技術も多いため、社内での技術講義も多くあります。オンラインでは会議室の定員に縛られずに行えるため、新入社員以外の聴講を可としました。今年は技術講義の見直しも行われ、新設された講義の中には20名近くが参加する講義もありました。 画面共有 講義や技術的な説明をする時には先輩社員の画面を共有をすることで、エディタやツールなど実際の作業環境を見る機会にもなりました。 チャットの有効活用 オンライン講義では、オンライン会議ツールのチャット機能を自由開放し、講師も都度見るようにすることで、感想や質問など発言しやすい雰囲気を醸成することができました。 オンライン輪読会 技術部教育チームが選定した書籍でオンライン輪読会を行いました。同期や先輩社員と一緒に読み、発表や質問等を行うことで、1人で読むよりも効率的に学べるだけではなく、発表や質問の練習にもなりました。 4) オンライン研修を楽しむ 技術部教育チームのメンバーも在宅研修に慣れていない中で研修に臨みましたが、『新入社員にオンライン研修を楽しんでもらう』ことを最優先に考え、そのために『技術部教育チームもオンライン研修を楽しむ』ことを心掛けました。 私が特に印象に残っているのは3月末に行われた技術部教育チームのミーティングです。 技術部教育チームとしては2ヵ月後に予定していた研修を急遽来週から行う事になり、オンライン用に研修内容を再検討しなければならない状況でした。しかし、そのミーティングでは「困ったね」「どうしよう」ではなく、その状況を前向きに捉え「オンラインだからこそできる研修」のブレストを行いました。 この前向きに楽しむ姿勢は研修を通じて新入社員にも伝わっていたと考えています。何事にも前向きな技術部教育チームのメンバーにはとても感謝しています。 さいごに オンライン研修にあたって技術部教育チームが心掛けたことを紹介してきましたが、成功した要因としては、若手社員が積極的に協力してくれたこと、そして何より、新入社員が楽しく前向きに取り組んでくれたことが大きいと実感しています。 次年度がどのような状況での研修になるかはまだわかりませんが、今年度実践したことを活かして、さらにパワーアップした技術部新入社員研修にしたいと思います。
アバター
FORCIAアドベントカレンダー2020 18日目の記事です。 検索プラットフォーム部 エンジニアの石川です。 2019年にキャリア入社し、それ以前はカーナビゲーションのアルゴリズム開発や自動運転技術の開発に従事していました。 ここ数年、AI(人工知能)を搭載した製品やサービスがとても増えており、エンジニアでなくともAIに興味を持つ方が増えているように感じています。 また、AIエンジニアに対する需要も増しており、AIエンジニアを目指す方も増えているようです。 今日はAIがどういうものかをあまり知らない方や、AIエンジニアを目指そうとしている方へ向けた記事を書きたいと思います。とはいっても自分もまだまだ勉強中なので、あくまで私個人の解釈と意見になります。 近年のAIというと機械学習を指すことが多いのですが、これは「コンピュータが一定のルールに基づいて大量のデータを分析して、特徴やパターンを学習する」というもので、代表的なものではスパムメールの判定やECサイトでの商品のレコメンドなどに使われています。 特にこの「一定のルールに基づいて」の部分に人間の脳の構造を模した仕組みを応用したものは深層学習(ディープラーニング)と呼ばれ、画像認識や音声認識、自然言語処理などに使われており、この数年で飛躍的に精度が向上して、人間に近い判断ができるようになってきたように思います。 その一方で、学習した結果コンピュータが内部でどのように判断したか、ということが非常にわかりにくくなってしまったという側面もあります。 ちなみにそれ以前のAIはどういったものかというと、人間が大量のデータを分析して特徴やパターンを見出してそのパターン通りに動くようにプログラムしていました。これはこれで開発者が意図した通りに動くため管理がしやすく、きめ細やかな設計をしていればむしろ高性能な面もあり、日本製品のクオリティの高さの一因でもあるので、決して古いから使えない、というものではないのです。特に乗り物の制御などの安全に関わる分野においては、動作する状況を限定的にして間違いのない判断をさせることの方が重視されるように思います。 ディープラーニングとは ここ数年AIが注目を集めている最大の理由の一つは、上記の深層学習(ディープラーニング)の発達にあります。 先ほど説明したとおり「人間の脳の構造をコンピュータで疑似的に作り出して学習させる」ということなのですが、これだけではよくわからないと思うので、ここでは具体的に、子供に勉強させる、という例で解説してみようと思います。 さて、親が子供(幼稚園児か小学校低学年くらいの子としましょう)に色々な動物を見分けられるように教えようとする場面を想像してみてください(動物のことは知らないけど、計算はめちゃめちゃ早くて記憶力も抜群な子供です)。 親は子供のために子供部屋や学習机などの学習環境を用意して、カリキュラム(どんな教材をどのくらい、どんなふうに勉強するか)を考えて、教材として図鑑を用意します。このあとどのように勉強させるでしょうか? ひとりで図鑑が読める子なので逐一教えなくても自分で学んでいきます。親は定期的に「これ、なーんだ」と問題を出してテストしてあげるだけです(教えるのではなく、自分で学習していくやり方は公文式に似ていますね)。 こうしてめでたく子供は動物を見分けられるようになり、正面だけでなく後ろ姿だったり体の一部しか見えなくても正解するレベルになりましたが、「なんでそう判断したの?」と尋ねても、「そう思ったからだよ」としか答えてくれず、うまく言葉で表現はできません。なので、これ以上カリキュラムやテキストをどう直したらもっとできるようになるのか親にはさっぱりわかりませんし、その子に合わせてカスタマイズしていかないとこれ以上のレベルに到達するのは無理そうです。 また、最初は楽しんで学習していた子供もあんまり「お勉強」をさせすぎると、テストでいい点とれればいいや、という考えになり丸暗記に走ってしまって応用が効かなくなることもあります(機械学習ではこれは過学習と呼ばれます)。 逐一教えなくていいのは良いのですが、この無限の可能性を秘めた子供に何をどうどこまで学習させたら良いのかは親にもわかりません。ちなみに私も一児の親ですが、ウチの子にどんな才能があるのか誰か教えて欲しいです! AI開発とは さて、今度は上記の話を専門用語を交えつつAI開発について説明してみます(必ずしも1対1で専門用語と結びつくわけではないので正確性を欠いているところもありますが、イメージで捉えていただければと思います)。 まず親(AIエンジニア)は子供(モデル)に何ができるようになって欲しいのかを考えます。 次に学習環境(フレームワーク)や筆記用具(ライブラリ)を用意し、その子どもの脳の特性(ニューラルネットワーク)に応じたカリキュラム(ハイパーパラメータ)を設定し、テキスト(学習データ)を用意し勉強させ、定期的にテスト(検証データ)を実施することを何度も繰り返すことで学習を進めます。 これを開発フローにすると、まずAIで解決したい課題をきちんと設定することから始まります。 次にその課題を解決するために必要な環境や道具を選定し、手に入れます。 幸い、AI開発に必要なフレームワークやライブラリの多くは無料で提供されています。 ただし、多くの種類がありますし、モデルや目的に合わせたものを選ぶ必要があります。 そして課題解決のための要となる学習・検証用のデータが必要になりますが、実際にこれを入手、作成するのが非常に大変です。 研究用に使われている学習データはたくさんありますが、あくまで研究用なので商用利用ができなかったり、それで学習したモデルに現実の問題を解かせても十分な精度がでないこともあります。 それだけでなく、自分で作成したデータはあらかじめ手作業で分類したり、学習に適した形に加工したりする必要があるので非常に手間がかかる作業です。 こうして必要なものが一通り揃ったら、実際に学習させ精度を確認します。 一度で十分な精度が出ることはほぼないので、何度何度もモデルを見直したりパラメータのチューニングをして学習を繰り返して精度を向上させていきます。 簡単にですが機械学習を用いない場合のアルゴリズム開発フローと機械学習によるモデル開発フローを図にしてみました。 大きな流れは変わらないのですが、機械学習ではデータの加工(学習データの作成)とモデルの学習が必要となることが特徴です(図のピンク枠の部分)。 もちろん、機械学習でない場合でもデータの加工をすることはありますが、多少データが粗かったり不足したりしていてもエンジニアのKKD(経験、勘、度胸)でロジックを直接調整できるのに対して、機械学習では学習データを介してモデルを学習させるためデータの質がモデルの精度に直結するというところが大きな違いになります(学習データの作成、加工の部分ではエンジニアのKKDを発揮することは大いにあるようです)。 エンジニアの在り方 さて、冒頭にお話したようにAIエンジニアへの需要は高まっているらしいのですが、AIエンジニアには何が求められているのでしょうか。 AIエンジニアになるためには、と調べると概ね以下の3つを学びましょうと出てきます。 プログラミング言語(Pythonなど) 数学(統計学、微分積分、行列など) 機械学習(フレームワークやライブラリ、ニューラルネットワークなど) たしかに技術スキルとしてはこれらになるのですが、個人的には AIで解決できる課題を発見すること AIが解決できるレベルに課題を単純化すること が重要だと思っており、これらはAIでは代替できないものです。 現在AIはあらゆる業界で使える道具になってきているので、趣味でも、他職種でも良いので様々な経験やバックグラウンドを併せ持つことで色々な視点から課題を解決していけるエンジニアが求められるのではないかと思います。 あくまで私見となりますが、製品やサービスの開発においてはAIについて論理的に精通していることよりも技術をうまく使いこなせることの方が大事だと思っています。 ディープラーニングが流行し始めた頃であれば、そのロジックに精通していることは重要だったかもしれませんが、今はフレームワークやライブラリもかなり使いやすくなっていますし、AmazonやGoogleのクラウド上でモデルの学習やAIによる判断も提供されていますので技術としてAIを使う敷居はかなり低くなってきたのではないかと思います。 どのような技術も、導入時と発展時ではエンジニアに求められるスキルは異なるものです。 どうしてもAIというワードが独り歩きしてしまいがちですが、AIはあくまでビジネス上の課題を解決するためのツールであるという意識で捉えてもらうと良いと思います。 最後に AI開発について、エンジニアではない方にも理解していただけるように書いてみましたが、いかがでしたでしょうか。 今後もAIの活用は続いていくと思いますがその開発の背景には、子を想う親の気持ちが詰まっている(!?)と理解していただければ幸いです。
アバター
FORCIAアドベントカレンダー2020 17日目の記事です。 新卒2年目エンジニアの平岡です。 2年前の今頃は有機合成化学の研究室で、試薬を混ぜてひたすら実験をしていました。 この記事ではTwitter APIを題材に、普段業務で触れる機会の少ないインフラ周りや複数の言語を触るなどして遊んだ話を書きます。 Twitter API 何かの情報を集める際、google検索だけでなくTwitter検索を使うこともあり、タイムリーな情報や口コミ等の情報はTwitter検索が適していると思います。 TwitterではAPIを提供 しており、タイムラインの取得・投稿や検索結果の取得ができるようです。 search APIを使ったキーワード検索を題材に、アプリの実装からAWSでの公開まで広く浅く触れてみました。 使用申請 TwitterAPIを利用するための各種トークンを発行してもらうために申請が必要です。 こちら から申請できます(要ログイン)。使用目的などを英作文し、申請から約3時間後には使用可能な状態になりました。 認証 Twitter公式のドキュメント にOAuth 1.0とOAuth 2.0、Basic認証について載っています。OAuth 1.0では個人アカウントでログインしたときと同じ機能が使えるのに対し、OAuth 2.0では公開されている情報までしか扱えないようです。 今回触ってみたsearch APIでは特にどれを用いても問題ないですが、例えば一般に公開されていない鍵アカウントのタイムラインの取得はOAuth 2.0の認証ではできないと思われます。 OAuthとは何ぞや、という方は、以下の記事が参考になります。 一番分かりやすい OAuth の説明 大まかな概念はこの記事でつかめます。 OAuth 2.0 の仕組みと認証方法 OAuth 1.0とOAuth 2.0の違いと仕組みについて書かれています。 やりたいこと TwitterAPIをたたいて結果をブラウザで見られるようにしたい ローカルからだけでなく外からでも見れるようにしたい 普段触っていない言語を触ってみたい ということを漠然と思いながら、以下のステップで進めていきました。 コマンドライン上で結果を取得する(Python) 取得した結果をブラウザに表示する 公開する(AWS) Rustでも実装してみる コマンドライン上で結果を取得する(Python) まずは、日本語の記事が多くて書きやすいPythonで書くことにしました。 環境 Ubuntu20.04(WSL2) Python3.8.5 実装 参考記事 の通りの実装でコマンドライン上での動作を確認できました(自分が書いたコードは後述します)。 今回は無料で使うことのできるStandard searchを触っています。こちらでは7日間のツイートを検索することができます。 search APIは複数種類用意され ており、30日間のツイートを検索できるものや2006年以降のツイートを検索できるものもあるようです。 Standard searchの使い方や用いることができるパラメータについては こちら に載っています。 また、各種トークンについては 開発者用管理画面 から、自分のプロジェクトのKeys and tokensタブにて確認することができます。申請時に発行されたトークンを忘れてしまった場合も、ここで再生成することができます。 取得した結果をブラウザに表示する(CGI) 参考記事:PythonでCGIを用いたWebアプリケーションを作る CGIサーバーを起動する import http.server http.server.test(HandlerClass=http.server.CGIHTTPRequestHandler) このファイルを実行すればCGIサーバーが起動します。 $ python cgiserver.py Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ... 同階層にindex.htmlを配置して http://localhost:8000 にアクセスすると、そのhtmlが表示されます。 index.html formにキーワードを入力し、Twitter APIを叩くPythonファイルを実行させます。 出力先を target="result" で指定してiframe内に検索結果を表示させることで、検索時に画面遷移をさせないようにしました。 <!DOCTYPE html> <html> <head> <title>CGI Sample</title> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> </head> <body> <form action="./cgi-bin/search.py" method="POST" target="result"> <input type="text" name="text" value="test" /> <input type="submit" name="submit" /> </form> <iframe name="result" style="top: 200px;height:500px;width:100%;border:0px;margin:100px"></iframe> </body> </html> search.py 各種キー・トークンはconfigファイルに記載しました。 CONSUMER_KEY = "-----------------" CONSUMER_SECRET = "-----------------" ACCESS_TOKEN = "-----------------" ACCESS_SECRET = "-----------------" #!/usr/bin/env python3 # -*- coding: utf-8 -*- import config import urllib from requests_oauthlib import OAuth1 import requests import cgi def main(): # configの値を使う CK = config.CONSUMER_KEY CKS = config.CONSUMER_SECRET AT = config.ACCESS_TOKEN ATS = config.ACCESS_SECRET # フォームからキーワードを受け取る word = cgi.FieldStorage().getvalue('text', '') count = 10 # 一回あたりの検索数(最大100/デフォルトは15) range = 10 # 検索回数の上限値(最大180/15分でリセット) # iframeに埋め込むHTML html_body = """ <!DOCTYPE html> <html> <head> <title>検索結果</title> <style> h1 { font-size: 3em; } </style> </head> <body> <h1>TwitterAPI Result</h1> <div>%s</div> </body> </html> """ if not word: print("キーワードを指定してください") else: tweets = search_tweets(CK, CKS, AT, ATS, word, count, range) print(html_body % (''.join(tweets))) def search_tweets(CK, CKS, AT, ATS, word, count, range): # 文字列設定 word += ' exclude:retweets' # RTは除く word = urllib.parse.quote_plus(word) # リクエスト url = "https://api.twitter.com/1.1/search/tweets.json?lang=ja&q=" + \ word+"&count="+str(count) auth = OAuth1(CK, CKS, AT, ATS) response = requests.get(url, auth=auth) data = response.json()['statuses'] # 2回目以降のリクエスト cnt = 0 tweetsCount = 0 tweets = [] while True: if len(data) == 0: break cnt += 1 if cnt > range: break for tweet in data: tweetsCount += 1 user = tweet["user"] tweets.append(str(tweetsCount) + "件目 ") tweets.append("name:" + user["name"] + "\n" + " ") # tweets.append(user["statuses_count"]) # 投稿数 # tweets.append(user["friends_count"]) # フォロー数 # tweets.append(user["followers_count"]) # フォロワー数 tweets.append("投稿日時:" + tweet["created_at"] + "\n" + " ") tweets.append( "いいね数:" + str(tweet["favorite_count"]) + "\n" + " ") tweets.append( "リツイート数:" + str(tweet["retweet_count"]) + "\n" + " ") tweets.append(tweet['text'] + "\n" + " ") maxid = int(tweet["id"]) - 1 url = "https://api.twitter.com/1.1/search/tweets.json?lang=ja&q=" + \ word+"&count="+str(count)+"&max_id="+str(maxid) response = requests.get(url, auth=auth) try: data = response.json()['statuses'] except KeyError: # リクエスト回数が上限に達した場合のデータのエラー処理 print('上限まで検索しました') break return tweets if __name__ == '__main__': main() search.pyには実行権限を付けておきます。 $ chmod 755 search.py 検索する 「http://localhost:8000」 にアクセスし、フォームにテキストを入力して送信するとTwitterでの検索結果が表示されました。 公開する(AWS) フォルシア入社後の研修でAWSを触る機会があり、EC2を立てるのはとても簡単だったので AWSのEC2 での公開を試みました。 アカウント作成 公式のフロー に従ってプロフィールを入力します。お支払方法(クレジットカードなど)の登録が必要なのですが、無料枠で遊ぶこともできます。 EC2を立ててsshする サインイン後EC2で検索し、 ダッシュボード に移ります。 ダッシュボード内やインスタンスタブから 「インスタンスを起動」をクリック します。 今回は無料枠で使うことのできるAmazon Linux 2 AMI (HVM)を使います。 「インスタンスタイプの選択」では、無料枠で使えるものがt2.microのみです。 「セキュリティグループの設定」に移り、必要なポートを設定します。 「確認と作成」→「起動」をするとキーペアについて確認されます。 初回はキーを作成してダウンロードします。 .ssh以下など適切な場所に配置し、 chmod 600 で権限を変更します。 起動しているインスタンス一覧が見れるページ にて、インスタンスの状態が実行中になればssh接続できます。「パブリック IPv4 DNS」(ec2-xx-xx-xx-xx.us-east-2.compute.amazonaws.com)を確認します。 ssh -i ~/.ssh/${key} ec2-user@${パブリック IPv4 DNS} あとは好きに遊ぶことができます。 Docker インスタンスを終了すると環境やソースが消えますが、インスタンスを起動する都度環境を作り直すのはとても手間がかかります。 そこで、Dockerfileを作りコマンド1回の実行で環境構築を済ませるモチベーションが生まれました。Python3.8.5とpipを入れて、必要なライブラリをpip installすれば環境は完成です。 FROM python:3.8.5 # ユーザ作成 RUN groupadd web RUN useradd -d /home/python -m python WORKDIR /home/python # pip RUN wget https://bootstrap.pypa.io/get-pip.py | python RUN apt-get update && apt-get install -y urllib3 requests_oauthlib requests # サーバ設置ファイル ADD cgiserver.py /home/python ADD index.html /home/python # cgi-binフォルダを作成 RUN mkdir cgi-bin ADD search.py /home/python/cgi-bin ADD config.py /home/python/cgi-bin RUN chmod 755 /home/python/cgi-bin/search.py # ポート番号を指定して、CGIサーバを起動 EXPOSE 8000 ENTRYPOINT ["/usr/local/bin/python", "/home/python/cgiserver.py"] USER python AWS上でアプリ起動 必要なファイルが少ないので手動でscpしました。 $ tree . ├── aws_init.sh ├── cgi-bin │ ├── config.py │ └── search.py ├── cgiserver.py ├── docker │ └── python_cgi │ └── Dockerfile ├── index.html 権限に問題のないホームに置きます。 scp -i ${key} index.html cgiserver.py docker/python_cgi/Dockerfile cgi-bin/search.py cgi-bin/config.py aws_init.sh ec2-user@ecxx-xx-xx-xx-xx.us-east-2.compute.amazonaws.com:~ インスタンスにssh後、Dockerを入れてbuild, runすれば環境構築・CGIサーバーの起動が完了です。以下のシェルスクリプトを用意してscpし、実行しました。 sudo yum update sudo yum install -y docker sudo service docker start sudo usermod -a -G docker ec2-user mkdir cgi-bin mv search.py cgi-bin mv config.py cgi-bin sudo docker build -t python_cgi . sudo docker run -d -p 8000:8000 python_cgi ssh -i ${key} ec2-user@ecxx-xx-xx-xx-xx.us-east-2.compute.amazonaws.com:~ [ec2-user@ip-xx-xx-xx-xx-xx ~]$ chmod +x aws_init.sh [ec2-user@ip-xx-xx-xx-xx-xx ~]$ ./aws_init.sh 「http://ec2-xx-xx-xx-xx.us-east-2.compute.amazonaws.com:8000」 にアクセスすればローカルと同じCGIアプリを見ることができます。 このURLで外部からも参照することができます。 注意 データの保存 EC2単体だとインスタンスを終了させると、インスタンス内で行った変更は破棄されます。データの保存には EBS を併用すると良いです。 IP IPを固定しないとインスタンス再起動時にはパブリック IPが変わります(現状ではIPを固定する必要が特に無いためそのままにしています)。 課金額 インスタンスを立てたままにするなどして、無料枠を超えた利用が発生すると登録しているクレジットカードから課金が発生します。 最初の12か月間は月750時間までは無料枠で使うことができるらしいです。今回の設定でEC2インスタンスを1台だけ起動する分には立てたままでも課金は発生しない(1台だと最大で月に24×31=744時間)と思われますが、何に課金が発生するかはしっかり確認した上で使いましょう。 Rustでの実装 下準備 rustlings を一通り解いて、ある程度Rustのソースが読めるようになりました。最初は、 非同期処理runtime: tokio サーバー: warp HTTP request: reqwest として、Twitter APIを叩いた結果のjsonを返すものを作ろうとしました。 warpで受け取ったパラメータを使ってreqwestでTwitter APIを叩く、というような実装を書いたのですが、warpでjsonを返す処理の中でTwitter APIを叩く処理が上手く書けなかったため、別のcrateを検討しました。RustのWebフレームワークで主要なものに、 Rocket と Actix Web がありますが、Rocketは非同期処理に対応していないためActix Webを使うことにしました。 各crateにはexampleが用意されており、実装の際に参考になりました。 https://github.com/seanmonstar/warp/tree/master/examples https://github.com/seanmonstar/reqwest/tree/master/examples https://github.com/actix/actix-web/tree/master/examples 環境 rustc 1.48.0 cargo 1.48.0 [package] authors = ["hiraoka"] edition = "2018" name = "twitter" version = "0.1.0" [dependencies] actix-web = "3.3.2" actix-rt = "1.1.1" reqwest = { version = "0.10.9", features = ["json"] } serde = { version = "1.0.117", features = ["derive"] } serde_json = "1.0.59" dotenv = "0.15.0" qstring = "0.7.2" コード bearer_tokenを用いるOAuth2.0認証を使いました。.envファイルにbearer_tokenを書きました。 bearer_token = AAAAAAAAAAAAAAAAAAAAA-------- use actix_web::{middleware, web, App, HttpRequest, HttpResponse, HttpServer}; use dotenv::dotenv; use qstring::QString; use reqwest::header::{HeaderMap, AUTHORIZATION}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::env; #[derive(Debug, Serialize, Deserialize)] struct SearchResult { search_metadata: Value, statuses: Vec , } #[derive(Deserialize, Serialize)] struct QueryObject { q: String, count: u32, } struct Twitter {} impl Twitter { pub fn new() -> Self { Twitter {} } pub async fn search( &self, _req: HttpRequest, ) -> Result<SearchResult, Box > { let endpoint = "https://api.twitter.com/1.1/search/tweets.json"; let mut headers = HeaderMap::new(); // .envファイルのトークンの値を読み込む dotenv().ok(); let bearer_token = env::var("bearer_token").expect("bearer_token is not found"); headers.insert( AUTHORIZATION, format!("Bearer {}", bearer_token).parse().unwrap(), ); let query_str = _req.query_string(); let qs = QString::from(query_str); let q = qs.get("q").unwrap(); let count = qs.get("count").unwrap(); let client = reqwest::Client::new() .get(endpoint) .query(&[("q", q), ("count", count)]) .headers(headers); let res: SearchResult = client.send().await?.json().await?; Ok(res) } } async fn twitter_search(req: HttpRequest) -> HttpResponse { let result = Twitter::new().search(req).await; match result { Ok(res) => HttpResponse::Ok().json(res), Err(err) => HttpResponse::InternalServerError().body(err.to_string()), } } #[actix_rt::main] async fn main() -> std::io::Result<()> { HttpServer::new(|| { App::new() .wrap(middleware::Compress::default()) .service(web::resource("/twitter_search").route(web::get().to(twitter_search))) }) .bind("0.0.0.0:8000") .expect("Can not bind to port 8000") .run() .await } cargo run して 「http://localhost:8000/twitter_search?q=keyword&count=10」 など、検索ワード(q)とカウント(count)をパラメータを指定するとTwitter検索結果のjsonが返ってきます。 おわりに 初めからRustでの実装を試みていたのですが、Rustのエラー処理や型の理解が浅く、コンパイルに苦戦したため、Pythonで動くものを作ってからRustで再実装しました。それぞれの領域でベストプラクティスを試せたわけではありませんが、広く触ってみるのは楽しかったです。 Pythonは調べると日本語の記事が沢山出て来る上、普段触っていないにしても動くものを作るまでのコストはRustと比較して低かったです。 一方で、Rustはこれまで自分が触れたことの無い概念が多くあり、学習コストが高かったです。また、 Rustの安定版がasync/await構文をサポートしたのは2019年11月 と日が浅く、非同期処理・同期処理のサンプルが混在して見つかり混乱することもありました。困ったらgithubのソースやcrateのドキュメントを見るのが一番ですね。もっとRustのコンパイラーと仲良くなりたいです。
アバター
FORCIAアドベントカレンダー2020 16日目の記事です。 旅行プラットフォーム事業部の山門です。 Versel社が現地時間の10/27に開催したNext.js CONFでは、開催当日にver10の発表もあり、なかなかに盛り上がりを見せたのが記憶に新しいですね。 CONFでは画像周りのアップデートが大きく取り上げらていた印象ですが、自分の中では同タイミングで発表されたNext.js Analyticsの方に興味が湧いたので、これを機にNext.js Analyticsについて調べてみました! Next.js Analytics is 何者? 本家のサイト をざっと見てみると、 実際のユーザーが操作したデータをもとに、サイトの本当のパフォーマンスを提供することができると紹介されています。 こういったパフォーマンスはリクエストをシミュレーションして計測することが多いですが、 Next.js Analyticsは実際にユーザーがページを見たときのデバイスをもとに情報を収集しているようで、 この実際の生きた情報、というのが推しポイントのようです。 始め方 公式の Analyticsページ を参考に詳細を見ていきましょう。 ※前提として、Next.jsのバージョンが10以降である必要があり、9以前のバージョンの場合はアップグレードが必要となります。 始め方は大きく分けて二つあり、Next.jsを開発しているVercel社が提供する、Vercelというサービス上で動いているアプリか、そうでないアプリかで分かれます。 Vercel上で動いているアプリの場合、Vercel上で適当なアプリを立ち上げ、Analyticsを有効にすると使えてしまうため、 それとは少し違うパターンで動くかどうかを試してみました。 フォルシアの一部サービスでは、AWS上にNext.jsを使ってアプリ構築をしており、全く同じではないのですが、 その構成を意識して、AWS Amplify上にNext.jsで構築したアプリに対して、Next.js Analytics導入を試してみました。 ということで、早速やっていきましょう。 公式の Self-Hosted の項目をみると以下の手順を踏むことで始めることができます。 templateからVercelのnew Projectを作成 作成後、プロジェクトのDomainsタブから、残っているドメインを削除 AnalyticsタブからNext.js Analyticsを有効化 有効化して表示されるconfig設定を、アプリの適切な箇所に配置 データが一定量貯まるまで待っていると、測定結果が見られるようになる キャプチャも交えて順を追ってみましょう。 ※注意書きで、ProプランまたはEnterpriseプランの場合のみ、Vercel以外でホストされているアプリケーションで使用できると書いているのですが、1日分であればお試しでも見られるようでした。 下準備 1. templateからVercelのnew Projectを作成 プロジェクトのtemplate作成画面 からNext.jsを選択します。 選択すると、下記のようにGitHub,GitLab,Bitbucketのいずれかでログインするように求められるので、 今回は社内の利用ツールに合わせてGitLabを選択します。 初回ログイン時だと、下図のようなアクセス権限を求められますが、Authorizeします。 その後、アカウント選択、プロジェクト名、インポートプロジェクト名を設定し、Deployを押します。 すると、自動でDeployが始まります(便利)。 そして、Deployが無事終わると、templateから作られたサンプルプロジェクトがすでにVercel環境上で立ち上がっています。 ここまでは、Vercel上でNext.js Analyticsを利用する場合と同じようですね。 ここで、Analyticsを有効化して計測してみる、ということもできるのですが、もう一手間かけていきましょう。 2. 作成後、プロジェクトのDomainsタブから、残っているドメインを削除 Settingタブに移動し、Domainsを見つけます。 そして、少々心苦しいですが、生まれたての <umaretate>.vercel.app のEditからのDomain削除を敢行しましょう。 消すよ?ええんか?と聞かれますが強い気持ちでREMOVEします。 3. AnalyticsタブからNext.js Analyticsを有効化 お次はAnalyticsタブに移動し、AnalyticsをEnable状態にし、next.config.jsに書くためのanalyticsIdを取得します。 このIDはあとでまた使います。 ここまででざっくり準備は完了です。 肝心のAWS Amplify上にNext.jsアプリ構築をしていないので、そちらを行ってから4,5についてまた見ていこうと思います。 Amplifyを使ってAWS上にNext.js環境を立ち上げる 今回Next.jsを立ち上げる環境としてAWS Amplifyを使っています。 Amplifyとは 公式 によると AWS Amplify は、モバイルとウェブのフロントエンドデベロッパーが、安全でスケーラブルなフルスタックアプリケーションを構築しデプロイできるようにする、AWS による製品およびツールのセットです。Amplify を使用すれば、アプリケーションのバックエンドを数分で設定し、わずか数行のコードでそれをアプリケーションに接続できます。そして、3 ステップで静的なウェブアプリケーションをデプロイできます。AWS Amplify で迅速な市場投入を実現しましょう。 とのことで、「バックエンド構築からフロントエンド開発までこれひとつでできちゃう便利ツールセット」のことのようです。 今回のような、ちょっと動く環境を用意して試したいことがある時に最適ですね! 構築はAmplify公式が提供している Tutorial に沿って行いました。 ※構築にはAWSアカウントが必要なため、持っていない方は作っていただく必要があります。 基本的にTutorialがしっかりしているため、説明はそちらに任せますが、構築時に引っかかった点についてはこの章の最後に備忘録的に残しておきます。 Tutorialを無事に完了し、Amplifyでdeployまで行くと下図のようなメモアプリが立ち上がるので(画面はすでにCreate Postを実行しているので、若干異なるとは思います)、ここまでできたらAnalyticsの設定に再び戻りましょう。 参考までに、Tutorialで構築した際のサービス構成図としては下図のようになっています。 (参照元と同じAmplify DocsのGetting startedに従って構築したため、構成図はお借りしています。) 参照元: Developers.IOより抜粋 備忘録: Tutorial構築時のエラー対処 Testing SSG 項目でファイルを書き換えた後 npm run dev をした際に、 GraphQLError: FormData is not Defined というエラーが発生しアプリが立ち上がりませんでした。 調べてみると AWSのフォーラム でも質問が上がっており、amplify packageをupdateする事で解消するとありましたが、 それに従い、amplify package で最新にupdateを行うと、今度は以下のエラーが発生します。 Error: Amplify has not been configured correctly. このエラーは aws-amplifyのissue で近い指摘が上がっており、 issue記載の通りにやれば上記エラーは解消できるのですが、お次はGraphQLのエラーが出ます(もぐらたたきゲーム感がありますね)。 Error: No credentials at GraphQLAPIClass 結局初めのエラーはaws-amplifyのバージョンが3.3.9で発生しており、この時点で最新の3.3.11にしたところGraphQLのエラーが出たということで、その間の3.3.10にする事で解決しました。 何か起きたら、packageのバージョン周りは疑ってみるといいかもしれません。 アプリ構築後の残作業 4. 有効化して表示されるconfig設定を、アプリの適切な箇所に配置 アプリが構築できたら、そのプロジェクトに next.config.js ファイルを用意し、Analyticsタブで表示されていたconfigをcopyして貼り付けます。 module.exports = { analyticsId: } 無事用意できたら、 npx serverless コマンドで再deployをしましょう。 5. データが一定量貯まるまで待っていると、測定結果が見られるようになる deployが完了したら、ほぼ完了なのですが、最後に一定のアクセス数を貯める必要があります(自分は立ち上がった環境に対して、家のPCとスマホで数十回程度アクセスしていました)。ぽちぽちアクセスして、一定時間おきに(Vercelは30分程度と書いていましたが、自分は1,2時間程度でした)Analyticsページを見に行くと、無事スコアが表示されました!!! スコアの遷移はデバイスごと(PC,タブレット,モバイル)に横軸時間軸で、全体・page名単位・URL単位で確認できます。 (ただし無料版でトラックできる期間は1日のため、継続してモニタリングしたい場合はProもしくはEnterprise版への加入が必要です。) ちなみに、Analyticsでは以下4項目を計測しています。 First Contentful Paint( FCP ) ページ読み込みが開始されてから、ページのコンテンツの一部が画面にレンダリングされるまでにかかった時間 テキストやイメージ(背景画像含む)、 <svg> 要素、白以外の <canvas> 要素などが該当 1秒以内に描画され始めるのが望ましい 測定に適した閾値は75パーセンタイル Largest Contentful Paint( LCP ) ビューポート内で最大サイズの画像やテキストボックスが表示されるまでにかかる時間 <img> 要素、 <image> 要素内の <svg> 要素、 <video> 要素、url()関数を介して読み込まれた背景画像を持つ要素、テキスト要素などを含むブロックレベルの要素が該当 2.5秒以内に表示されるのが望ましい 測定に適した閾値は75パーセンタイル 描画されるにつれて最大サイズのコンテンツは変わり、更新される 最終的に、ビューポート内で最大サイズのコンテンツが描画されるまでにかかった時間 ユーザーが何かしら操作した場合は、それ以降に最大サイズのコンテンツが登場しても更新しない Comulative Layout Shift( CLS ) ユーザーが予期しないレイアウトシフトが発生していないかどうかを数値化した値 ざっくり(レイアウトシフトが発生したフレームがビューポート内に占める割合)×(ビューポート内で動いた割合)をスコア化したもの スコアが0.1未満が望ましい 測定に適した閾値は75パーセンタイル First Input Delay( FID ) ユーザーが最初にページを操作してから(リンク押下やボタンのタップなど)ブラウザが実際に処理を開始し始めるまでの時間 初めに限定しているのは、サイトの品質と信頼性に関する、全体の印象に大きく関わるのが初めの応答のため 100ミリ秒未満が望ましい 測定に適した閾値は75パーセンタイル 上記のうち、LCP,FID,CLSの3項目はCore Web Vitalsとして、Googleによって導入されたUXの指標の中でも重要とされている項目で、SEO的にも重要とされています。 こうしたwebページで重要とされる項目が、リアルに即したデータで計測することができるのは魅力的ですね! 終わりに 最後まで読んでいただきありがとうございました! 今回新登場したNext.js Analyticsを、サービスで使っているのに近い環境で動かすことができるのかを試してみましたが、 無事に計測するところまで行くことができました! 使ってみた感想としては、 導入が手軽 シミュレーションではなく、実際に訪れたユーザーの情報をもとにスコアを計測して、リアルな状況を明らかにできる といったメリットがある一方で、 実際にサイトが重い場合に、どのコンテンツがボトルネックで、どう改善していくと良いか、といったフローが見えにくい ProまたはEnterpriseのプランでコストをかけてモニタリングしていくとなった時に、もう少し詳細な情報が欲しい といったこれからの改善を期待したい点もいくつか見受けられました。 とはいえ、まずは入れてみて実際自分たちのサイトはどれくらいのスコアで早いのか、遅いのか、といったことを知るツールとしては、 導入もconfig追加してdeployするだけでお手軽なので、試してみる価値はあると思います。 何かを改善したいとなった時には、まずは計測することが重要です。 今回触ってみたNext.js Analyticsもその改善の一助になると期待して、 私自身もより良いアプリを作るために、改善を続けていきたいと思います。
アバター
FORCIAアドベントカレンダー2020 15日目の記事です。 フォルシアはAtCoderJobsにてエンジニアを絶賛募集中! 新卒採用はこちら 中途採用はこちら この記事はなんでしょう? 新卒エンジニア1年目の吉田です。冒頭でも紹介した通り、フォルシアではAtCoderJobs経由での採用を行っていますが、私も就活ではこちらを活用して入社しました。 私を含めた20年新卒には、初めてAtCoderJobs経由で採用された社員が複数名いるということになるわけですが、ここはひとつJobs一期生を代表して、競技プログラミングとフォルシアでの業務に関して、就活や入社後の経験も踏まえた実感を書き出してみよう!というのが、本稿の趣旨となります。 さらりとした私のこれまで お控えなすって! 私は経済系の大学院出身です。在学中は知人の紹介でwebアプリを開発するアルバイトをしていました。 といっても、エンジニアとしてはfor文とif文が書ける程度の実力で、フロントサイドのちょっとした修正をするにも時間がかかるような状況でした。 少年は競プロを通しエンジニアの道を知る そんな中、ふとしたきっかけでAtCoderの存在を知り、趣味の一環として継続的に取り組むようになりました。 ちょうど私が就活を始めた2018年からAtCoderJobsがサービスを開始したこともあり、就活の時期には活用してみることにしました。掲載企業の多くが要求ランクに水色を指定していたこともあり、水色になることを目標にそこそこ頑張った記憶があります(まだBeginners Contestがレート1199までを対象としていた時期ですね)。 そして縁あってフォルシアに内定が決まり、残りの学生生活は修士論文を書きつつ、競技プログラミングをやりつつ・・・といった日々となりました。 入社後も競技プログラミングはやりつつ、しれっと青色になることができ、今に至るといった感じでしょうか。 競技プログラミングは業務の役に立っているか? 私にとってはyesです。 問題解決能力は問題解決で身に着けるものなり 競技プログラミングは、一言でいえば「プログラミングという土俵での問題解決能力を競う競技」であると解釈しています。 業務においても、「プログラミングという土俵での問題」を解決するという点では同じです。 競技プログラミングでは問題解決のためにアルゴリズムやデータ構造の知識を駆使しますが、業務ではこれがフレームワークや扱うデータ等の知識に置き換わるという感じでしょうか。 また、競技プログラミングでは具体的な問題があらかじめ与えられていて、解決策を考える部分が大半を占めますが、業務では抽象的な現象から具体的な問題点を見つける部分、またどのような方法でその解決を行うか考える部分の比重が大きくなります。 そのため完全にオーバーラップするまでは言えないのですが、競技プログラミングにおける問題解決能力の向上は、業務における問題解決能力の向上に直結すると考えています。 ああ やっぱり過去問が一番だわ 私が競技プログラミングに取り組み始めた時期は、前述の通りエンジニアとしての力はほとんどない状況でした。 力がなかった原因は、あきらかに圧倒的な知識と経験の不足によるものでした。きちんと情報科学の勉強をしたわけでも、趣味で開発をしていたわけでもなかったので、作業の中で生まれる課題は初めて見るものばかり、考えつく対応策も不適当なものが多かったように思います。 しかし、競技プログラミングに取り組む過程で、私は上達のため過去に出題された問題をたくさん解く必要に迫られます。 この作業は、 問題点を見つけ 解決策を考え それを実現する という一連のサイクルを繰り返し訓練する事に他ならず、私にとっては不足していた知識と経験を非常に効率よく補強するものとなりました。 実際AtCoderのレートが伸び始めた頃には、webアプリの開発をするアルバイトの作業中に「昔苦しんだ挙句放置していた問題が簡単に解決できた」「これまで理解できなかったソースコードが容易に理解できた」といった体験を何度もしました。 実は競技プログラミングで使っている言語は開発と一切関連がないのですが、それでもエンジニアとしての根本的な能力が劇的に改善されたと実感しています。 入らないとわからないこともある 一方で、フォルシアに入社する前に想像していた「役に立ち方」と異なっていた部分もあります。 例えば、「競技プログラミングを通じて学んだアルゴリズムやデータ構造の知識が、直接業務で問われるわけではない」という点です。 AtCoderJobs上で フォルシアの新卒求人 は水色以上を要求ランクとしています。 競技プログラミングで良いパフォーマンスを得るには、問題解決のスキルだけでなく、出題されうるアルゴリズムやデータ構造の知識が必要不可欠です。 フォルシアでは、AtCoderの水色を「標準ライブラリのデータ構造に乗せれば解ける問題」を解くだけの知識と能力が担保されている水準、という観点で要求しています。 水色を目指そうとすると、結構な時間を知識の習得に割く必要があるかと思います。特に私は情報科学の教育をほぼ受けていなかったこともあり、かなり苦戦した記憶があります。 この苦労して培った競技プログラミングにおける知識の部分は、一切役に立たないというわけでもないのですが、業務内容と直接は関係しないものです。少なくとも、競技プログラミングの知見を生かして計算量改善をしていこう!というわけにはいきません(オーダーレベルで改善を行う業務はかなり稀だと思います)。 費やした労力を考えると、少しもったいない気持ちもあります。 しかしフォルシアに入って、これまでの学習がまったくの無駄だった、あるいは要求ランクと実務の間にギャップがある、といったことは感じません。これは実際に業務をするまで理解できませんでしたが、フォルシアの顧客が属する旅行業界が扱っているデータは本当に膨大で複雑です。 私が担当しているアプリではダイナミックパッケージと呼ばれる商品を扱っていますが、これは交通手段と宿泊施設を自由に組み合わせられ、その上価格も動的に変動するというもので、データもアプリも非常に(非常に!)複雑だと実感しています。 膨大かつ複雑なデータに対する高速な検索を実現するため、フォルシアではデータの持ち方にとても気を遣っています。これを理解し適切に運用するための学習は、扱うものは違えど競技プログラミングにおけるデータ構造の学習に通ずるものがあります。 そのため、「AtCoderで水色になれる水準の知識を習得した」という保証は新卒採用の基準として適切だと考えています。 (もちろんこれは「AtCoderのレート」をアピールポイントにする場合の話であって、AtCoderのレートが緑以下ならば適性がない、という意味ではまったくありません!フォルシアがエンジニアに対して求める理想を、採用段階ですべて満たしている必要はありません。フォルシアのビジネスや志向に興味を持った方は、ぜひ公式のフォーム( こちら! )からご応募いただければと思います。) 補足として(これは重要なことなので強調したいのですが)フォルシアは、AtCoderJobs経由での入社だからといって競技プログラミングの継続を強要しませんし、競技プログラマ向けに特別な業務を強いることもありません。 無論、計算機科学の深い知識が必要になる仕事もありますし、希望する方のそういった分野での活躍も強く期待しています(今はインメモリデータベースが熱い!詳しくは こちら )が、あくまで意思決定はエンジニア個人に委ねられています。 業務と競技プログラミングの相乗効果 ここまでの主張は「競技プログラミングをやっていると業務にとってプラスになるよ!」というものでしたが、今になって思うのは、逆に「業務を行っていると競技プログラミングにとってプラスになるよ!」という面すらあるのだ、ということです。 変数名はナメたら命取り 一つ目は、「型」を学べるという点です。これはデータ型という意味ではなく、コーディングの流儀という意味での型です。 アルバイトコーダー時代の私はそもそも流儀云々以前の実力だった上に、基本的に一人での開発、内容も「動けば何でもよし!」というものでした。 それゆえに命名は適当、表記の統一性もバラバラ、可読性なんて言葉聞いたことありませんが?という有様でした。 競技プログラミングを始めてからも、読むのは自分だけ、正答すれば何でもよし!という世界の中で、コードの可読性を上げる努力をしたことはありませんでした。 一方で、フォルシアでコードを書く上では、フォルシアのコード規約を守る必要があります。 また、甘えた命名をしたり可読性の低い処理をコミットしてしまった場合、コードレビューで矢のように指摘が飛んできます(コードレビューに関しては同期が こちら で記事を書いています)。 これは複数人での開発において、プロジェクトの開発効率や保守性を高いものにする上で必要不可欠な過程ではありますが、同時に私にとっては、業務の中で自分のコーディングの「型」を育てる過程にもなりました。 私は入社後にしばらく競技プログラミングを一切しない時期があったのですが、久しぶりにやや複雑な問題を解こうとした際にとてつもない違和感を感じたことを覚えています。 これまで通りのやり方で問題を解くコードを書いていると、心の中のリトルヨシダが「そこは関数に切りだせ」「もっとわかりやすい名前をつけろ」と話しかけてくるのです。いつの間にやら、私はすっかり可読性の低いコードを受け付けない体質になってしまっていたようでした。 特に開発経験のあまりない、プログラミングはAtCoderでしかしていません!というような方にとっては、(フォルシアに限らず)業務を通じて一定の流儀がある環境に身を置くことで、プラスになる面があると考えています。 チャランポランなコード程バグると恐い 二つ目は、デバッグが速くなるという点です。 先程述べた通り、業務では競技プログラミングと比べて問題点を見つける部分の比重が大きくなります。 例えば運用・保守の業務であれば「問題が発生した。解決してほしい」という依頼を出発点として、沢山あるコードの中から原因を特定して修正することになります。 また、開発業務であっても、自分の実現したいことと既存の実装に矛盾がある場合は、その原因を特定して改善する必要があります。 競技プログラミングでは基本的に自分のコード、または正解のコードを見る機会がほとんどだと思いますが、業務の中では誤っている(かもしれない)コードを見る機会が非常に多いです。 また、前項の通り、業務の中で「型」を身に着け、可読性の高いコードを書くようになることで、自分のコードがそもそもバグを生みにくい、かつバグを発見しやすいものになっていきました。誤りを見つける訓練と、誤りを見つけやすくする訓練の二つが、業務の中で自然と行われていたことで、競技プログラミングにおいても自分のデバッグ力の向上を実感することができました。 フォルシアでの競技プログラミング さて、業務におけるシナジーという観点で話を進めてきましたが、入社してからも純粋に競技プログラミングを楽しむ機会は沢山あります。 せっかくの機会なので、過去約一年以内にフォルシア内で行われた、またはフォルシアとして参加した関連イベントについて紹介していきます。 AtCoder やはり一番盛んなのはAtCoderです。20卒の入社で社内のユーザーがかなり増えましたが、それ以前から取り組んでいる先輩も多数います。 最近は社内向けの仮想コンテストを定期的に開催しており、夏のインターン時には学生も交えて盛り上がりました(インターンについて詳しくは こちら )。 また、アルゴリズム実技検定(PAST)も毎回多くの社員が受験しています。前回は満点を出した同期が社内向けの解説会を開催し、普段競技プログラミングに触れない層も含めたイベントになりました。 ゆるふわオンサイト フォルシア主催のプロコンです。 詳しくはこちら 前回は20卒の同期たちが作問をしていました。私はオンラインでこっそり参加しましたが、あまり成績が良くなかったのでこのことは誰にも明かしていません。 次回の開催予定はまだ未定ではありますが、必ず再開されると思います。フォルシア競技プログラミング部では作問のできるエンジニアを絶賛募集中! ICFPC 72時間で難問を解くタフなプロコンです。私は軟弱なので参加しませんでしたが、タフな同期と先輩が参加していました。フォルシア競技プログラミング部はタフなエンジニアを絶賛募集中! ISUCON チームでwebサービスの高速化を競うプロコンです。私は参加しましたが、普段やっている競技プログラミングと比べるとかなり実務よりの知識が要求されるため新鮮に感じました。 案の定というか結果は散々だったので来年こそは!という気持ちがあります。フォルシア競技プログラミング部は自走力のあるエンジニアを絶賛募集中! PG BATTLE 3人1組でそれぞれが問題を解いて合計点を競うプロコンです。 私は20卒の同期と組んで出場しましたが、二人が満点だったにも関わらずせんべい役の私が大失速してしまいランクインを逃してしまいました。フォルシア競技プログラミング部は強靭な顎を持ったエンジニアを絶賛募集中! CodinGame ゲームAIを作って他のAIと戦わせ、上位を目指すプロコンです。 かなりカジュアルですが、企業ごとに上位プレイヤーのスコアの集計でランキングが競われます。 私はカジュアルに参加してカジュアルな戦績しか得られませんでしたが、フォルシアとしては全世界62位でした。フォルシア競技プログラミング部は拡大再生産に強いエンジニアを絶賛募集中! おわりに 節目節目に宣伝を入れ直せ 上でも少し触れましたが、特に情報系以外の出身の方や、競技プログラミングがコーディング生活の中心という方ほど、競技プログラミングとフォルシアでの業務は相互にシナジーを生む、ということが実感できるかと思います。この記事を読んで「もしかして私?」と思ったあなたには、この後ぜひ見ていただきたいページがあるので、それを紹介してこの記事はおしまいということにします。 フォルシアはAtCoder Jobsにてエンジニアを絶賛募集中! 新卒採用はこちら 中途採用はこちら
アバター
FORCIAアドベントカレンダー2020 15日目の記事です。 この記事はなんでしょう? 新卒エンジニア1年目の吉田です。冒頭でも紹介した通り、フォルシアではAtCoderJobs経由での採用を行っていますが、私も就活ではこちらを活用して入社しました。 私を含めた20年新卒には、初めてAtCoderJobs経由で採用された社員が複数名いるということになるわけですが、ここはひとつJobs一期生を代表して、競技プログラミングとフォルシアでの業務に関して、就活や入社後の経験も踏まえた実感を書き出してみよう!というのが、本稿の趣旨となります。 さらりとした私のこれまで お控えなすって
アバター
FORCIAアドベントカレンダー2020 14日目の記事です。 事業開発部の龍島です。皆さんschema firstな開発してますか? フォルシアではwebアプリケーション開発にサーバサイドはExpress.js + TypeScript、クライアントサイドはNext.js(React.js) + TypeScriptを用いており、間をつなぐAPIインターフェースの定義にOpenAPI Specification(swagger)を用いてschema firstな開発を行っています。 ここ2年ほどschema firstな開発を行ってきましたがAPIのインターフェース定義を初めに設計する
アバター
FORCIAアドベントカレンダー2020 14日目の記事です。 事業開発部の龍島です。皆さんschema firstな開発してますか? フォルシアではwebアプリケーション開発にサーバサイドはExpress.js + TypeScript、クライアントサイドはNext.js(React.js) + TypeScriptを用いており、間をつなぐAPIインターフェースの定義にOpenAPI Specification(swagger)を用いてschema firstな開発を行っています。 ここ2年ほどschema firstな開発を行ってきましたがAPIのインターフェース定義を初めに設計することでサーバサイドとクライアントサイドの開発を複数人で同時に進めることができ、近年のフォルシアでも大規模化してきているwebアプリケーション開発に適した手法だと感じています。 web上にはschema firstで開発していこう!という記事は多くありますが、実際にOpenAPIの仕様を満たすAPIを実装する方法を解説した記事は少ないです。 社内でもOpenAPI周りの仕組みは約2年前に情報のない中手探りで整備したものですが、苦労した部分もありつつ、ある程度上手くワークしていると思うので、これから導入を検討している方に向けて紹介したいと思います。 schema first開発が世の中に浸透してきた今ではライブラリも充実してきているため、現状の仕組みに加えて新しいライブラリの導入で今ならこうしたほうが良さそうという点も補足していきます。 今回紹介する内容の全体像を図にしてみました。①~③の箇所で利用している内容について、詳細をみていきましょう。 1. リクエスト、レスポンスのTypeScriptの型を自動生成 一番メリットを享受しやすいのはTypeScriptの型生成の部分かと思います。 フォルシアでは sw2dts を用いてOpenAPIの定義からリクエスト、レスポンスの型定義を自動生成しています。sw2dtsはOpenAPI定義のJSONからdtsファイルを出力する dtsgenerator のラッパーですが、dtsgeneratorでできなかったGETのクエリパラメータも型定義として出力できるようになっています。 /post/:id などのようにpathにパラメータが含まれる場合は定義が出力できないので注意が必要です。 現在では openapi-typescript というライブラリが出てきており、クエリパラメータもOpenAPI3系であれば上手く出力できるようなので、置き換えが可能そうです。 2. リクエストパラメータのバリデーション機能を自動生成 型定義を生成しても、リクエストパラメータに関しては定義通りに叩いてもらえるとは限らず、パラメータをバリデーションする必要があります。バリデーションを手作業で実装すると、定義と乖離してしまう可能性があります。二重管理を防ぐためOpenAPIの定義からバリデーションを生成したいです。 整備当時はこの要求を満たす良いライブラリを見つけられず、expressの標準的なバリデーションライブラリのexpress-validatorを利用して実現しました。OpenAPI定義のJSONからexpress-validatorの schema validation 機能の形式のオブジェクトを生成するスクリプトを内製しています。基本的なバリデーションは成功し、内製のため自由度もあり、ある程度上手く運用できているとは言えますが、default値の設定など対応できていない部分もあり、可能なら外部のライブラリで巻き取ってしまいたいとも感じています。 現在では express-openapi-validator という目的に完全合致したライブラリがあり、Express.jsのmiddlewareとして導入することで定義通りにバリデーションをしてくれるようです。現在の内製スクリプトの仕組みを置き換えるものとしてフォルシアでも導入を検討しています。 3. 実際のレスポンスが定義通りかチェックするテストを自動生成 前述のsw2dtsを用いてレスポンスの型定義は作れているものの、DBから取得してきた値などでTypeScriptの型定義が100%信用できない場面もあり、実際のレスポンスが定義通りであることを保証するテストも導入しています。 openapi-response-validator はオブジェクトがOpenAPIの定義を満たしているかチェックすることができるので、frisby.jsで実際にAPIを叩き、レスポンスが仕様を満たしているかをテストしています。 番外 OpenAPIの定義をいい感じに書く API開発とは少しずれますが、以前 OpenAPI(Swagger)の定義をいい感じに書くTips を記事にしています。よろしければご覧ください。 まとめ 以上がフォルシアでのOpenAPIの定義に準拠するAPI開発で導入している仕組みです。まとめると下記のようになります。 リクエスト、レスポンスのTypeScriptの型をopenapi-typescriptなどを用いて自動生成 実際のリクエストパラメータの値をexpress-openapi-validatorなどを用いてバリデーション 実際のレスポンスの値をopenapi-response-validatorなどを用いてテスト TypeScriptの世界と実際のリクエスト、レスポンスの両方がOpenAPIの定義と一致していることを保証する仕組みを作ることで、定義と実装の乖離を防げます。 OpenAPIを用いた開発は一度環境を整備してしまえば、特にクライアント側とサーバ側を同時に開発するようなプロジェクトでかなり有効です。今回改めて周辺ライブラリを調査することで改善できそうな点も見えてきたので、フォルシアのschema first開発環境もUPGRADEしていきたいと思います。
アバター
FORCIAアドベントカレンダー2020 13日目の記事です。 こんにちは検索プラットフォーム部エンジニアの伊藤(亜紀)です。 今日は、社内の意見交換の活性化を目的として始めたオンラインディスカッション(以下Jamboardディスカッション)について、その内容や始めた経緯、工夫をご紹介します。 Jamboardディスカッションとは フォルシアでは、週に1度、エンジニアのほぼ全員40人程度が参加して、情報共有ミーティング(WEBミーティング)を行っています。 今年の10月より、そのミーティングのなかで、フォルシアのビジネスや日々の仕事を見直し、改善していくためのディスカッションを始めました。 進め方 大勢が参加するWEBミーティングであっても各人が意見を表明しやすいように、 Google Jamboad のブラウザアプリを利用しています。 ※Jamboardについて検索すると、専用ハードウエアを使って云々という説明がたくさん出てきますが、ブラウザからもJamboardを閲覧・編集することができます。 最初に、ファシリテーターがその月のディスカッションテーマの背景について説明します。その後、全体に向かって問いを投げかけ、それに対する答えを参加者がその場でJamboard上の付箋に書いていきます。 ある程度答えが出揃ってきたら、ファシリテーターがいくつかの意見をピックアップして、「詳しく教えてください」「理由を教えてください」といった深堀りをしていきます。 ファシリテーター以外のメンバーも、気になることがあれば適宜「その点はこう考えた方が良いのでは?」「これはどういう意図ですか?」といった質問や指摘をしていきます。 30分弱ほどで1~2つの問いについての付箋記載→深堀りを繰り返し、次の週は続きの問いからスタートします。 11月のディスカッションのJamboard。付箋に参加者の様々な考えが記載されています。 ディスカッションを始めたきっかけ フォルシアにおけるコミュニケーション 元来フォルシアは「同じ場所で時間を共有して互いの人となりを理解すること」や「社員が部署や役職を超えて話をすること」が仕事にも良い影響を与えるとの考えのもと、 福利厚生 や社員発案の シャッフルランチ などにおいて、社員間のコミュニケーションを活性化させる取り組みを積極的・継続的に行ってきた会社でした。 それが、2020年度はコロナ禍により、こういった対面の活動は大幅に縮小せざるを得なくなりました。 業務そのものについても、在宅勤務で業務を遂行することはできますが、他社員の考え方や置かれている状況・取り組んでいる課題について、さりげない雑談やそれとなしに耳に入る会話から知る機会は大幅に減少しました。そういった情報を「些細な情報」「余計な情報」とする見方もあると思いますが、フォルシアではそういった情報に価値を見出してそれまで社員全員のデスクをひとつの執務室に集めていたこともあり、私個人としてはこの変化はフォルシアにとって大きな変化に感じられました。 特に、他のチームで起こっている問題や困りごとに触れる機会が減ると、自分たちのビジネスや業務プロセスを"全社視点に立って"改善していこうという気持ちにもなりにくい、というのは私自身在宅勤務をしながら痛感しており、課題意識を感じていました。 各人が意見を表明し、他者が見ている景色を知る場としてのディスカッション そんな課題感について上長と相談したところ、始めることになったのが、このJamboardディスカッションです。 大勢が集まる場で込み入った議論はできないまでも、共通のテーマに対して各人が意見や経験を話すことで、 誰もが意見を表明し 他者の多様な意見から、自分には見えていない現実の景色や新しい視点を知り その新たな視点をふまえて、現在のビジネスや業務プロセスを見直す そんな機会を作りたいと考え、Jamboardディスカッションを企画しました。 ディスカッションテーマ これまでの開催テーマをご紹介します。 10月:ロイヤリティビジネスの今後 フォルシアのメインプロダクトSpookは、一般的な受託開発ではなく、長らくロイヤリティビジネスの形態をとってきた点に特徴があります。昨今の「サブスク」浸透や、今年はフォルシア創業から20年目の節目であることもふまえ、フォルシアのロイヤリティビジネスの今後を考えるために、まずは「今」を再確認する機会を設けました。 お題 問1:ロイヤリティビジネスの形態によって、私たちはどのような恩恵を受けているでしょうか? 問2:ロイヤリティビジネスの形態によって、私たちはどのような制約を受けているでしょうか? 問3:ロイヤリティビジネスの形態によって、私たちが提供できている価値はどのようなものでしょうか? 11月:どうやっている!? 運用保守 フォルシアでは開発と運用保守で担当エンジニアを分けず、1人のエンジニアが両方を担当します。両方を担当することで広い視野に立ったエンジニアリングが可能な一方で、性質の異なる両タスクを並行して進めていくのには特有の難しさもあります。そのなかでも運用保守について、各人の取り組みを問いかけてみました。 お題 問1:運用保守として、どのような対応をしていますか? 問2:運用保守対応で、難しいと感じること・悩むことは何ですか? 問3:運用保守対応で、心がけていること・工夫していることを教えてください。 投げかける問いは、あえて「現実の確認」にとどめる 「より良くするためには」「これから何をやっていくべきか」といった直球の議論をしたい思いは強くありましたが、そういった課題感は問いの背景として触れるにとどめ、問いはあくまでも「各人が見ている現実」を書いてもらうものとしました。 具体的には、投げかけを「ほぼ誰でも少し考えれば答えることができて、人によって違った答え(または同じ答えでも違った理由)が返ってくるであろう」問いに絞りました。 これは、もともとは とにもかくにも参加者に付箋を書いてもらわないと始まらない。そのためにアウトプットを出すハードルをできる限り下げたい でもせっかく大勢で話すからには意味のある場にしたい。だから最低でも「他者の視点」は参加者が持ち帰ることができるようにしよう との考えでやっていたことでした。改善案までたどりつけたら文句なしに素晴らしいですが、限られた時間のなかでは欲ばりすぎず「背景課題の存在や、それを取り巻く状況を、ディスカッション終了時点で理解してもらえたらそれでOK!」と考えることにしました。 しかし何度かディスカッションを重ねているうちに、これはアウトプットのハードルを下げるだけでなく、改善や何か新しい取り組みを始めようというときに、とても大事なステップなのだということに気づきました。 いきなり課題の絞り込みや解決に取り掛からずに、「背景課題を取り巻くあれこれを各人がどう見ているか」にとことん焦点を当てたことによって、「ぱっと想像できる以上に、周囲の人は皆違った景色を見ているし、違った現実認識をしている」ということを改めて認識させられるとともに、背景にあった課題がより手ざわり感のあるものになったように感じられました。 どんな課題も他者と協力して解決していく必要がありますが、協力のためには、そのよすがとなる共通の認識や感覚・経験が必要です。 頭ではそうとわかっていても、そのことを忘れて(自分が興味がある)課題にいきなり取り掛かろうとしてしまうことが個人的には多かったのですが、「背景課題を取り巻くあれこれを各人がどう見ているか」についての問いと回答は、そのよすがのようなものを作る役割を果たしてくれるものになるように感じました。 開催してみて 正直なところ、やってみる前は「リモートかつ大勢で活発な議論ができるものだろうか」という心配とともにスタートしたのですが、これまでのところ、実際には予想を大きく上回るたくさんの意見・経験談をもって開催してきています。 ファシリテーションは難しい(!)ですが、周囲のフォローにも助けられ、私自身非常に学びのある時間になっています。 Jamboardディスカッションに参加した社員からは、 「会社としてなぜこのようなやり方をしているのか」など在籍年数によって知識量に差が出がちだが、経緯となった事例がわかってよかった 難しい問いもあったが、自分が普段苦労していることなどはたくさん付箋を書くことができた 様々なバックグラウンドの人がいるなかで、前職の経験をふまえた話をきけて興味深かった といった声をもらいました。 最後に いかがでしたでしょうか。リモートワーク下でのコミュニケーションについては今後も様々な組織で模索が続くと思いますが、この事例がひとつの参考になれば幸いです。 12月分からは議題提起者を変えて、また新たな切り口からディスカッションを行っていきます。
アバター
FORCIAアドベントカレンダー2020 12日目の記事です。 弊社はこれまで PostgreSQL を利用した高速なスペック検索をコアコンピタンスとしてきましたが、今後はドキュメント検索にも注力していく予定です。OSS のドキュメント検索エンジンといえばまず思いつくのが Elasticsearch  です。PostgreSQL と比較されることの多い Elasticsearch ですが、今回は特に日本語処理の周りを技術的にやや深めに比較してみたいと思います。 本記事はPostgreSQL についてある程度知っているがElasticsearch はあまり知らない、という方を対象としています。 Elasticsearch とは 簡単にElasticsearch の特徴を挙げると下記のとおりです。 全文検索に特化した検索エンジン。コア部分は java 製の全文検索エンジン Apache lucene を利用 REST APIですべてのオペレーションを実行可能。POSTするデータはJSONで記述する スキーマレス。DB設計をせず文書を登録・検索できる。スキーマを設定することも可能 スケーラブル Elasticsearch v.s. PostgreSQL 一般的な比較記事は多いので、よくまとまっていると思われる記事を紹介します。 Elasticsearch vs. MongoDB vs. PostgreSQL Comparison 上記はデータベースとしての比較記事です。ドキュメントストアとしてよく使われるMongoDB との比較も含まれていて参考になります。 Elasticsearch は MongoDB 同様 "Schema-free"、すなわちDBの設計をせずにとりあえず文書を放り込んで検索することができます。この手軽さが人気のある理由と思われます。 SQLとElasticsearchとのクエリの比較 - Qiita データベースとしてのElasticsearch - Qiita 上記は検索言語(query)の比較を行っています。Elasticsearch の検索はSQLではなく Elasticsearch 独自のシンタックス(Domain Specific Language) で行います。 SQL的な厳密で複雑な検索は苦手ですが、テキスト検索については様々な指定ができて便利です。検索処理をコントロールするスクリプトや並び順を調整するオプションを query に含めることもできます。 また aggregation といって検索結果をランキングして出力するだけでなく、ファセット毎の検索数をあわせて出力するよう指定もできます(SQL でいうgroup byに相当) 。 GUI tool  の比較 PostgreSQL には pgAdmin4,   Elasticsearch には head と呼ばれるGUIツールがあります。 pgAdmin はスタンドアロンのアプリケーションですが head は node.js の server版と chrome extension 版があります。[ 1 ] pgAdmin pgAdmin - PostgreSQL Tools Elasticsearch  head GitHub - mobz/elasticsearch-head: A web front end for an elastic search cluster Google Trend の比較 Google Trendでの比較 Google Trend 的にはPostgreSQL が Elasticsearch を圧倒しています。 例外的に中国では Elasticsearch が強いようです。 全文検索速度の比較 PostgreSQL と Elasticsearch は設計思想が違うので一概に比較できないのですが、 Full-Text Search Battle: PostgreSQL vs Elasticsearch | sudo README によれば、かなり乱暴にまとめると下記のようになっています。 普通はElasticsearchが 5倍位速い PostgreSQL でチューニングした場合、速度は大体同等 上記は様々な条件付きの結果です。詳細はオリジナルの記事をご覧ください。 機能拡張の比較(extension v.s. plugin ) PostgreSQL にはextension と呼ばれる機能拡張用のモジュール( pg _bulkload のように pg ** という名前が多い)が多数あります。 Elasticsearch にも同様の機能拡張用のモジュールがあり pluginと呼ばれています。どちらもユーザが作ることができますが、PostgreSQL のextension はC言語で記述、plugin は Java で書くことになります。そのせいかplugin の方が個数は多いように見えます。 Elasticsearch の面白いプラグインとして ingest-attachment plugin があります。これは pdf や docx, xlsx, pptx といったオフィス文書ファイルをpostすると、テキストや author 属性、CreateDate 情報を抽出して自動的にDBに登録してくれるものです。このようにElasticsearch のプラグインはより応用的・ユーザ志向である一方、PosgreSQL の extension はシステム開発者向けという位置づけになるかと思います。 Elasticsearch では言語(英語、ドイツ語、日本語など)に固有な処理もプラグインの形で提供されています。日本語形態素解析もプラグインとして Elasticsearch に組み込みます。 Elasticsearch の日本語形態素解析 以下では Elasticsearch の日本語形態素解析周辺について述べていきます。 Elasticsearch の言語解析はAnalyzer と呼ばれる言語毎のプラグインの形になっていますが、基本的な機能や仕様は言語共通になっています。すなわち、 文字単位の前処理を行う Char_filter 単語分割を行う Tokenizer 不要単語の削除など後処理を行う Token_filter の3つから構成されています。 詳細は下記をご覧ください。 ElasticsearchのAnalyzer, Tokenizer, Token Filters, Char Filtersの一覧 - Qiita Elasticsearchを日本語で使う設定のまとめ - Qiita 日本語解析で使える Analyzer プラグインは下記の5つです。 analysis-icu GitHub - elastic/elasticsearch-analysis-icu: ICU Analysis plugin for Elasticsearch analysis-kuromoji Japanese (kuromoji) Analysis Plugin | Elasticsearch Plugins and Integrations [7.10] | Elastic analysis-kuromoji-ipadic-neologd GitHub - codelibs/elasticsearch-analysis-kuromoji-ipadic-neologd: Elasticsearch's Analyzer for Kuromoji with Neologd analysis-mecab GitHub - animalmatsuzawa/elasticsearch-analysis-mecab: elasticsearchのanalysis plugin 形態素にmecabを使用 sudachi GitHub - WorksApplications/elasticsearch-sudachi: The Japanese analysis plugin for elasticsearch analysis-icu プラグインは日本語だけでなく中国語、韓国語などアジア言語に対応しています。 ユニコード処理に強いので全角半角統一などユニコード関連の文字の正規化には便利ですが、単語分割は弱そうです。また、ユーザが辞書を追加することができません。[ 2 ] Kuromoji と Sudachi はJavaで書かれた日本語形態素解析モジュールです。 上記はその Elasticsearch プラグイン版です。analysis-kuromoji は辞書のソースコードは MeCab と共通なので解析結果は MeCab と似ていますが、デフォルトで入っている辞書がIPADICと最低限のものになっています。 そこで NEologd 辞書 を追加したものが analysis-kuromoji-ipadic-neologd です。 analysis-mecab は Java からMeCab を呼び出す形で形態素解析するものです。[ 3 ] Java製でないためElasticsearch ではマイナーなプラグインのようです。 弊社では日本語形態素解析としてMeCab を使うことが多く、辞書が MeCab 互換だと嬉しいです。そこで以下では analysis-kuromoji,  analysis-kuromoji-ipadic-neologd, analysis-mecab の3つについて調査します。 日本語処理のカスタマイズ ICU プラグインを利用すると下記処理ができます。 ICU Normalization Character Filter は NKFC正規化 を行うもので、英数字やカタカナの全角半角統一等ができます。 Kuromoji プラグインは下記の処理ができます。 踊り字(々、ゞ)の正規化とは、「常々」⇒「常常」といった処理を行います。 これら以外にも言語非依存のフィルターとしてHTMLのタグを除去する HTML Strip Character Filter や電話番号等を正規表現で正規化する Pattern Replace Character Filter があります。 また、これらのフィルターをアップロードする文書のどの部分(フィールド)に適用するか、柔軟に指定することができます。これにより文書の日付欄には日付の正規化を行う、電話番号欄には電話番号の正規化を適用する、名前欄は形態素解析しない等の設定をすることができます。 Kuromojiによる形態素解析 analysis-kuromoji には単語分割に3つのモード(normal, search, extended) があります[ 4 ]。 normal  通常の単語分割(最長一致) search  通常の形態素解析に短い単語分割を追加したもの extended 未知語(辞書に登録されていない単語)を1文字単位にばらしたものを更に追加 search モードは検索のrecall を上げるために使います。extended は n-gram で部分一致する単語を検索することを想定していると思われます。解析例は下記のようになります。 Untokenized Normal mode Search mode Extended mode 関西国際空港 関西国際空港 関西 国際 空港 関西 国際 空港 日本経済新聞 日本経済新聞 日本 経済 新聞 日本 経済 新聞 シニアソフトウェアエンジニア シニアソフトウェアエンジニア シニア/ソフトウェア/ エンジニア シニア/ソフトウェア/エンジニア ディジカメを買った ディジカメ/ を/ 買っ/ た ディジカメ/ を/ 買っ/ た デ/ ィ/ ジ/ カ/ メ/ を/ 買っ/ た 下記によれば、Searchモードは漢字のみで構成される4文字以上の単語、もしくは7文字以上の単語に対してコストを重くすることで、分割を促しているようです。 Java製形態素解析器「Kuromoji」を試してみる 以下に   search モードでの解析例を示します。言語解析結果を表示する API である _analyze を上述した head ツールで利用しています。 ユーザ辞書は使えるか analysis-kuromoji、およびanalysis-kuromoji-ipadic-neologd は CSV 形式のテキストファイルをユーザ辞書として組み込むことができます。プラグインのカスタマイズパラメータに "user_dictionary" というフィールドがあり、そこにテキストファイルを指定します。 kuromoji_tokenizer | Elasticsearch Plugins and Integrations [7.10] | Elastic しかし、テキストファイルで指定することからわかるようにユーザ辞書としてはせいぜい数百語程度のサイズを想定しているようです。数万~十数万語レベルのユーザ辞書は使えません。換言すると(検索用にバイナリコンパイルされた)ユーザ辞書を実行時に指定して利用する方法はありません。 実行時でなければ analysis-kuromoji-ipadic-neologd プラグインのソースコードをダウンロードして、NEologd 辞書のソースコードにユーザ辞書をCSV で追加してプラグインをビルドすることにより、大量の単語の追加をすることができます。 一方analysis-mecab は "user_dictionary" カスタマイズパラメータにMeCab用にコンパイルしたユーザ辞書を指定することで、その辞書を利用することができます。 長い単語を辞書登録して形態素解析できるか analysis-kuromoji は内部的に Lucene Kuromoji を使っていますが、Lucene Kuromoji には「見出しは16文字未満」という制約があります[ 5 ]。 一方、NEologd には16文字以上の長い単語が多数登録されています。analysis-kuromoji-ipadic-neologdでこの制約が改善されているか確認しました。 念の為 analysis-mecab でも、辞書登録された長い単語が正しく形態素解析できるか確認しました。[ 6 ] 原文 (NEologd 辞書ソースより採取) 原文文字数 kuromoji-ipadic-neologd analysis-mecab あいしてると言ってよかった 13 あいしてると言ってよかった あいしてると言ってよかった あさぎり町立上小学校皆越分校 14 あさぎり町立上小学校皆越分校 あさぎり町立上小学校皆越分校 あけましておめでとうございます 15 あけましておめでとうございます あけましておめでとうございます あなたの夢の中そっと忍び込みたい 16 あなた/の/夢の中/そっと/忍び込みたい あなたの夢の中そっと忍び込みたい うわうみ漁業協同組合日振島女性部 16 うわ/うみ/漁業協同組合/日振島/女性/部 うわうみ漁業協同組合日振島女性部 分割された箇所に"/" を入れています。残念ながらkuromoji-ipadic-neologd では16文字以上の辞書登録単語は分割されてしまいました。この他にも記号で始まる単語で解析されないものがあり(形態素解析結果が空になる)、kuromoji-ipadic-neologd は若干不安定な印象です。 PostgreSQL の日本語形態素解析 PostgreSQL で単語分割を導入して検索精度を上げるのには textsearch ja モジュールを用います。ja wakati 関数により MeCab を呼び出して単語分割を行い、tsvector 型のフィールドに分割結果を入れます。検索するときは検索文字列を tsquery 型に変換して比較を行います。詳細は下記を参照ください。 MeCab と textsearch_ja を使って高速な全文検索を実現しよう - PowerGres 体験記 第 4 回 形態素解析の前の正規化は to_tsvector() 関数内部で実行されますが、ビルトインでカスタマイズはできません。 tsvector 型は 制約が多く 検索が遅くなりがちなので、以下のような回避策がとられる場合もあります。 検索対象テキストを単語分割する。単語間にセパレータ文字を挟んでテキストとして登録する(a)。 検索文字列を単語分割する。単語間にセパレータ文字を挿入する(b)。 (a) に対して(b) をフルテキスト検索する。 まとめ Elasticsearch の日本語解析の周辺をやや細かく眺めてみました。 文字の正規化やストップワードの除去など、テキスト検索に必要な各種機能をElasticsearch はワンストップで提供しています。文書のフィールド毎に解析方法を替えることができ柔軟性が高いです。したがって通常の用途(それなりに単語切りできれば良い)には十分ですが長い専門用語を正確に検索したいといった用途には向きません。 Elasticsearch の設計思想は、長い単語を正確に形態素解析してprecision を上げるより短く切って recall 重視というように感じます。そのほうが解析速度も確保できます。 また、Kuromoji と その Elasticsearch プラグインである analysis-kuromoji (あるいは Lucene Kuromoji)はソースコードが違うというのも意外でした。様々なバージョンがあり辞書や解析結果が若干異なるというのは面倒です。またユーザ辞書の扱いがMeCab に比べると弱いと感じました。後継(Sudachi) が登場しているのはその辺りが理由かもしれません。 PostgreSQLは MeCab を呼ぶ機能はありますが、それ以外の正規化や後処理は基本的に自作する必要があります。 ただ、どれも実装は容易です。MeCabは登場してから10年以上経ちますが、基本設計と性能・実装が良いため日本語形態素解析のスタンダードとして確立しています。ユーザ辞書に辞書登録すれば原文にその単語が出てきたときに正しく分割されます。その意味で安心して使うことができます。 今回は日本語形態素解析周辺の話で終わってしましました。Elasticsearch と PostgreSQL に於ける同義語・類義語の扱いや、Elasticsearch 7.* で導入されたドキュメントベクトル検索について触れられませんでした。いずれ機会があれば紹介したいと思っています。 注釈 [1] 以前は plugin 版や docker 版もありましたが、最新版 Elasticsearch 7.* にはないようです。 Elasticsearch は上述したように全てのオペレーションをREST API で実行できるので、開発や動作確認は curl や wget で済むといえば済むのですが、head には JSON のvalidate やprettyPrint 機能もあり便利です。クラスタの統計情報を確認することもできます。 [2] なので char_filter には analysis-icu を使い tokenizer には 後述する analysis-kurmoji を使うのがベストプラクティスとされているようです。 [3] URL版は Elasticsearch 5.* 用で最新版では動作しませんが、簡単な修正で 6.* ↑ 用にビルドすることができます。 [4] これは Kuromoji 自体の機能です。 [5] Kuromoji(Atilika)0.9-SNAPSHOTに、NEologd(ipadic、unidic)を適用してみた話 - CLOVER 🍀 [6] mode = normal で計測しました。analysis-mecab ではシステム辞書として mecab-ipadic-neologd を指定しています。
アバター
FORCIAアドベントカレンダー2020 12日目の記事です。 弊社はこれまで PostgreSQL を利用した高速なスペック検索をコアコンピタンスとしてきましたが、今後はドキュメント検索にも注力していく予定です。OSS のドキュメント検索エンジンといえばまず思いつくのが Elasticsearch です。PostgreSQL と比較されることの多い Elasticsearch ですが、今回は特に日本語処理の周りを技術的にやや深めに比較してみたいと思います。 本記事はPostgreSQL についてある程度知っているがElasticsearch はあまり知らない、という方を対象としていま
アバター
FORCIAアドベントカレンダー2020 11日目の記事です。 こんにちは。旅行プラットフォーム部の新卒1年目エンジニアの三浦です。 業務では大きな旅行サイトのプロジェクトに携わっており、技術面・仕事面ともにキャッチアップに追われる日々を過ごしています。 さて、今日は日々の業務で行っている、チーム開発に欠かせないコードレビューについての記事です。 先日チーム内でコードレビューについて話し合う時間があり、その中で勉強になる意見が多かったので自分なりにまとめて皆さんにお伝えしたいと思います。 この記事を読んでくださった方が少しでも「コードレビューやっていくか~」という気持ちになっていただければ幸いです。 記事に出てくる用語の整理 MR:Merge Requestの略。社内のコード管理に使用しているGitLab上で、改修を行ったブランチをMasterブランチに取り込む際に出すもの。これを見てコードレビューをし、大丈夫そうなら改修が取り込まれる。GitHubのPull Request レビュワー:コードレビューをする人。MRを見る人 レビュイー:コードレビューを受ける人。MRの作成者 そもそもコードレビューってなに? 私は未経験から入社したエンジニアなので、この状態から始まりました。 読んで字のごとく、人の書いたソースコードを見てレビューすることですが、より具体的に言うと、たとえば間違いがないか、もっと良い書き方はないかなどを実装者のコードを確認して指摘したりすることですよね。チーム開発ではそのようにコードの質を担保していきます。 しかしながら、自分でコーディングすることすら不慣れな私には、人のコードを読むことは難題でした。レビューなんて「ベテランの先輩が新人のコードが正しいかどうかチェックする」くらいのものだろうと思い、今のチームに参加した当時は敬遠していました。 ですが実際は様々なメリットが望めるもので、むしろ新人でプロジェクトに途中から参加した私のような立場の人こそ積極的にやっていくべきことなのです。 そう思えるようになったのは、「何のためにコードレビューをするのか」という目的意識を持てるようになったからだと思います。 「バグを減らす」ことももちろん大事な目的ではありますが、それだけではなく様々な目的があったのです。 では、そもそもどうしてコードレビューをするのでしょうか? コードレビューの目的とは 様々あるコードレビューの目的を整理してみます。 目的が意識できればやる気にもつながりますし、とるべき手段や考えるべきことも見えやすくなります。 バグを減らす 仕様の誤解、実装(ロジック)レベルのバグ、改修によるデグレなど細分化もできますが、とにかく予期しない挙動をなくすことです。一番わかりすい目的ですね。 ある先輩曰く「無くす、ではない(重要)」らしいです。レビューばかりにコストは避けないのでバランスが難しいですね。プロダクトによっても異なりそうです。 メンテナビリティの向上 メンテナンスのため、可読性向上や実装方針の統一も大事ですね。実装者以外の人が手を入れることが容易になります。 テストを書いておいて欲しいとなることもありますよね。少し入り組んだロジックでも、テストに通っていればひとまずその単位では動作が保証され、後の調査などの助けになります。 レビュイーの成長 この部分は特に先輩の方が意識されているところかと思います。実装者にはなかった発想や知識を提示することで、レビュイーの成長となります。 レビュワーの成長 先輩曰く「コードを読むことが最大のコーディング力&仕様理解の向上」。この頃実感できるようになりました。 私の場合、理解できないときはまず自分でいろいろ調べてみます。それでも理解できないときはその改修の方針や意図を理解できていないのか、または実装がおかしい可能性もあるので質問してみることに。とても勉強になります。 その他の人の共通理解の助長 私のチームではMRへのコメントを全てSlackに流すように設定しています。誰かが気になったり、良いと思ったりした部分が切り取られて流れるので、ここからの学びは相当大きいです。 まったく触ったことがない部分でも案外、「これ前にMRのコメントでチラッと見たな」となることもあります。 slackにコメントが流れるイメージ 諸説あるとは思いますが、私たちは以上のようなことをレビューの目的として意識しています。 これだけ意義深いとなれば「やってやろう」という気持ちにもなりますよね。 しかし、実際やるとなるとこれがまた大変で難しいのです。私も、自身の成長になるんだ!とは思いつつ、どうコメントすればいいんだ・・・と悩んでしまいがちです。適切なレビューをするにはどのようにしたら良いのでしょうか? どのような手順でレビューすると良いか 実際にレビューをするには、ただ漠然とコードを眺めていてもだめですよね。レビューをする際に意識すると良さそうな点を順序立てて整理してみます。 これを逆に考えれば、「レビュイーがどんなMRを書いていくと良いか」という話にもつながります。 まず改修の目的(before-afterで何を変えようとしているか)を確認 「何のための」「どう考えての」「どういう改修か」を確認する。 MRの概要欄や、リンクされている事前のやり取りなどを見ても不明瞭なら遠慮なく質問する。 そのMRでは(まだ)やらないことも確認する。不必要な指摘を避けられる。 [中上級者向け]レビュイーの仕様認識が妥当そうか検討してみる。 プロジェクト特有の知識、コモンセンスなどを頼りにする。 どのようにその仕様を確認したか(顧客と直接やり取りした、社内の〇〇さんに聞いた、定義書を確認した、自己判断、など)。 目的を実現する実装方針を自分なりに想像してみる どこで、どんな処理をすることになりそうか。考えた結果見当がつかないとしても考えないより良いはず。 複数案あって良い。 複雑なロジックがありそうかどうか。 [中上級者向け]見落としがちな例外ケースに思いを馳せてみる。 diffがあるファイルリストをみる diffがあるファイルリストを軽く眺め、ガッツリ見るべきファイルとそうでないファイルにあたりをつける。 複雑な処理がありそうな箇所を見る。 テストがあればそこから見た方が楽に実装を追える可能性がある。 2.の自分の想像と乖離があれば注意力をアップする。 実装を追う 感想ベースで、良いと思ったところにスタンプを挟むだけでも良い。 ここまで見てもらえているという、コードをレビューしてもらっている感が出てレビュイーも安心できる。 前述のようにslackに流れるので、レビュワー以外への共有にもなる。 MRへコメントすることへのハードルが下がって、レビューが活発になる。 読んでもよく分からないところは(調べつつ)率直に指摘する。折角だから教わる機会とするぐらいの気持ちでやる。 1.で確認した目的が過不足なく達成されているか自信がなければ気軽に聞いてみる。 1.で確認した目的と別の目的と思われる改修が含まれていたら指摘する。 2.で想像した方針と実際の実装方針が異なっていたら 自分・レビュイーいずれかに考慮漏れがないか、注意力を上げて見る。 自分の方針の方が良いかもしれないと思ったら、提案してみる。 方針ごとの優劣を考えてみる。どんな基準で方針を決めたか聞いてみるのも良い。 複雑なロジックほど注意してみる。テストが欲しい、コメントが欲しい、などは指摘しやすい。 テストケースの過不足やテストコード自体の可読性にも注意してみる。 [中上級者向け]類似の仕様や処理、定義値、命名などに心当たりがあれば軽く探して見比べてみる。 どうでしょうか。これだけ順序立てて考えるべき項目を整理しておけば、レビューの質も上がりそうな気がしてきませんか。もちろん、実際にこんなに細かく考えるのは難しいです。それでも、私はこれに即して考えることで以前より楽にレビューできるようになりました。 レビュワーが心がけたいこと 最後におまけとして、チーム全体でより良いコードレビューをしていくために、レビュワーが心がけるべきことをまとめておきます。これはチームや状況ごとに様々だと思います。あくまで私のチームで心がけていることです。 ある程度時間と労力をかける覚悟をもつ。 かけた労力以上の見返りはあるはず。 レビューをいっぱいすればチームも活発に、自身もそのアプリのマスターに。 レビュイーを待たせすぎない。 アサインされたら遅くとも1日以内、理想は通知され次第即見るくらいの気持ちで(gitlabの機能で、MRごとに自動で1人のレビュワーがアサインされます)。 レビュイーが指摘に対応する時間、も計算に入れる。 コメントしたらレスポンスが帰ってきてないか気にかける。 本当に見る人を選びそうなら見れそうな人の手動再アサインも視野に入れる。 レビュイーの労力を減らす。 可能なら改善方法のsuggestを出す。 簡単なコンフリクトなら指摘だけでなく解消してあげる。 とは言えレビュイーの労力を理由に指摘や質問を遠慮するべきではない。 ある程度はレビュイーを信頼する。 流石に動作確認はしてるはず、手元で自動テストは通してるはず、など。 もちろんレビュイーのレベルにもよる。 最悪大事に至らない段階で(CIの自動テスト等で)バグ検出が可能な面については多少注意力を下げても良いかもしれない。 MRへのコメントはレビュイーだけに向けたコメントではなく、全体への共有のためのコメントでもある。 最後に 先日チーム内でコードレビューについて話し合い、上記の内容が共有されたのですが、以前よりもレビューやコメントが活発になりました。また、実装者もよりレビューしてもらいやすいMR作りを心がけるようになりました。やはり、共通認識は大事ですね。 何より私自身がこれらを意識するようになり、人のMRをよく見るようになりました(とは言えレビューするのは難しいと感じる毎日ですが(笑))。 MRのコメント欄で議論が繰り広げられている図 チーム開発をする上で避けては通れないコードレビューですが、どうせやるなら楽しく、より成長できるものにしたいですね。それでは、皆さんも良いコードレビューライフを。
アバター
FORCIAアドベントカレンダー2020 10日目の記事です。 こんにちは。旅行プラットフォーム部エンジニアの乙村です。 フォルシアでは JavaScript を利用して開発することが多いのですが、最近は JavaScript の世界にも TypeScript という形で「型」の概念が広まり始めています。私が社会人エンジニアとして初めて触った言語は C++ という型付けがキッチリしている言語でしたが、学び始めた当初「インターフェース(抽象型)って何の役に立つのだろう?」と、ずっと疑問に思っていました。 インターフェース(抽象型)は何がうれしいのか、どういう場面で役に立つのかについて TypeScript を使って説明してみたいと思います。 はじめに オブジェクト指向(Object Oriented, 以下OO)の世界では当たり前のように使われるインターフェースですが、メソッドのシグニチャ定義がされているだけで実装がないため、最初は何が嬉しいのかよくわからないことが多いです。実際には依存関係の制御や実装の隠蔽などで、これがないと OO は成り立たないと言って良いくらい重要な概念です。 pure JavaScript には構文としての interface はありませんが、TypeScript は interface 構文をサポートしており、同じような恩恵が受けられます。本文中のサンプルコードは TypeScript で、Node.js 13.0.1、TypeScript 4.1.2 で動作確認をしています。 以降の話は、クラス(Class)による設計を中心とする言語(C++, C#, Java 等) におけるインターフェースの使い方の話ですので、TypeScript の文法としての interface を知りたい方は 公式ドキュメント を読むことをお勧めします。 オブジェクト指向言語における「インターフェース」とは 実装のないクラスだと思って下さい。メソッドの型だけ定義してありますが、そのメソッドを呼んだときの処理は何も定義されていません。プロパティを持つ場合もあります。 処理が定義されていないので、インスタンス化(new)できません。 C++、C#、Java などのオブジェクト指向言語ではインターフェースを作るための構文として "interface" が存在します。 クラスの多重継承は禁止されている言語が多いですが、インターフェースは多重継承が可能です。 インターフェースについて覚えるべきことは 2 つ 1. インターフェースを実装(継承)するクラスは、そのメソッドの処理を必ず実装する必要がある 以下のクラス図のように ClassA が InterfaceA を実装しているとします。 ClassA は method1() を実装していないと(コンパイル時やトランスパイル時の)型チェックでエラーになります。method2() のように ClassA 独自のメソッドがあっても問題ありません。InterfaceA で定義されている method1() が実装されてさえいれば、インターフェースを継承するクラスとしてのルールは守っています。 2. インターフェースを実装(継承)したクラスのインスタンスはインターフェース型の変数に代入できる ClassAのインスタンス(クラスを new した実体)を、InterfaceA の型の変数に代入できます。ただし、インターフェース型の変数からアクセスできるメソッドはインターフェースに定義されたもののみになります。 // インターフェース InterfaceA // 実装内容はなんでもよいが、このインターフェースを継承するクラスは // method1 を実装しないといけない interface InterfaceA { method1() : void; }; // InterfaceA を継承(実装) したクラス ClassA // method1 の実装があるので型チェックは通る class ClassA implements InterfaceA { // method1() をコメントアウトするとエラー method1() : void{ console.log("ClassA - method1()"); } method2() : void { console.log("ClassA - method2()") } }; let dummy = new InterfaceA(); // エラー(TS2693). インターフェイスは new できない let classTypedVar : ClassA = new ClassA(); // OK. クラスのインスタンスをクラスの型の変数に代入可能 let interfaceTypedVar : InterfaceA = new ClassA(); // OK. クラスのインスタンスをインターフェースの型の変数に代入可能 classTypedVar.method1(); // OK classTypedVar.method2(); // OK interfaceTypedVar.method1(); // OK interfaceTypedVar.method2(); // エラー(TS2551). インターフェースに定義のあるメソッドしか呼べない この「エラー」は TypeScript であればトランスパイル時に検知されます。コンパイル型の静的言語であればコンパイル時に指摘されます。エディタやIDEによっては、コーディング中に即時に指摘されます。 引数で渡す場合も代入と同様です。引数の型に InterfaceA が指定されている場合に、ClassA のインスタンスを渡すことができます。 // 関数の引数でも話は同じ. InterfaceA が要求されている箇所に ClassA のインスタンスを渡せる const someFunction = (interfaceTypedVar : InterfaceA) => { interfaceTypedVar.method1(); // OK interfaceTypedVar.method2(); // エラー(TS2551). インターフェースに定義のあるメソッドしか呼べない } someFunction(new ClassA()); // OK 注目すべきポイント ここで注目すべきなのは、someFunction の内部では ClassA の存在を知らなくてもそのメソッド(処理)を呼べるということです。つまり、InterfaceA を実装していさえすれば、ClassB でも ClassCでもそのインスタンスを渡すことができ、someFucntion はそれが実際は何のクラスのインスタンスかを知ることなく、そのメソッドを呼ぶことができます。 OOでよく聞くプラクティスの一つに「実装ではなく抽象に依存すべき」というものがあります。「抽象」を実現したものがインターフェース、「実装」はそのインターフェースを継承(実装)したクラスと考えてください。このプラクティスに従えば、someFunction の引数の型としては、ClassA ではなく InterfaceA のほうが好ましいということになります。 const someFunction = (classTypedVar: ClassA) => {...} // こうするとClassAに依存してしまう! const someFunction = (interfaceTypedVar : InterfaceA) => {...} // こうしたほうがいい で、一体何が嬉しいんでしょうか・・・?この例だとよくわかりません。 インターフェース活用の例 インターフェースが有用なケースを、Key-Value ストアクラス の作成を例に考えてみます。 1.よくあるパッケージ構成 アプリケーション用のクラスは app 、共通で使うクラスを infra というパッケージに置くことにします。共通して使うためのクラスとして、 KeyValueStore クラスを用意します。 共通部分を抜き出して複数のファイルから参照して利用するという、よく見る構成です。 KeyValueStore クラスはKey-Value 型のデータを管理するクラスですが、今回は永続化が不要という要件だったとして、メモリ上に Key-Value の情報を保持することにします。TypeScript での実装例は以下の通りです。 // infra/ keyValueStore.ts class KeyValueStore { // 属性 (データの保存先.ただのオブジェクトを辞書として使う) dictionary: { [key: string]: string; // TypeScript 記法. key も value も string 型の意味 }; // コンストラクタ constructor() { this.dictionary = {}; } // Key-Value の組み合わせで保存する save(key: string, val: string): void { this.dictionary[key] = val; } // key に結びついた Value を取得する load(key: string): string { return this.dictionary[key]; } // 保存されているデータを表示する showAll(): void { Object.keys(this.dictionary).map((key) => { console.log(`${key} : ${this.dictionary[key]}`); }); } } export default new KeyValueStore(); // インスタンスをエクスポートする KeyValueStore クラスのインスタンスをどう取得するかですが、ここではお手軽に、クラスを定義しているモジュール keyValueStore で、クラスではなくインスタンス自体を export しています。Node のように一度しかロードされないことが保証されているモジュールシステムを想定すると、このインスタンスはシングルトンになります。 ClientA と ClientB が それぞれ違うキーと値を保存します。違いは保存する Key-Valueだけです。 // app/ClientA.ts import keyValueStore from "../infra/keyValueStore"; export default class ClientA { public someMethod() : void { keyValueStore.save("A", "A desuyo."); } } // app/ClientB.ts import keyValueStore from "../infra/keyValueStore"; export default class ClientB { public someMethod() : void { keyValueStore.save("B", "B desuyo."); } } ちゃんと Key-Value が保存されているか main で確認します。 // main.ts import ClientA from "./app/ClientA" import ClientB from "./app/ClientB" import keyValueStore from "./infra/keyValueStore" new ClientA().someMethod(); new ClientB().someMethod(); keyValueStore.showAll(); // output // A : A desuyo. // B : B desuyo. このようにクラスを直接参照して使用する方法は、非常によくあるやり方です。サードパーティ製のライブラリをこのように使うことも多いはずです。 特に問題ないように見えますし、実際問題ないことも多いのですが、OO的にはこの構成では以下のような要求に応えにくいことが問題点とされています。 自作の Key-Value ストアではなくて世の中のイケてる Key-Value DBを使いたくなった 単体テスト時は決まった初期データでテストしたいから、ローカルファイルのデータを参照するようにしたい そもそも Key-Value ストアの実体はクライアントにとってはどうでもいい。実体の決定をなるべく遅らせたい また、言語によりますが、 keyValueStore のソースを少しでも変更すると、クライアントをすべてビルドしなおす必要がある という開発上のデメリット(ビルド時間の増大)が発生します。 これをインターフェースを使って解決します。 2.Key-Value ストアへの依存を切り離し、実装を切り替えやすくした構成 図にすると一気に複雑に見えますが・・・ IKeyValueStore という名前のインターフェースが登場しています。 この IKeyValueStore インターフェース を実装(継承)するクラスが、DBStore, OnMemoryStore, TextFileStore といった具象クラスです。これらはあくまで例ですが、それぞれ、DBへの保存、メモリ上への保存、テキストファイルへの保存をする Key-Value ストアを表しています。 ポイントは、クライアント(ClientA, ClientB)からは、インターフェース(IKeyValueStore) に依存があるだけで、Key-Valueストアの実処理が定義してあるクラス(DBStore, OnMemoryStore, TextFileStore)に依存(--->)がないということです。 クライアントは InfraFactory クラスの getStore() というメソッドを呼んでストアの実体を取得しますが、その返り値の型はインタフェースであり、実際にそれが何クラスのインスタンスなのかはわかりません。 実装を一部見ていきます。まず、インターフェース(IKeyValueStore ) には Key-Value ストアとして備えていてほしい機能(メソッド)を定義しておきます。 // infra/IKeyValueStore .ts export default interface IKeyValueStore { save(key: string, val: string): void; // Key-Value の保存 load(key: string): string; // Key-Value の読み出し showAll() : void; // 保存している値の出力(これは無くてもいい) } インターフェースを実装するクラスで、その実処理を定義します。 // infra/OnMemoryStore .ts import IKeyValueStore from "./IKeyValueStore"; // TypeScript の'implements' キーワードを使って、IKeyValueStore を // 継承したクラス OnMemoryStore を作る // IKeyValueStore で定義したメソッドを実装していないとエラーになる class OnMemoryStore implements IKeyValueStore { // (略) 「1.よくあるパッケージ構成」の KeyValueStore の実装と全く同じ } export default OnMemoryStore; // インスタンスではなく、クラスをエクスポート DBStore クラス、TextFileStore クラスも同様に IKeyValueStore インターフェースで定義されているメソッドを実装します。DBStore であれば Key-Value DB への値の読み書きがされるように実装し、TextFileStore であれば File への値の読み書きがされるように実装します。 クライアントから直接 OnMemoryStore などのクラスを new してしまうと具体的なクラスへの依存が発生することになり、後から切り替える場合にクライアント側の修正が必要になってしまいます。それを避けるため、インスタンスを作って返してくれるファクトリ(工場)の役割を持つクラスを用意します。 // infra/ InfraFactory.ts // key-value ストアインターフェース import IKeyValueStore from "./IKeyValueStore"; // インターフェースを実装(継承)したクラス群 import OnMemoryStore from "./OnMemoryStore"; import DBStore from "./DBStore"; import TextFileStore from "./TextFileStore"; // ストアの実体を返してくれるクラス class InfraFactory { private store : IKeyValueStore; // インターフェース型の属性 constructor(config : {storeType:String}) { if (config.storeType === "OnMemory") { // メモリ上にデータを保存するストア this.store = new OnMemoryStore(); } else if (config.storeType === "DB") { // DB上にデータを保存するストア this.store = new DBStore(); } else if (config.storeType === "Text"){ // テキストファイルにデータを保存するストア this.store = new TextFileStore(); } else { // default console.error("Wrong Configuration :", config) this.store = new OnMemoryStore(); } } getKeyValueStore() : IKeyValueStore { return this.store; } } // プログラム全体で、どの Key-Value ストアを使うのか、ここだけで変更が可能 // 環境変数や設定ファイルで変更することもできる const infraFactory = new InfraFactory({storeType:"OnMemory"}); export default infraFactory; このファクトリにより、あとで Key-Value ストアを変更したくなった場合に、このファクトリだけ修正すればよいことになります。 1.の問題点への回答 自作の Key-Value ストアではなくて世の中のイケてる Key-Value DBを使いたくなった 「イケてる Key-Value DB 」のAPIを save, load で呼び出すクラスを作成して getKeyValueStore() で返すようにする 単体テスト時は決まった初期データでテストしたいから、ローカルファイルのデータを参照するようにしたい テキストファイルを読み出して初期データとして持つクラスを作成して getKeyValueStore() で返すようにする そもそも Key-Value ストアの実体はクライアントにとってはどうでもいい。実体の決定をなるべく遅らせたい とりあえずデフォルトの OnMemoryStore を返すようにしておいて、決定次第返すクラスを差し替えればよい となります。 TypeScript のインターフェースはもっと柔軟 ここまでに説明した方法は、処理をクラスのメソッドとして表現する言語(JavaやC++)の場合によく行われるやり方ですが、JavaScript は関数そのものを引数や返り値として受け渡すことができる性質(第一級関数)をもつため、必ずしもクラスのインスタンスを返り値で返す必要はありません。TypeScript の場合はクラスだけでなく、データや関数にもインターフェースを設定することができるため、より柔軟でお手軽に実装への直接の依存を切り離すことができます。 https://www.typescriptlang.org/docs/handbook/interfaces.html (今回の例はリンク先の「Class Types」としてのインターフェースの使い方です) 実際には、関数やデータの取得箇所を一か所に集めることで、実装を一気に切り替えるという方法は pure JavaScipt でも可能です。ただし、interface がないために、クライアント側は返されるデータの型(プレーンなデータオブジェクト?クラスのインスタンス?関数?)や持っているプロパティについては何も保証されていない状態で使うことになります。 残る問題点は、パッケージ間の依存関係 これまではクラス間の依存関係に注目してきましたが、app パッケージと infra パッケージというパッケージ間で依存関係を見てみると、infra パッケージにあるインターフェース(IKeyValueStore )やファクトリ(InfraFactory )を app で使用しているため、app --> infra という依存関係ができています。これが問題になるケースがあります。 infra パッケージがないと、app の開発ができない(IKeyValueStore や InfraFactory の import 箇所でエラーになり、単独でビルドできない) infra でインターフェースを変更されると、app の修正が必要になる 呼び出し先ができていないと呼び出し元の開発ができないのは当たり前で、特に問題点ではないように思えます。 app パッケージと infra パッケージが別のチームにより開発されていると考えると、少し問題点がわかるかもしれません。infra チームがころころ interface のメソッドを変えてきたらどうでしょうか? infra 開発チーム 「Key-Value DB の中身を XXXDB から YYYDB に変更することにしたわ!」 「IKeyValueStore も変わるんで呼び出し元の修正よろしく!」 app 開発チーム 「(・・・なんのためのインターフェースやねん)」 ということが起きないように、 Interface を使って app が主導権を握れるようにします。依存関係を制御することで、app が infra に依存するのではなく、infra が app に依存するようにできます。 3.Key-Value ストアクラスだけでなく、それを含むパッケージへの依存を切り離した構成 これで infra パッケージが app パッケージに依存するようになりました。インターフェースとクラスの依存関係について補足すると、inferfaceA を classA が実装するとき、classA は intefaceA を知っていなければならない(importの必要がある)ため、依存性の向きは classA --> interfaceA となります。 まず、infra にあった IKeyValueStore インターフェースが app に移動しています。これで app はパッケージ内のインターフェースを参照すればよいことになり、infra のファイルを参照する必要がなくなります。これまでは、使われる側の infra が「うちはこういうAPIなのでそれに従って使ってください」という、変更の主導権が infra 側にある形(infra でAPIを変えられると app は従わざるをえない)でしたが、変更後は、使う側の app が「こういうインターフェースがほしい」と宣言して、infra がそれに従って実装するという主従関係の逆転が起きています。 ただし、InfraFactory クラスも infra にあったので、これを放置すると app と infra の相互依存になってしまい問題です。もう一段階抽象化して、Factory の実体を直接参照するのではなく、FactoryRepository から取得するようにします。 どれだけ抽象化を進めてもどこかでその実体を決定しなければなりません。それは通常、プログラムの開始位置 main に近い場所になります。プログラムの開始直後に、インターフェースから返る実体クラスを決定しておく必要があります。これを依存性の注入といいます。 新しく登場したインターフェース・クラスの実装をみてみます。 まずは、ファクトリのインターフェース IInfraFactory です。ストアを返すメソッド getStore() を実装する必要がある、ということを表現しています。 // app/IInfraFactory import IKeyValueStore from "./IKeyValueStore"; export default interface IInfraFactory { getStore() : IKeyValueStore; } ファクトリの実体クラス InfraFactory は IInfraFactory インターフェースを implements する以外は、前の例と変わっていません。すでに getStore() は実装済だからです。 // infra/InfraFactory // key-value ストアインターフェース // ★ インターフェースの場所が infra から app に代わっている import IKeyValueStore from "../app/IKeyValueStore"; import IInfraFactory from "../app/IInfraFactory"; // インターフェースを実装(継承)したクラス群 import OnMemoryStore from "./OnMemoryStore"; import DBStore from "./DBStore"; import TextFileStore from "./TextFileStore"; // ストアの実体を返してくれるクラス class InfraFactory implements IInfraFactory{ // ★ implements キーワードで「実装」を表す // 実装は前と同じ } export default InfraFactory; FactoryRepository はファクトリを登録するだけの場所です。 // app/factoryRepository.ts import IInfraFactory from "./IInfraFactory"; class FactoryRepository { private infraFactory : IInfraFactory; // ファクトリの実体は外部から注入する setInfraFactory(infraFactory : IInfraFactory) { this.infraFactory = infraFactory; } getInfraFactory() : IInfraFactory { return this.infraFactory; } } export default new FactoryRepository(); // インスタンスを返す. シングルトン. main でファクトリの実体を決定します。 // main.ts import InfraFactory from "./infra/InfraFactory"; import factoryRepository from "./app/factoryRepository" import ClientA from "./app/ClientA"; import ClientB from "./app/ClientB"; // 依存性の注入 // infra/InfraFactory を app の FactoryRepository に設定 factoryRepository.setInfraFactory(new InfraFactory({storeType:"OnMemory"})); new ClientA().someMethod(); new ClientB().someMethod(); factoryRepository.getInfraFactory().getStore().showAll(); // output // A : A desuyo. // B : B desuyo. クライアントは FactoryRepository からファクトリを取得して使用します。 // app/ClientA import factoryRepository from "./factoryRepository"; export default class ClientA { public someMethod() : void { factoryRepository.getInfraFactory().getStore().save("A", "A desuyo."); } } この状態で infra パッケージをまるごと削除しても、コンパイル/トランスパイルの型チェックでエラーが発生するのは main だけです。infra が開発を完了するまで、ダミーの infra パッケージを用意して main で依存性注入しておけば、app は infra を気にすることなく開発を進めることができます。 依存関係逆転の原則とクリーンアーキテクチャ 呼び出し元と呼び出し先の依存関係は「呼び出し元 → 呼び出し先」になるのが普通ですが、この例のように interface を使って、その方向を逆にすることができます。このテクニックは「依存関係逆転の原則」と呼ばれており、 「Clean Architecture 達人に学ぶソフトウェアの構造と設計」 に詳しく載っています。 この依存関係逆転の原則を徹底し、その中心点(依存の行きつく先)に Domain Driven Development (DDD) でいうドメインモデルを置いたものが 「クリーンアーキテクチャ」 になります。 まとめ インターフェースを使ってクライアントから実装を隠蔽することで、後からの変更をしやすくしたり、また、パッケージ間の依存関係のコントロールができるようになります。ただ、この例を見てもわかるとおり、同じ処理をするにもクラスやインターフェースの数が増えています。抽象化に伴う開発コストを払ってでも、変更容易性を確保したいかどうかは見極める必要があります。
アバター
FORCIAアドベントカレンダー2020 10日目の記事です。 こんにちは。旅行プラットフォーム部エンジニアの乙村です。 フォルシアでは JavaScript を利用して開発することが多いのですが、最近は JavaScript の世界にも TypeScript という形で「型」の概念が広まり始めています。私が社会人エンジニアとして初めて触った言語は C++ という型付けがキッチリしている言語でしたが、学び始めた当初「インターフェース(抽象型)って何の役に立つのだろう?」と、ずっと疑問に思っていました。 インターフェース(抽象型)は何がうれしいのか、どういう場面で役に立つのかについて Ty
アバター
FORCIAアドベントカレンダー2020 9日目の記事です。 事業開発部所属エンジニアの籏野です。 フォルシアではデータの取り込み・DBの構築といったバッチ処理についてフォルシア独自のツールを開発し、管理・実行していました。この独自ツールは、あらかじめ決められたフローを、設定を変えて実行するような作りになっており、タスク実行順の組み換えやアプリ独自の処理を追加するにはツール自体をアプリごとにカスタマイズする必要がありました。 このアプリごとのカスタマイズをより簡単に行えるよう、最近フォルシアでは「ecflow」というワークフローエンジンを導入し始めました。本記事では簡単なワークフローを作りながら、ecflowについて紹介したいと思います。 ecflowとは? ecflowは欧州中期予報センター(ECMWF)が開発したワークフローエンジンであり、天気予報のためのプログラム実行を担っています。 こちらの記事 でも紹介していますが、複雑な依存関係を持った大量のタスクを処理できるだけでなく、タスク間の待ち時間が短いことが特徴です。 フォルシアはフロントでの検索速度だけでなくバッチの速さにも重きを置いているため、このオーバーヘッドが短縮されることは大きなメリットでした。 ecflowでは独自形式のファイルを組み合わせることで、一つのワークフローを構築していきます。どのようなファイルを用意する必要があるのか、具体的に紹介していきたいと思います。 ※ecflowのインストールは 本家のドキュメント を参考にしてください。 タスクの定義 今回は単純に「Hello!!」と出力するだけのワークフローを作ってみます。 ファイル構成は以下のようになります。 ├── ecf_files │ └── echo.ecf ├── ecf_include │ ├── head.h │ └── tail.h └── test.py .ecfファイル ecflowで実行される処理は.ecfファイルに記載します。 例えば、今回作成した echo.ecf は以下のようになっており、任意の単語を出力できるようになっています。 %include <head.h> echo "%WORD%" %include <tail.h> 変数の埋め込み .ecfファイルでは変数名を % で囲うことで任意の文字列を埋め込むことができます。例に挙げた echo.ecf では %WORD% 部分に任意の文字列を埋め込むことで、出力する文字列を設定できるようになっています。 include %include <{{ file_name }}> と記載することで、任意の処理を各.ecfファイルに追加することができます。各タスクで共通に実行されるべき処理は別のファイルに切り出すことができるのです。 今回はecflowに対して、実行開始/終了を知らせる処理を head.h と tail.h に切り出しています。 【head.h】 #!/bin/bash set -eux set -o pipefail # ecflowとのやり取りに必要な変数 export ECF_PORT=%ECF_PORT% export ECF_HOST=%ECF_HOST% export ECF_NAME=%ECF_NAME% export ECF_PASS=%ECF_PASS% export ECF_TRYNO=%ECF_TRYNO% export ECF_RID=$$ export PATH=/usr/local/ecflow-%ECF_VERSION%/bin:$PATH # ecflowにタスク開始を知らせる。 ecflow_client --init=$$ # タスク中でエラーが発生した場合に実行する。 ERROR() { set +e wait ecflow_client --abort=trap trap 0 exit 0 } trap ERROR 0 trap '{ echo "Killed by a signal"; ERROR ; }' 1 2 3 4 5 6 7 8 10 12 13 15 【tail.h】 wait # ecflowにタスク終了を知らせる。 ecflow_client --complete trap 0 exit 0 ワークフローの構築 用意した.ecfファイルを組み合わせて、一つのワークフローを構成する必要があります。そのためには「それぞれの.ecfファイルをどのような順番で実行するか」を記載したファイルを用意し、ecflowに読み込ませる必要があります。 しかし、フローが複雑になってくると、この設定を0から用意するのがかなり難しくなってきます。そこでecflowが用意しているPythonライブラリを利用します。 【test.py】 import os from ecflow import Defs, Suite, Family, Task, Edit THIS_DIR = os.path.dirname(os.path.abspath(__file__)) ECF_DIR = os.path.join(THIS_DIR, "ecf_files") INCLUDE_DIR = os.path.join(THIS_DIR, "ecf_include") print("Creating suite definition") # Suite: 一つのワークフローを示す suite = Suite( "test", Edit( ECF_HOME=THIS_DIR, ECF_FILES=ECF_DIR, # ecfファイルを置いたディレクトリ ECF_INCLUDE=INCLUDE_DIR #includeするファイルを置いたディレクトリ ) ) # Family: 複数のタスクやFamilyをまとめたもの # 任意の名前を付ける(今回は"hello") hello = Family("hello") # Task: ecfファイルを読み込み処理を実行する hello.add_task( Task( "echo", # ecfファイルを指定 Edit( WORD="Hello!!" # %WORD%に埋め込む文字列 ) ) ) suite.add_family(hello) defs = Defs() defs.add_suite(suite) print("Checking job creation: .ecf -> .job0") print(defs.check_job_creation()) print("Saving definition to file 'test.def'") defs.save_as_defs("test.def") 上記を実行すると以下のように新しいファイルが生成されます。 ├── ecf_files │ └── echo.ecf ├── ecf_include │ ├── head.h │ └── tail.h ├── test │ └── hello │ └── echo.job0 ★NEW ├── test.def ★NEW └── test.py .defファイル 新しく生成された test.def が、先に紹介した「それぞれの.ecfファイルをどのような順番で実行するか」を設定したファイルになります。このファイルをecflowが読み込むことでワークフローが構築されます。 #5.1.0 suite test edit ECF_HOME '/home/forcia/ecflow_test' edit ECF_FILES '/home/forcia/ecflow_test/ecf_files' edit ECF_INCLUDE '/home/forcia/ecflow_test/ecf_include' family hello task echo edit WORD 'Hello!!' endfamily endsuite # enddef .jobファイル では、 echo.job0 とは何なのでしょうか?ファイルの中身は以下のようになっています。 #!/bin/bash set -eux set -o pipefail # ecflowとのやり取りに必要な変数 export ECF_PORT=3141 export ECF_HOST=localhost export ECF_NAME=/test/hello/echo export ECF_PASS=XXXXXX export ECF_TRYNO=0 export ECF_RID=$$ export PATH=/usr/local/ecflow-5.1.0/bin:$PATH # ecflowにタスク開始を知らせる。 ecflow_client --init=$$ # タスク中でエラーが発生した場合に実行する。 ERROR() { set +e wait ecflow_client --abort=trap trap 0 exit 0 } trap ERROR 0 trap '{ echo "Killed by a signal"; ERROR ; }' 1 2 3 4 5 6 7 8 10 12 13 15 echo "Hello!!" wait # ecflowにタスク終了を知らせる。 ecflow_client --complete trap 0 exit 0 こちらを見てわかるように、 echo.ecf では %include や %WORD% で記載されていた部分が展開されて通常のbashファイルが生成されています。 ecflowでは各変数を展開して生成されたファイルを、defファイルで指定した順番で実行することでワークフローを実行しているのです。 ワークフローの実行 生成されたdefファイルをecflowに読み込ませて実行してみます。 $ ecflow_client --load=test.def # 設定の読み込み $ ecflow_client --begin=test 今回の実行ログは ./test/hello/echo.1 に出力されます。 このファイルを確認すると Hello!! と出力されており、無事タスクが実行されたことがわかります。 ...略... + echo 'Hello!!' Hello!! ...略... 最後に 今回紹介したように、ecflowではワークフロー内のタスクが一つの実行ファイルとして生成されます。そのため、jobファイルを見ればタスク実行時に何が起きているのかが一発でわかり、デバッグ等もやりやすいです。 また、スクリプトに落とし込める処理は何でも実行できるので、タスク生成の自由度も高いのではないかと感じています。 今回の内容以外にもGUIによるワークフローの管理、トリガー設定、タスク失敗時の後処理・・・など、ecflowでできることはたくさんあります。それらについても、今後機会があれば紹介したいと思います。
アバター
0までのjsonb
FORCIAアドベントカレンダー2020 8日目の記事です。 フォルシアで旅行横断検索を主にエンジニアリングをしています。相澤といいます。 普段は主にPostgreSQLを使ってデータ処理の高速化とホテル名寄せに苦戦する日々を送っています。 少し前に PostgreSQL12 が登場しましたね! フォルシアで働く私としては検索が各種インデックスの性能改善がどの程度の物なのかが一番気になるところなのですが、合わせて JSON Pathに対応 というのが気になりました。 実はいままでjson(jsonb)型データをあまり扱ったことがなかったので(PostgreSQLに他言語のデータ型を持ち込む理由が分からず、積極的に知りにいく機会がなかったので)、これを機に勉強したいと思います。 jsonbを1から学び始める前、スタート地点に立つまでの調査・確認ということで、「0までのjsonb」というタイトルでお送りします! 基本知識編(jsonbとは) https://www.postgresql.jp/node/320 そもそもJSONというのはJavaScript のデータフォーマットです。 PostgreSQLでも9.2系からjson型がサポートされています。JavaScriptのJSONと違う点は、サーバ符号化方式がUTF-8でなければならない点となっています(公式ドキュメントには 厳密に仕様を満たすJSONに対応することができません と記載されていますが、厳密でないデータ形式に何の意味がありましょうか)。text型にjsonで文字を書くのと違い、json型になっている点で優っていますし、いくつかの関数が使用できます。 ※ 以後、区別のためPostgreSQLのjsonのことのみを小文字でjsonと記載します。 余談では、ありますが弊社はかつてPostgreSQLにjsonが実装される前に、json型を独自定義し操作のための関数ライブラリを作成していました。 JSONはシンプルで可読性が高く、何かと便利なので、webアプリを作成する上であると便利なケースが多々あります。 公式文章によれば、PostgreSQL9.4からはjsonbという形式が現れました。これはjson型とは以下の点で異なるようです。 jsonデータ型は入力テキストの正確なコピーで格納し、処理関数を実行するたびに再解析する必要があります。 jsonbデータ型では、分解されたバイナリ形式で格納されます。 格納するときには変換のオーバーヘッドのため少し遅くなりますが、処理するときには、全く再解析が必要とされないので大幅に高速化されます。 また jsonb型の重要な利点はインデックスをサポートしていることです。 json型は入力値のコピーを格納しているので、意味的に重要でないトークン間の空白だけでなく、JSONオブジェクト内のキーの順序も維持します。 また、JSONオブジェクト内に同じキーと値が複数含まれていてもすべてのキー/値のペアが保持されます。(この処理関数は最後の値1つを処理させるようすれば済みます。) これとは対照的に、jsonbは空白を保持しません。オブジェクトキーの順序を保持せず、重複したオブジェクトキーを保持しません。重複キーを入力で指定された場合は、最後の値が保持されます。 PostgreSQL 12.4文書 より引用 JSONと違い、空白とkeyの重複が許されていないようです。とはいえ、まともにJSONを運用する場合、valueかkeyがなかったり空白だったり揺れたりするとバグの原因になりやすいですし、keyの重複ももってのほかですので、ほとんどのアプリケーションではjsonbでまったく問題がないのではないでしょうか。 そしてjsonbで便利な点はvalueのみの全文検索ができる点、そして高速な検索を実装するにあたって重要なことですがインデックスが張れるという点です。jsonbはGINインデックスを使用して、keyとvalueのペアの検索と @> 演算子(左のJSON値はトップレベルにおいて右のJSONパスまたは値を包含するか)をサポートするインデックスを作成することができます。 そしてPostgreSQL12からはjsonpath型というものが実装されました。これによって、jsonbの特定のpathにアクセスしやすくなり、特定の要素が存在するかどうかや一定以上の値かどうかをフィルターできるようになりました。jsonpathの注意点としては、大文字小文字の区別があることと、配列インデックスが1から始まる点で、このあたりはJavaScriptに浸食されてちょっと嫌な感じですね。 ここまで充実しているのであれば、あとは使ってみて理解すれば強い選択肢になりそうです! 基本実践編 さて、簡単にではありますが、これらの機能を使ってみたいと思います。 DB作成 まずUTF-8でDBを作成します。 createdb -E utf-8 jsontest 文字列からのjson型、jsonb型キャスト jsonやjsonbはtextからキャストすることが出来ます。 # textをキャストできる select '{"index":1,"value":"a"}'::json, '{"index":1,"value":"a"}'::jsonb; json | jsonb -------------------------+---------------------------- {"index":1,"value":"a"} | {"index": 1, "value": "a"} # textがjson形式でないときは以下のようなエラーになる select '{"index":1:"value":"a"}'::json; ERROR: invalid input syntax for type json LINE 1: select '{"index":1:"value":"a"}'::json; ^ DETAIL: Expected "," or "}", but found ":". CONTEXT: JSON data, line 1: {"index":1:... 外部ファイルの使用 外部ファイルをCOPYしてjson型jsonb型データを作成することもできます。 COPYの際にはダブルクオーテーションとカンマがカラム中に必須になることから、CSVモードにせずtsvで取り込むのがよさそうです。 外部ファイル(タブ区切りtsv) 1 {"idx" : 1, "value" : "a a"} 2 {"idx" : 2, "value" : "b a"} # 外部ファイルを使用できる。 drop table if exists testjson; create table testjson ( idx int ,json_column json ); copy testjson from '/path/to/json.tsv' delimiter E'\t'; drop table if exists testjsonb; create table testjsonb ( idx int ,jsonb_column jsonb ); copy testjsonb from '/path/to/json.tsv' delimiter E'\t'; まずはjson型, jsonb型のカラムを持つテーブルを作成してみます。 -- 元テーブルの作成 DROP TABLE IF EXISTS testtext; CREATE TABLE testtext AS ( SELECT idx, concat('{"idx":',idx::text,',"value1":"', substring(md5(idx::text),1,2), '","value2":"', md5(idx::text),'"}') AS text_column FROM ( SELECT generate_series(1,1000000) AS idx )s ); ANALYZE testtext; -- jsonテーブルの作成 DROP TABLE IF EXISTS testjson; CREATE TABLE testjson AS ( SELECT idx ,text_column::json AS json_colmun FROM testtext ); ANALYZE testjson; -- jsonbテーブルの作成 DROP TABLE IF EXISTS testjsonb; CREATE TABLE testjsonb AS ( SELECT idx ,text_column::jsonb AS jsonb_colmun FROM testtext ); ANALYZE testjsonb; データサイズはjsonb型が大きくなっていることがわかります。 SELECT relname ,(relpages / 128) AS mbytes FROM pg_class WHERE relname like 'test%' ORDER BY relname; relname | mbytes -----------+-------- testjson | 104 testjsonb | 120 testtext | 104 (3 rows) 簡単な操作の確認 特定のパスの値を取り出す -> int でjson配列要素、 -> text でjsonオブジェクトフィールドの取り出し、 #> path でパスにあるJSONオブジェクトを取得。いずれの場合も > を >> と書くとオブジェクトではなくtextにキャストされます。 select '[{"a":"foo"},{"b":"bar"},{"c":"baz"}]'::json->2; ?column? ------------- {"c":"baz"} (1 row) select '{"a": {"b":"foo"}}'::json->'a'; ?column? ------------- {"b":"foo"} (1 row) select '{"a": {"b":{"c": "foo"}}}'::json#>'{a,b}'; ?column? -------------- {"c": "foo"} (1 row) # -> はjsonbのままなので合わせ技もできます select '[{"a":"foo"},{"b":"bar"},{"c":"baz"}]'::json->2->'c'; ?column? ---------- "baz" (1 row) # 存在しないpathは空になっています(エラーにはなりません) select '{"a":{"b":{"c":"d"}}}'::jsonb->'a'->'c'; ?column? ---------- (1 row) パスの追加と削除 追加は || で 削除は - です。(シンプルですね!) select '{"a":"b"}'::jsonb || '{"c":"d"}'::jsonb; ?column? ---------------------- {"a": "b", "c": "d"} (1 row) select '{"a":"b","c":"d"}'::jsonb - 'a'; ?column? ------------ {"c": "d"} (1 row) なお、追加の際に同じkeyをとることができないので右辺が優先されるようです。 select '{"a":"b"}'::jsonb || '{"a":"c"}'::jsonb; ?column? ------------ {"a": "c"} (1 row) トップレベルキーの存在チェック ? text textというトップレベルキーが存在するかどうか。 select '{"a":"b","c":"d"}'::jsonb?'a'; ?column? ---------- t (1 row) select '{"a":"b","c":"d"}'::jsonb?'b'; ?column? ---------- f (1 row) select '{"a":"b","c":"d"}'::jsonb?'c'; ?column? ---------- t (1 row) ?| array text 配列中のtextのトップレベルキーが一つでも存在するかどうか。 select '{"a":"b","c":"d"}'::jsonb?|array['b','c']; ?column? ---------- t (1 row) ?& array text 配列中のtextのトップレベルキーがすべて存在するかどうか。 select '{"a":"b","c":"d"}'::jsonb?&array['b','c']; ?column? ---------- f (1 row) select '{"a":"b","c":"d"}'::jsonb?&array['a','c']; ?column? ---------- t (1 row) pathとvalueの組み合わせを問い合わせる 前述の演算子 -> あるいは #> と ? を組み合わせます。 select '{"a":{"b":{"c":"d"}}}'::jsonb#>'{"a","b","c"}' ? 'd'; ?column? ---------- t (1 row) トップレベルにおいて右辺のjsonbを含むかどうか @> を使用します。 select '{"a":{"b":{"c":"d"}}}'::jsonb @> '{"c":"d"}'::jsonb; ?column? ---------- f (1 row) select '{"a":{"b":{"c":"d"}}}'::jsonb->'a'->'b' @> '{"c":"d"}'::jsonb; ?column? ---------- t (1 row) インデックス付与(高速化) 公式ドキュメントによると「トップレベルキーの存在チェック」「keyとvalueの組み合わせ」「右辺のjsonbを含むかどうか」でindexが有効に活用できるようです。それぞれ確認してみましょう。 トップレベルキーの存在チェック(すべての場合ヒットする場合と一部のみヒットする場合) indexなしで検索を行う場合。 EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE (jsonb_colmun) ? 'value1' ; QUERY PLAN -------------------------------------------------------------------------------------------------------------------------------- Gather (cost=1000.00..21693.33 rows=1000 width=92) (actual time=0.121..235.050 rows=1000000 loops=1) Workers Planned: 2 Workers Launched: 2 -> Parallel Seq Scan on testjsonb (cost=0.00..20593.33 rows=417 width=92) (actual time=0.015..121.201 rows=333333 loops=3) Filter: (jsonb_colmun ? 'value1'::text) Planning Time: 0.068 ms Execution Time: 289.437 ms (7 rows) UPDATE testjsonb SET jsonb_colmun = jsonb_colmun || '{"value3":"1"}'::jsonb WHERE (jsonb_colmun->'idx')::int4 % 100 = 0; -- 1%のカラムにキーを足す ANALYZE testjsonb; EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE (jsonb_colmun) ? 'value3' ; QUERY PLAN ------------------------------------------------------------------------------------------------------------------------------- Gather (cost=1000.00..21866.33 rows=1000 width=93) (actual time=36.314..123.155 rows=10000 loops=1) Workers Planned: 2 Workers Launched: 2 -> Parallel Seq Scan on testjsonb (cost=0.00..20766.33 rows=417 width=93) (actual time=33.251..118.628 rows=3333 loops=3) Filter: (jsonb_colmun ? 'value3'::text) Rows Removed by Filter: 330000 Planning Time: 0.074 ms Execution Time: 123.707 ms (8 rows) 単純にjsonbカラムにGINを張った場合は、トップレベルキーの存在チェックが高速化します。 しかしながら必ずindexが使用されてしまい、すべてのレコードが持っているvalue1というカラムに対して存在チェックを行ってもindexが使用されます。 以下の2つの理由で検索が遅くなるようです。 indexを使用している分IOが発生しているため workerが分岐しないため DROP INDEX IF EXISTS idxgin; CREATE INDEX idxgin ON testjsonb USING GIN (jsonb_colmun); ANALYZE testjsonb; EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE (jsonb_colmun) ? 'value1' ; QUERY PLAN ---------------------------------------------------------------------------------------------------------------------------- Bitmap Heap Scan on testjsonb (cost=27.75..3186.69 rows=1000 width=92) (actual time=79.083..441.709 rows=1000000 loops=1) Recheck Cond: (jsonb_colmun ? 'value1'::text) Heap Blocks: exact=15385 -> Bitmap Index Scan on idxgin (cost=0.00..27.50 rows=1000 width=0) (actual time=75.965..75.966 rows=1000000 loops=1) Index Cond: (jsonb_colmun ? 'value1'::text) Planning Time: 0.117 ms Execution Time: 493.719 ms <-- 遅くなっています (7 rows) ANALYZE testjsonb; EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE (jsonb_colmun) ? 'value3' ; QUERY PLAN ----------------------------------------------------------------------------------------------------------------------- Bitmap Heap Scan on testjsonb (cost=27.75..3190.76 rows=1000 width=93) (actual time=0.764..4.349 rows=10000 loops=1) Recheck Cond: (jsonb_colmun ? 'value3'::text) Heap Blocks: exact=174 -> Bitmap Index Scan on idxgin (cost=0.00..27.50 rows=1000 width=0) (actual time=0.733..0.733 rows=10000 loops=1) Index Cond: (jsonb_colmun ? 'value3'::text) Planning Time: 0.138 ms Execution Time: 4.883 ms (7 rows) keyとvalueの組み合わせ indexなしで検索を行う場合。 EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE (jsonb_colmun->'value1') ? '00'; QUERY PLAN ------------------------------------------------------------------------------------------------------------------------------ Gather (cost=1000.00..22735.00 rows=1000 width=92) (actual time=0.432..210.369 rows=3878 loops=1) Workers Planned: 2 Workers Launched: 2 -> Parallel Seq Scan on testjsonb (cost=0.00..21635.00 rows=417 width=92) (actual time=0.371..205.503 rows=1293 loops=3) Filter: ((jsonb_colmun -> 'value1'::text) ? '00'::text) Rows Removed by Filter: 332041 Planning Time: 0.068 ms Execution Time: 210.628 ms (8 rows) GINを以下のように使用することでkeyとvalueの組み合わせが高速化します。 DROP INDEX IF EXISTS idxgintag; CREATE INDEX idxgintag ON testjsonb USING GIN ((jsonb_colmun->'value1')); ANALYZE testjsonb; EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE (jsonb_colmun->'value1') ? '00'; QUERY PLAN ------------------------------------------------------------------------------------------------------------------------- Bitmap Heap Scan on testjsonb (cost=19.75..3181.19 rows=1000 width=92) (actual time=2.741..23.530 rows=3878 loops=1) Recheck Cond: ((jsonb_colmun -> 'value1'::text) ? '00'::text) Heap Blocks: exact=3438 -> Bitmap Index Scan on idxgintag (cost=0.00..19.50 rows=1000 width=0) (actual time=1.176..1.176 rows=3878 loops=1) Index Cond: ((jsonb_colmun -> 'value1'::text) ? '00'::text) Planning Time: 0.150 ms Execution Time: 23.939 ms (7 rows) なお、確認してみたのですがjsonの内容をtext型で返させる ->> という演算子を使用した場合には、indexは使用されないようです(当たり前といえば当たり前ですが)。 SELECT * FROM testjsonb WHERE (jsonb_colmun->>'value1') = '00'; 右辺のjsonbを含むかどうか indexなしで検索を行う場合。 EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE jsonb_colmun @> '{"value1":"00"}'::jsonb; QUERY PLAN ------------------------------------------------------------------------------------------------------------------------------ Gather (cost=1000.00..21693.33 rows=1000 width=92) (actual time=0.368..155.156 rows=3878 loops=1) Workers Planned: 2 Workers Launched: 2 -> Parallel Seq Scan on testjsonb (cost=0.00..20593.33 rows=417 width=92) (actual time=0.184..151.480 rows=1293 loops=3) Filter: (jsonb_colmun @> '{"value1": "00"}'::jsonb) Rows Removed by Filter: 332041 Planning Time: 0.034 ms Execution Time: 155.405 ms (8 rows) jsonb_path_opsを選択してGINを貼ると @> 検索が高速化します。 DROP INDEX IF EXISTS idxginp; CREATE INDEX idxginp ON testjsonb USING GIN (jsonb_colmun jsonb_path_ops); ANALYZE testjsonb; EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE jsonb_colmun @> '{"value1":"00"}'::jsonb; QUERY PLAN ----------------------------------------------------------------------------------------------------------------------- Bitmap Heap Scan on testjsonb (cost=27.75..3186.69 rows=1000 width=92) (actual time=1.390..6.162 rows=3878 loops=1) Recheck Cond: (jsonb_colmun @> '{"value1": "00"}'::jsonb) Heap Blocks: exact=3438 -> Bitmap Index Scan on idxginp (cost=0.00..27.50 rows=1000 width=0) (actual time=0.606..0.606 rows=3878 loops=1) Index Cond: (jsonb_colmun @> '{"value1": "00"}'::jsonb) Planning Time: 0.140 ms Execution Time: 6.437 ms (7 rows) キーワード検索 jsonbの使い方・・・というわけではありませんが、一部のvalueに部分一致検索をしたいときは、以下のようにしてpg_bigm indexを使用することができます。 DROP EXTENSION IF EXISTS pg_bigm CASCADE; DROP INDEX IF EXISTS idx_pg_bigm; CREATE EXTENSION pg_bigm; CREATE INDEX idx_pg_bigm ON testjsonb USING gin (((jsonb_colmun->>'value2')) gin_bigm_ops); ANALYZE testjsonb; EXPLAIN ANALYZE SELECT * FROM testjsonb WHERE (jsonb_colmun->>'value2') like '%abcd%'; QUERY PLAN ------------------------------------------------------------------------------------------------------------------------------ Bitmap Heap Scan on testjsonb (cost=126.00..13407.36 rows=8000 width=92) (actual time=21.786..39.752 rows=424 loops=1) Recheck Cond: ((jsonb_colmun ->> 'value2'::text) ~~ '%abcd%'::text) Rows Removed by Index Recheck: 2311 Heap Blocks: exact=2488 -> Bitmap Index Scan on idx_pg_bigm (cost=0.00..124.00 rows=8000 width=0) (actual time=21.103..21.103 rows=2735 loops=1) Index Cond: ((jsonb_colmun ->> 'value2'::text) ~~ '%abcd%'::text) Planning Time: 0.251 ms Execution Time: 39.804 ms (8 rows) それぞれのインデックスサイズは以下の通りです。 SELECT indexname ,pg_relation_size(indexname::regclass)/(1024*1024) as mbyte -- データサイズをmbyte単位で表示 FROM pg_indexes WHERE schemaname = 'public' and indexname like 'idx%'; indexname | mbyte -------------+------- idxgin | 139 idxgintag | 2 idxginp | 69 idx_pg_bigm | 38 (4 rows) インデックスサイズはケースバイケースなのであまりあてにはなりませんが、ご参考までに。 (今回は英数字の乱数のカラムを使っていますが、bigmインデックスを貼る対象として日本語を使うと2文字列の組み合わせが増大してしまいますし、jsonの構造が複雑になるほど他のindexも増加していきます。) jsonpath演算子 jsonpath演算子はjsonのオブジェクトフィールドにアクセスする記法の一つです。 これを使って、簡単なフィルター式(比較演算子、論理演算子、存在のチェック、パターンマッチ など)を経て得られる値や配列に、簡単な処理(数学的処理、keyvalue)を加えたものを取得できます。フィルタリングにはindexが適用されます。 記法はややJavaScript寄りです。等価演算子でフィルタリングしてみます。等価演算子は == となっていたりします (なお厳密等価演算子 === は使用できません) 。配列はインデックスも1から始まります。 また、以下の例の場合where句を書いていないですが、すべてのフィルター式に偽値を返すレコードは落ちてしまいます。 SELECT idx, jsonb_path_query(jsonb_colmun, '$[*]?(@.value1 == "00").value2') -- トップレベルキーvalue1 == "00" のレコードのvalue2を取得したい FROM testjsonb ORDER BY idx LIMIT 5 ; idx | jsonb_path_query ------+------------------------------------ 168 | "006f52e9102a8d3be2fe5614f42ba989" 363 | "00411460f7c92d2124a67ea0f4cb5f85" 381 | "00ec53c4682d36f5c4359f4ae7bd7ba1" 610 | "00ac8ed3b4327bdd4ebbebcb2ba10a00" 1164 | "00e26af6ac3b1c1c49d7c3d79c60d000" (5 rows) SELECT idx ,jsonb_path_query(jsonb_colmun, '$[*]?(@.value1 == "00").value2') -- value1 == "00" のレコードのvalue2を取得したい ,jsonb_path_query(jsonb_colmun, '$[*]?(@.value1 == "01").value2') -- value1 == "01" のレコードのvalue2を取得したい FROM testjsonb ORDER BY idx LIMIT 5 ; idx | jsonb_path_query | jsonb_path_query -----+------------------------------------+------------------------------------ 138 | | "013d407166ec4fa56eb1e1f8cbe183b9" 168 | "006f52e9102a8d3be2fe5614f42ba989" | 236 | | "01161aaa0b6d1345dd8fe4e481144d84" 348 | | "01386bd6d8e091c2ab4c7c7de644d37b" 363 | "00411460f7c92d2124a67ea0f4cb5f85" | (5 rows) jsonbの基本的な操作はここまでです。 jsonpath式は若干filiter式に罠がありますが、基本的な操作が出揃っているようですね。 使用についての展望 jsonオブジェクトや配列に何でもデータを突っ込むのは、SQLのアンチパターンにほかなりません。 ここに書いてあることだけでもキャッチアップするのは面倒ですし、いろいろと罠があることが見えてきています。 CSVモードで取り込む際には、工夫が要りますし、工夫がいること自体がバグの温床のように思えます。 jsonpathも、自分が担当しているアプリに新しい担当者が付いた時など、すんなりと理解しミスを犯さず運用してもらうのは難しいと思いました。 ただ、以下のような条件を兼ねそろえている場合は有効に使えるのではないかと思いました。 SQL上でカラムからjsonを組み立て、webアプリで使用する 紹介したパターンにありますがcsvやtxtを取り込んでjsonbを作る場合、カラムの型チェックや妥当性の評価、余分な文字の排除などが効きません。まずtsvやcsvを取り込み、アプリで使用する形に組み立てる分にはいいのではないでしょうか 私自身の経験では、jsonを返すはずのAPIの返却値を取り込んでDBに格納しようとした際に、実際には返却値がjsonになっておらず取り込みに失敗した経験もあります 単純なjsonを出力する(言い換えれば以下のようなアンチパターンがありそうです) 外部ファイルのcsv, tsvをjsonb型としてCOPYコマンドで取り込む 人がチェックすることが困難になり、ミスも生まれやすくなりそう なんでもかんでもjsonにしてしまう 複雑なjson構造にしてしまう 例えばですが、私が担当している宿横断検索アプリなどの場合、宿泊施設データ管理上のイメージ画像(urlと画像タイプの2つの情報があり、数は施設ごとにまちまち)をjsonbとして持つのはいいと思いました。 pathとvalueの組み合わせでしか操作することがなく、パターンマッチなどもせず、シンプルです。 こういった画像用のテーブルなどを用意するのが不要だと感じる際には良いと思います。 { imageNum: 4, images [ { "url" : "https://domain.co.jp/image/hotelXXXX/gaikan.gif", "type" : "外観" },{ "url" : "https://domain.co.jp/image/hotelXXXX/huro.gif", "type" : "浴室" },{ "url" : "https://domain.co.jp/image/hotelXXXX/heya1.gif", "type" : "室内" },{ "url" : "https://domain.co.jp/image/hotelXXXX/dinner.gif", "type" : "食事" } ] } こういった選択肢は持っていること自体が強いので、乱用せずに使える範囲で使用していきたいですね!
アバター
FORCIAアドベントカレンダー2020 7日目の記事です。 検索プラットフォーム部エンジニアの吉成です。 普段の業務では理化学機器などを取り扱うECサイトを担当しています。 フォルシアは以前より、旅行系サイトやECサイトなどにおける、膨大かつ複雑なデータの高速検索を得意分野としてきました。 さらに近年では、検索に関連する周辺技術にも注目して開発に取り組んでいます。 人間が話したり書いたりする「ことば」をコンピュータに処理させる「自然言語処理」もそのうちの1つです。 フォルシアアドベントカレンダー2020 7日目である今日は、ECサイトを担当するエンジニアである私が、ECサイトにおける自然言語処理で取り組まれているタスク、特にカテゴリ予測と口コミ分析について語ります。 自然言語処理に馴染みのない方にもわかるような説明を心がけますので、現在・未来に自然言語処理がECサイトへともたらすものを考えながら、自然言語処理に興味を持っていただければと思います。ECサイトにおける自然言語処理に興味を持っていただけた際には、この記事が「どのようなキーワードで調べれば良いか」の指針になれば幸いです。 自然言語処理に馴染みのある方には、これまでに身につけてきた技術がECサイトというフィールドでどんなふうに役立てられるのかを考える参考にしていただきたいと思います。 ECサイトと自然言語処理 ECサイトには、たくさんの「ことば」が使われています。商品名や商品の説明文、商品を分類するためのカテゴリやタグに加えて、ユーザが入力する検索キーワードや実際に商品を購入した人が書く口コミなど、「何のために書かれたのか」も「誰によって書かれたのか」も様々です。 そのため、ECサイトは様々な自然言語処理的な課題を抱えています。 今回はその中から、 カテゴリ予測 と 口コミ分析 を取り上げたいと思います。 カテゴリ予測 ECサイトにおけるカテゴリ予測とは、その名の通り与えられた商品やサービスのカテゴリを予測する問題のことです。 カテゴリの代わりにタグなどを予測することもあります。 ECサイトで取り扱われる商品数が増加する中で、今までは人手だったカテゴリやタグの付与を自動化する需要も増してきています。 予測する方法には様々なものが考えられますが、既にカテゴリがわかっている商品のデータを大量に用意し、商品名や画像・説明文といった商品情報から予測するモデルを学習することが多いです。 特に、分類に使うのが商品の名前や説明文といった自然言語によるデータの場合、カテゴリ予測は文書分類タスクの一種と言えます。 一般的な分類問題との違いは、多くの場合、カテゴリが図1のような階層構造をなしていることです。 図1: カテゴリの階層構造 商品の階層的なカテゴリ予測に取り組んだGaoら [1] は、現在の階層的な分類の主な課題を2つ挙げています。 階層情報をどう表現するのか 学習過程における階層の不整合をどう処理するのか 1は、例えば図1中の「中カテゴリ: PC」は「大カテゴリ: 家電」の子カテゴリである、「中カテゴリ: PC」と「中カテゴリ: エアコン」は同じ「大カテゴリ: 家電」を親カテゴリとして持つ兄弟カテゴリである、などのようなカテゴリとカテゴリの間の関係を、カテゴリ予測の機械学習モデルにどうやって組み込むのか、ということです。 Naive BayesやSVM など、基本的な分類モデルの多くは分類対象のクラス (ここでは商品カテゴリ) が階層構造をなすことは前提としていません。 Gaoらは下位カテゴリをベクトルで表現する際に、図2のように上位のカテゴリのベクトル表現を一部として持たせるようにすることで、深層学習モデルにカテゴリ間の関係を組み込みました。 2は、カテゴリ予測の結果が矛盾した場合どう処理するのかということです。図1のカテゴリ階層の例でいうと、大カテゴリを「家電」と予測したにもかかわらず中カテゴリを「帽子」と予測してしまった場合にあたります。 既存のモデルのほとんどは、カテゴリの階層別にカテゴリを予測したり、親カテゴリごとの予測モデルを学習したりしていますが、前者の場合はカテゴリ予測の矛盾に対処できず、後者の場合には子カテゴリを持つカテゴリごとのモデルが必要なので中間層のカテゴリが増えると膨大な数のモデルが必要になります。 Gaoらは深層学習で正解データと現在のモデルの予測との「ずれ」を測るために用いる損失関数を、2つの連続する層の予測が矛盾している場合ペナルティが与えられるように定義して矛盾した予測結果が出づらくなるようにしました。 実は、フォルシアでも商品の自動カテゴリ予測について取り組んでいます。フォルシアでは、ECサイト上で数百万点の商品を取り扱う顧客に対し商品データからの自動カテゴリ予測を提案しました。このときに構築したカテゴリ予測モデルは現在も商用利用されています。 今回はカテゴリ予測の自然言語処理的な側面のみを取り上げていますが、商品画像からのカテゴリ予測も当然考えられます。 現在の EC サイトの多くは商品画像と商品のタイトル・説明の両方がありますし、自動でカテゴリを予測するモデルが一般的に使われるようになるには、まずテキストと画像のどちらか一方だけに拘らず、両方を使ってより精度の高い予測モデルを作ることが先決だと思います。 図2: カテゴリのベクトル表現 ((Gao et al., 2020) Figure 2 を参考に作成) 口コミ分析 続いては口コミ分析です。 もともと口コミという言葉は「人の口から口へと個別的に伝えられるコミュニケーション」を意味し、マスメディアを通して不特定多数に情報を届けるマスコミュニケーションと対比される言葉でした。しかし現在では、その意味も少し変化してきています。 SNS・ブログといった消費者生成メディアが台頭し、誰もが情報の発信者になれる時代となりました。 最近では本来の意味の口コミだけではなく、消費者生成メディアによって発信される一消費者による商品・サービスの感想・評価・批評なども口コミに含むことがほとんどです。 本記事でも、消費者生成メディアを通した消費者の言葉も口コミに含むものとします。 ECサイトには口コミの投稿・閲覧機能を持つものが多くあります。 みなさんの中にも、何かを購入する際に口コミを参考にした経験のある方は多いのではないでしょうか。 口コミは商品・サービスの消費者と供給者、双方にとって有益な情報源です。消費者は口コミを通して、商品が本当に購入に値するものなのか判断する材料にすることができます。供給者は口コミを読んで商品開発や広告の打ち方のヒントにすることができます。 しかしどちらにしても、口コミの数が増えてくるとすべての口コミをじっくり読み込んで分析することが困難になります。 さらに近年では、ECサイトで購入した商品の感想を ECサイトの口コミ機能ではなく、自らのSNSアカウントで発信する人も増えています。 口コミを網羅的にチェックするのはより困難な状況になってきていると言えるでしょう。 そのような状況下で大量の口コミを効率よく活用するため、様々なタスクとアプローチが日々開発されているようです。 例えば Gautamら [2] は、口コミの中でも特にSNSに投稿されている "苦情" に着目し、SNSにおける商品やサービスへの "苦情" を抽出するタスクに取り組みました。 私も普段SNSを眺めていると、購入した商品に対する「返品するほどではないけれども誰かに聞いてほしい不満」の投稿をよく見かけます。 このタスクの困難な点は、SNSの投稿全体に対して「ある特定商品・サービスに対する苦情」の投稿は (余程メジャーな商品・サービスでなければ) 非常に少なく、「商品 Aへの苦情を検出するモデルを作ろう」と思っても、十分な量の教師データが用意できないことです。 Gautamらは、「苦情の投稿」と「苦情の投稿を見つけるための指標」を交互に抽出する手法を適用することで、少数の苦情の投稿を入力として大量の苦情の投稿を検出できるような手法を提案しました (図3) 。 最初に、人手で用意した少数の苦情の投稿から、「苦情の投稿を見つけるための指標」を抽出します。 この指標には、「投稿にある語句が含まれているかどうか」「投稿に含まれる単語のベクトル表現のクラスタリング結果」「投稿に含まれる単語の品詞」などがあります。 指標を抽出したら、今度はその指標を使って、大量のSNSの投稿の中から苦情の投稿を検出します。 より多くの苦情が投稿できたら、さらにそれを使ってもう一度指標を抽出し・・・・・・というように、苦情と指標を交互に抽出しながら検出される苦情の数を増やしていきます。 図3: 苦情の検出 ちなみに、このような少数のラベル付きデータと大量のラベルなしデータを使って行う機械学習を「半教師あり学習」と呼びます。 今回は苦情であることがわかっている少量の投稿と苦情であるかないか不明な大量の投稿をもとに学習を行う「半教師あり学習」ですね。 ラベル付きデータを大量に用意して学習を行う「教師あり学習」や、ラベルなしデータから学習を行う「教師なし学習」と対比してこのように呼ばれます。 もしこのタスクを教師あり学習で解くとすると、「苦情であることがわかっている投稿」と「苦情でないことがわかっている投稿」を大量に用意する必要があります。 また、このタスクを教師なし学習で解こうとすると、学習用のデータは単にSNSの投稿を集めるだけでよいのですが、今度は苦情であるかないかの基準をモデルに教えることが難しくなりますね。 Gautamらは評価実験として、実際の交通サービスの苦情を検出する実験を行いました。 最初は326個の苦情からスタートして苦情の投稿の抽出と言語的指標の抽出を4回反復し、2840個の言語的指標と3700件以上のツイートを収集しました。 収集した投稿のうち 700件の投稿をランダムに選んで確認したところ、47%以上が実際にその交通サービスを対象とした苦情だったそうです。 この実験は交通サービスが対象であったため ECサイトとは違いますが、ECサイト上で販売されている商品にも同じ手法を適用することができるでしょう。 小さなラベル付きデータを手動で用意することで、多くの抽出対象を抽出できる半教師ありの手法は、アカデミアでの研究とは異なり大規模データが手に入りづらい EC サイトでの自然言語処理でしばしば見かけます。 ここでは苦情の検出タスクを紹介しましたが、口コミ分析に対してもっと広く、もっと体系的に学んでみたい方は 実践・自然言語処理シリーズ 第6巻 クチコミ分析システムの作り方 がお勧めです。 おわりに 本記事では、カテゴリ予測・口コミ分析という代表的な2つのタスクを取り上げながら、ECサイトにおける自然言語処理について見ていきました。 ECサイトにおける自然言語処理の活用は、自然言語処理の最高峰の国際会議ACLでも EC × 自然言語処理をテーマとしたワークショップの第3回 (ECNLP3) が開かれるなど、盛り上がりを見せています(ちなみに、今回紹介した2本の論文も ECNLP3 で発表されました)。 今回取り上げることができなかったタスクの中にも、商品情報テキストからの情報抽出、検索キーワード入力の際のサジェスト、ある商品を見ている人への別の商品の推薦など、難しくも興味深いタスクがたくさんあります。ぜひ調べてみてください。 冒頭でも触れた通り、近年はフォルシアでも自然言語処理で検索を便利にしていこうという流れができつつあります。 今年4月には、検索キーワードの表記ゆれに対応するための検索辞書のクラウドサービス「WordFort」をリリースしました。 参考: フォルシア、検索辞書のクラウドサービス「WordFort」開始 私自身も業務でECサイトに携わっていますが、個人的には半教師ありの手法に特に注目しています。 ECサイトまわりの自然言語処理タスクではビジネス上・著作権上の理由から大規模なラベル付きデータが用意しづらいことが多いため、データ自体が大量にあればラベルがついているものが少数でも学習できるというのは魅力的です。 しかしそこにとらわれ過ぎず、まずは「何ができたら (顧客は/エンドユーザは) 嬉しいのか?」を継続的に考えていきたいと思っています。 参考 [1] Dehong Gao, WenjingYang, Huiling Zhou, Yi Wei, Yi Hu, Hao Wang. "Deep Hierarchical Classification for Category Prediction in E-commerce System." Proceedings of The 3rd Workshop on e-Commerce and NLP. 2020. [2] Akash Gautam, Debanjan Mahata, Rakesh Gosangi, Rajiv Ratn Shah. "Semi-Supervised Iterative Approach for Domain-Specific Complaint Detection in Social Media." Proceedings of The 3rd Workshop on e-Commerce and NLP. 2020.
アバター
FORCIAアドベントカレンダー2020 6日目の記事です。 12月が始まり、既に1週間。2020年があと3週間で終わってしまうなんて信じられない!と思いながらアドベントカレンダーを書いている事業開発部の岡地です。 いきなりですが、私はエンジニアではありません。プログラミング経験はゼロで、新人研修のOJTで先輩に教わりながらコードを書いたり、簡単な名簿アプリを作ったりしたくらいです。 そこで今回は、非エンジニア代表として、エンジニア社員に対して「?」と思う部分を独自調査してみることにしました。具体的には、開発に欠かせない開発用キーボードについてです。 入社してビックリ!十人十色なキーボード キーボードと聞けば、一般的に長方形の四角いボタンがある絵が思い浮かぶのではないでしょうか。私もその一人でした。 しかし、入社後にエンジニアたちのキーボードを見て、未知との遭遇状態になりました。エンジニア界では普通のことなのかもしれませんが、私にとっては知らない世界が広がっていたのです。それではさっそく、エンジニアの声とともに多種多様なキーボードの世界を紹介させていただきます! 目を見張る驚き、左右が分かれたキーボード キーボードが左右に分割されているキーボードです。分割部分は非常に芸術的な凸凹で、タイピング心地はどんな感じなんだろう・・・?と、見ているだけでわくわく感にかられます。 コードを打っているときのエンジニアの姿勢も独特なので、この姿勢は左右分割型かな!?と予測できたりします(もちろん、全員が全員というわけではありませんが!)。 このキーボードを選んだ理由・きっかけは? 肩がこることに悩んでいて、先輩におすすめしてもらったため 肩こり改善。左右のキーボードの間にノートを置けるのがよい。軸の打感がよい キー配置が特殊ではないから お気に入りポイント! 一般的なキーボードと比べて肩を抱きこまないので姿勢が良くなる。また手首も自然な角度を保てるのでとても楽 手が小さく不器用なためFnキーを右側のキーボードのスペースに設定できるのが最高 キー配置が特殊ではないので、他のキーボードをタイプしないといけない機会があっても戸惑わない よく見るとすっごくコンパクト...HHKB配列キーボード 一見普通のキーボードと変わらないように(私には)見えますが、よく観察するとキーがギュッとまとまっていて超コンパクトなものがこちらのHHKB型のキーボード。確かによくよく見てみると、F1~12らへんの部分がありません。 また、無文字盤のものや有文字盤のものがあり、配列も日本語配列やUS配列のものなどがあるそうです。 このキーボードを選んだ理由・きっかけは? 押し心地最高、コンパクト、かっこいい 上長が使っていたから まずUSキーボードであること。また、体に優しい お気に入りポイント! タッチした時の打鍵音と感触が最高。やみつきになります 押し心地が良く、タイピングしているときに気持ちがいい 凄い形!人間工学に基づいたキーボード なかなかお目にかかれなさそうなキーボードが現れました。通称エルゴノミクスキーボードと呼ばれ、極力人間が自然な形でタイピングできるよう設計されているそうです。 上記の湾曲したタイプでは、リストレストと呼ばれる部分(手首を置く場所)がデザインされており、タイピング時にかかる手首への負担を大幅に減らしてくれるとか。 また、お椀型と呼ばれるタイプも存在し、キーボード部分(画像上左右部分)がお椀の中に埋め込まれているような形をしています。普通に生活していたら出会えませんが、この調査の中で出会えました。 この画像以外にもエルゴノミクスキーボードには様々な種類があるそうですが、これまた奥が深い・・・調べきれない! このキーボードを選んだ理由・きっかけは? 10年前当時、エルゴ系のものを探していて見つけた 購入当時、超円高でお手頃だったから お気に入りポイント! 左右分割式よりは狭いが手が開いて自然な姿勢となる お椀型を使っており、すべての行に指が届きやすい リマップはもちろんのこと、マクロまでキーボード単体で組むことができる 親指をモディファイヤ、エンター等に使えるので小指に負荷がかからない 若手からベテランエンジニアまで~初期装備を選んだ理由~ 入社当時支給されたキーボードをそのまま使っているエンジニアも多数います。理由を聞いてみました。 自分が会社支給のものに順応することを選んだ キーボードにこだわりがなく、初期装備を使い続けている 会社支給のものに慣れたため RPGの初期装備「ひのきのぼうとぬののふく」で魔王を倒すのと同じロマンがある。初期装備が逆にかっこいい 上記のように、色々な意見がありました。RPGの件は調べてみましたが、よくわかりませんでした(ファンの方ごめんなさい)。でも、ロマンがあることはなんとなくわかった気がします・・・! キーボードではありませんが、私も小学1年生の時に父に名前を書いてもらったはさみを、上京するときも東京に連れてきて、社会人になっても家で使っています。これもロマンでしょうか。 キーボードとは全く関係のない、岡地の私用はさみ その他のキーボード 執筆者の長年愛用私物キーボード(イメージ図) ご紹介したキーボード以外にも、 キートップの文字が消えにくいもの 打鍵音が静かなもの 赤ポチで有名なもの(赤ポチがあると落ち着く) 薄型のキーボード(打鍵のストロークが浅くて指が疲れにくい) テンキー(キーボードの右側にある電卓みたいな部分)がなくコンパクトなもの 電気屋さんで運命的に出会ったもの お気に入りポイント:印字ミスでLがOになっている など、様々な愛用キーボードに対する想いを聞くことができました。 最後に 以上、キーボード素人ならではの目線で独自調査をしてみました。エンジニア目線では当たり前のことかもしれませんが、非エンジニアにとっては目からうろこのことばかり。みなさんはどのキーボードに心惹かれましたか?自分が一番使ってみたいのは左右分割対応のものですね(使いこなせるかはわかりませんが(笑))。 将来エンジニアを目指している方でこの記事を読んでくださっている方がいましたら、自分にフィットするキーボードを調べてみるといいかもしれません。そうでない方も、こんな世界があるんだなと少しでも知っていただけたら嬉しいです。最後まで読んでくださりありがとうございました。
アバター