TECH PLAY

WESEEK, Inc.

WESEEK, Inc. の技術ブログ

75

こんにちは、GROWI.cloud の開発・運用を担当している WESEEK のエンジニアの伊勢です。 今回は、 GROWI.cloud の開発で困った問題が起きて、その問題を解消した時の話をご紹介します。 背景 GROWI.cloud は node.js を主な言語として開発しており、そのプロジェクトを役割に応じて「プロジェクトA」「プロジェクトB」... と分割しています この「プロジェクトA」「プロジェクトB」... が互いに通信することで、 GROWI.cloud のサービスは成り立っています また最近になって、開発環境は VSCode の devcontainer(, docker-compose) を利用することになりました 各プロジェクト単体での開発に限っては、他のプロジェクトとの連係に社内テスト環境を利用していたため、devcontainer 化の影響はありませんでした 開発環境内で「プロジェクトA」と「プロジェクトB」を連係して開発する場合において、両プロジェクトの devcontainer 同士が通信できない状態に陥ってしまい、開発に支障が出てしまいました そこで、devcontainer のネットワーク周りの設定を見直し、複数の devcontainer 間で通信できるよう開発環境を改善することにしました やったこと devcontainer の docker-compose 起動時に network を指定できるように、 devcontainer 構築時に Docker ネットワークが無ければ自動的に作成されるよう設定を追加 "initializeCommand": "if ! docker network ls | awk '{ print $2 }' | grep -qx 'growi-cloud-common'; then docker network create --driver bridge growi-cloud-common; fi", ※ projectB でも同様の設定を追加することで、プロジェクトA, B どちらの devcontainer が先に起動しても同じ名前空間のネットワークに接続できます 同じ Docker ネットワーク上では service 名で名前解決できます .devcontainer/docker-compose.yml の service 名がプロジェクトA, B で被らないように修正 ※同じ Docker ネットワーク内ではサービス名が被っていると名前解決が正しくできないため、別プロジェクトであってもサービス名が被らないようにする必要アリ services: node: -> node-project-b: これで、projectA の node.js アプリが node , projectB の node.js アプリが node-project-b と、名前解決できるようになりました .devcontainer/docker-compose.yml の network を同一のネットワークに接続するように設定 services: node: networks: - growi-cloud-common networks: growi-cloud-common: external: true services: node-project-b: networks: - growi-cloud-common networks: growi-cloud-common: external: true 通信先を見直し ※devcontainer 化以前は、 localhost の「ポート番号」で通信先を振り分けていたが、サービス名を指定して通信できるようになったので、修正 例) projectA → projectB に POST リクエストを送る場合 (修正前) const res = await axios.post('http://localhost:3001/growi', params); ↓ ↓ (修正後) const res = await axios.post('http://node-project-b:3001/growi', params); Rebuild Container する やったことは以上です! では、よい devcontainer LIFE を!
アバター
strictBindCallApply ってなに? tsconfig のオプションのひとつ デフォルトでは false (厳密なチェックはしない) 設定になっています strictBindCallApply: false のままだとどうなるの? false のままだと、 bind , call , apply を使う時に type safe でないコードになってしまいます 「type safe ではない」ってどういうこと? TypeScript入門『サバイバルTypeScript』 の strictBindCallApply のページ がわかりやすいです type safe じゃないと何が困るの? type safe になると何が嬉しいの? 例えば、 const value1 = 100; const value2 = "100"; updateSize.bind(this)({ width: value1, hieght: value2, }); みたいな、 height のスペルが間違ってる、value2 が number じゃないのに突っ込んじゃったり…というコードが生まれます 早い話、 DX が落ちます type safe になると、IDE や VSCode で lint error が出たり、コード補完が効くようになります bind , call , apply って使わないよね?(どんなときに使うの?) 確かに使わないといけない機会は多くないです 例えば bind だと、サードパーティーのライブラリを使う時に this の引き渡しが必要になったりすることがあります GROWI 開発では remark/rehype plugin を書く時 に利用しました 逆に strictBindCallApply: true にして困ることってある? 全くありません 新規プロジェクトでも既存プロジェクトでも、すべからく true にした方がいいです 既存プロジェクトでも bind , call , apply しているメソッドは正常に動いているはず(動いていないといけないはず)なので、lint error でリスクを炙り出せるのは有効です strict: true でいいんじゃないの? よくご存知で。 たしかに strict: true を設定すれば、今回紹介した strictBindCallApply をはじめ、 strictNullChecks や strictFunctionTypes などいくつかのオプションがまとめてONになり、最も type safe な設定になります。 ただしこれまで strict: false で運用してきたプロジェクトに関してはかなりの変更検知が予想されますし、また今後 TypeScript のバージョンアップに伴って新しいオプションが strict: true で有効化される対象として入る可能性もあるので、個別具体的・明示的な設定にはならないという懸念も出てきます。 参考: https://www.typescriptlang.org/tsconfig#strict プロジェクトの状態に合わせて書き方は変えていただければと思います。 まとめ というわけで、今すぐ strictBindCallApply: true (または strict: true ) を設定しましょう!
アバター
どうも、インターンの手塚です。今回は、Next.jsのstandaloneという機能に焦点を当てた記事を書こうと思います。standalone機能がビルドサイズをどれだけ小さくするのかを確認してみましょう。 目次 standalone機能とは standalone公式ドキュメント // next.config.js module.exports = { output: 'standalone', } 公式ドキュメントにもあるように、 next.config.js に上のように書くことで standalone機能 が有効になります。このモードが有効になった状態でビルドすると、 .next ディレクトリ下に standalone フォルダが作成されます。このフォルダの下には、 node_modules から、使用するファイルのみがコピーされ、さらに next start コマンドの代わりに使用できる最小限の server.js ファイルが生成されます。 要するに、自動的に standalone フォルダが作成され、その中に動作に必要な最小限のファイル群がコピーされるという便利な機能です。この機能によってビルドサイズを削減できます。 いざビルド 自分が個人的に運営している ブログ を実際に2通りの方法でビルドして確認してみたいと思います。参考までに、ブログの依存関係は下の通りです。 // package.json "dependencies": { "autoprefixer": "^10.4.7", "copy-webpack-plugin": "^11.0.0", "gray-matter": "^4.0.3", "markdown-it": "^13.0.1", "markdown-it-anchor": "^8.6.4", "markdown-it-container": "^3.0.0", "markdown-it-emoji": "^2.0.2", "markdown-it-prism": "^2.2.4", "markdown-it-table-of-contents": "^0.6.0", "next": "12.1.6", "react": "18.1.0", "react-dom": "18.1.0", "write-file-webpack-plugin": "^4.5.1" } 特に複雑でもない、Next.js製のよくある静的な個人ブログです。 standalone機能を無効にしてビルド まずは、 standalone機能 を無効にした状態でビルドしてみます。 # Dockerfile FROM node:16 AS builder WORKDIR /app COPY . . RUN yarn install --frozen-lockfile --production=false RUN yarn build FROM node:16 AS runner WORKDIR /app COPY --from=builder /app/next.config.js ./ COPY --from=builder /app/.next ./.next COPY --from=builder /app/public ./public COPY package.json ./ COPY yarn.lock ./ RUN yarn install --frozen-lockfile --production=true CMD ["yarn", "start"] # in package.json # scripts": { # "start": "next start", # }, standalone機能 を無効にしているので、本番用にビルドした node_module をそのままビルドに含めます。 docker build -t not_standalone . docker run -p 3000:3000 not_standalone ビルドして、ブラウザからアクセスができることを確認します。 standalone機能 を使用しない場合のイメージサイズは、 2.09GB でした。 standalone機能を有効にしてビルド 続いて、 standalone機能 が有効な状態でビルドをしてみたいと思います。 # Dockerfile FROM node:16 AS builder WORKDIR /app COPY . . RUN yarn install --frozen-lockfile RUN yarn build FROM node:16 AS runner WORKDIR /app COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/public ./standalone/ COPY --from=builder /app/.next/static ./standalone/.next/ CMD ["node", "server.js"] 公式のDockerfile を参考にしながら Dockerfile を作成します。standalone機能を有効にしているので、本番用にビルドした node_module をビルドに含める必要がありません。 public や .next/static などの静的なファイルは通常CDNによって配布される想定なので、自動的には standalone フォルダの下にはコピーされませんが、手動でこれらを standalone/public 、 standalone/.next/static にコピーすることで、文字通りstandaloneフォルダの中のみでアプリを動作させることが可能になります。 docker build -t standalone . docker run -p 3000:3000 standalone ビルドして、ブラウザからアクセスができることを確認します。 standalone機能 を有効にした時のイメージサイズは、 956.74MB でした。 結果を比較 standalone機能が無効な状態だと: イメージのサイズが 2.09GB 。 standalone機能が有効な状態だと: イメージのサイズが 956.74MB 。 と大幅にビルドのサイズが小さくなっていることがわかりました。アプリが正常に動作するならビルドサイズは小さいに越したことはありませんので最高に嬉しいですね! 最後に クラウドのコンテナレジストリサービスを使っている人は、イメージのサイズで料金が変わってきたりすることもあるかと思います。DockerとNext.jsを用いてアプリをデプロイしている人は、ぜひstandalone機能を活用してみてください!!!
アバター
こんにちは、 ryosuke です。 今回は、 以前に取り上げた、ドキュメント作成ツールである Sphinx を使って、多言語の文章を作成する際に、困った点とその解決手段についての続編です。 この記事では、「解決方法」の話をします。 前回の記事は「 Sphinxでi18nなドキュメントを作成するときのナレッジ:課題編 」です おさらい: 翻訳作業時の課題 前回の記事では翻訳作業における3つの課題を説明しました。 reference による大量の差分 文の自動改行による大量の差分 翻訳漏れの検知機構不足 これらを解消するために用いた技術的方法を紹介します。 方針: gettext utilities の助けを借りる このような課題に悩まされる背景に、 sphinx-intl コマンドでは細かいオプションが指定できない点があると思います。 sphinx-intl コマンドは Sphinx で i18n 対応をする際に、一連の作業をスムーズに進めるために有用なツールですが、出力する PO ファイルの細かい制御まではサポートしていないようです。 ところで、 Sphinx のドキュメントにも書いてあるとおり [1] 、Sphinx の i18n 機構は、 gettext を採用しています。 POT ファイルや PO ファイルを使った i18n の仕組みは、古くからある GNU gettext システムによるものです。 gettext には多数の utility コマンドが用意されており、多数の機能や細かいオプション指定ができます。 gettext のコマンドを活用することで、 sphinx-intl コマンドだけでは手が届かない痒いところを解消します。 解消方法 reference による大量の差分 この課題の解消には、 gettextとバージョン管理システムの相性の悪さを解消する案 - 2013-11-14 - ククログ で紹介されている案を採用しました。 reference の情報はファイルのバージョンや差分管理の観点では、無視したい(注目したくない)内容のため、 git などの VCS との相性はよくありません。 従って、 git commit する段階では、削除しておきたい情報です。 上記記事のとおり、 reference を PO ファイルから簡単に削除する手段は用意されています。 (自前で sed とか grep で処理する必要はありません!) reference が含まれる PO ファイルから reference を消すために msgcat コマンドを使用します。 msgcat --no-location --output-file="PO file" "PO file" 本来は、複数 PO ファイルを結合して、単一の PO ファイルを出力するためのコマンドですが、入力と出力に同一の PO ファイルを指定することで、 msgcat コマンドをあたかも formmater として使う事ができます。 肝は --no-location オプションを指定してる点です。 このオプションによって、 reference の記述が削除されます。 reference が消えることで、以下の reST ファイルがある場合、 1行目の文章です。 3行目の文章です。 5行目の文章です。 生成される PO ファイルは下記のようになります。 msgid "1行目の文章です。" msgstr "This is a paragraph on first line." msgid "3行目の文章です。" msgstr "This is a paragraph on third line." msgid "5行目の文章です。" msgstr "This is a paragraph on fifth line." 従って reST ファイルを下記のように変更しても、 1行目の文章です。 後から追記した文章です。 3行目の文章です。 5行目の文章です。 再生成した PO ファイルは下記のようになります。 msgid "1行目の文章です。" msgstr "This is a paragraph on first line." msgid "後から追記した文章です。" msgstr "This is a paragraph that was added later." msgid "3行目の文章です。" msgstr "This is a paragraph on third line." msgid "5行目の文章です。" msgstr "This is a paragraph on fifth line." 従って reST ファイルの加筆に伴う PO ファイルの変更が発生しても、差分は下記のように表示されます。 reference が記述されているときと比べて、明らかに注目すべき変更点に集中できる状態になったと思います。 文の自動改行による大量の差分 この課題は、自動改行を無効にすることによって解消します。 実際の手段は、下記の2つのいずれかを好みに応じて選択できます。 sphinx-intl コマンドでオプション指定する sphinx-intl update -p builddir -l en -w 0 msgcat コマンドでオプション指定する msgcat --no-wrap --output-file="PO file" "PO file" sphinx-intl コマンドを使う手段は、原文修正時の PO ファイル再生成をする時に実行する sphinx-intl update コマンド に -w 0 オプションを指定する使い方です。 -w は、1行あたりの文字数を指定できるオプションですが、 0 を指定することで自動改行を無効にできます。 msgcat コマンドを使う手段は、 reference 問題の解消方法と同様に、 PO ファイルから自動改行を削除する formatter として使う手段です。 --no-wrap オプションを指定することで、自動改行を無効にできます。 この手段を使った場合の効果を、前回記事の例を使って視覚化してみましょう。 reST ファイルで以下のような修正を行った場合、 -今日は晴れです。明日は曇りです。明後日は雨の可能性がありますが、現時点では降水確率は低めの予報です。 +今日は晴れです。明日は曇りです。明後日は雨の可能性があります。 修正前の PO ファイルは下記のようになり、 msgid "今日は晴れです。明日は曇りです。明後日は雨の可能性がありますが、現時点では降水確率は低めの予報です。" msgstr "Today is sunny. Tomorrow will be cloudy. The day after tomorrow there is a chance of rain, but at this time the chance of precipitation is forecast to be low." 修正後の PO ファイルは下記のようになります。 msgid "今日は晴れです。明日は曇りです。明後日は雨の可能性があります。" msgstr "Today is sunny. Tomorrow will be cloudy. The day after tomorrow there is a chance of rain." 従って PO ファイルの変更差分は下記のように表示されます。 こちらの問題も、注目すべき変更点が読み取りやすくなったと思います。 翻訳漏れの検知機構不足 この課題は、 msgcmp コマンドを活用することで解消します。 このコマンドを使った翻訳漏れの検知は、主に CI で実施することを前提に紹介します。 原文の reST ファイルに任意の文章を加筆し、併せて PO ファイルも再生成し、適切な訳を記述済みの commit に対して、 msgcmp コマンドを使った CI を行うことを想定します。 CI の中では、まず reST ファイルから POT ファイルを再生成します。 sphinx-build -b gettext sourcedir builddir そして、生成した POT ファイルと commit 済みの PO ファイルについて、 msgcmp コマンドを使用します。 msgcmp "PO file" "POT file" msgcmp コマンドを実行しても、何の出力もなく、コマンド実行のステータスコードも 0 であれば、問題なしです。 一方で、何らかのエラーメッセージが出力され、ステータスコードも 0 以外であれば、異常があります。 msgcmp コマンドは、指定した PO ファイルと POT ファイルの全ての msgid の存在が両ファイル間で完全一致しているかどうかを判定します。 例えば、 原文の reST ファイルに加筆したのに PO ファイルの再生成を怠った場合を考えましょう。 このとき、 POT ファイルには含まれるが PO ファイルにはない msgid が存在します。 その状態で msgcmp コマンドを実行すると、以下のようなエラーメッセージが表示されます。 sample.pot:XXX: this message is used but not defined in sample.po msgcmp: found 1 fatal error 一方で、原文の reST ファイルから文章を削除したのに PO ファイルの更新を怠った場合を考えましょう。 このとき、 POT ファイルにはないが PO ファイルには含まれる msgid が存在します。 この場合は、翻訳漏れは起きませんが、不要な訳の記述が PO ファイルに残ったままとなり、健全な状態から逸脱します。 このような状態で msgcmp コマンドを実行すると、以下のようなエラーメッセージが表示されます。 sample.po:XXX: warning: this message is not used msgcmp: found 1 fatal error この仕組みによって、翻訳の過不足を検知できます。 解消手段導入による効果 上記の課題解消手段を導入したことにより、翻訳に関わる下記のトラブルを回避できました。 訳の更新漏れ 訳文のレビュー時のレビュアーに対する無用な負荷 まとめ 今回紹介した手段を導入することによって、国際化ドキュメンテーションの活動での無用な品質低下の要因を減らせます。 その分、ドキュメンテーションの関係者は、本来注力すべき、ドキュメントそのものの構成や、適切な訳の記述などに集中でき、本質的な内容の品質向上につながります。 似たような課題に悩んでいるようでしたら、是非参考にして下さい。
アバター
こんにちは、システムエンジニアの kouki です。 この記事では WordPress のアップデートをした時に遭遇した LDAP のトラブルとその対処法について紹介します。今回は端的にまとめていますので、調査経緯に興味がある方は気づくまでに至った「調査ログ」も見ていただけると幸いです。 トラブルに遭遇した環境 WordPress 内で miniOrange が提供している「 LDAP Login for Intranet Sites 」というプラグインを導入していた 上記のプラグインに限定されず、PHP の LDAP extension を利用しているコードが存在するならば今回のトラブルに遭遇するはずです。 経緯 bitnami/wordpress docker image を 5.9.3 から 6.0.0 にアップデートした アップデートを行った時に、WordPress から OpenLDAP に接続できない事象が発症した ログには Can't contact LDAP server というログが出力されていた 原因と対処 bitnami/wordpress の 5.9.3 では存在していた /etc/ldap/ldap.conf というファイルが 6.0.0 では削除されていました。そのため、PHP LDAP extension において「TLS_CACERT」のファイルパスが見つからず、証明書の検証に失敗し、TLS 接続時にエラーが出ていることが分かりました。 対処として wordpress container 起動時に環境変数として TLS_CACERT=/etc/ssl/certs/ca-certificates.crt を指定することで PHP LDAP extension が LDAP サーバに接続できるようになりました。 2022/09/08 時点の 5.9.3 image でも /etc/ldap/ldap.conf が存在しないようです。(社内の bitnami/wordpress:5.9.3 をカスタムしたイメージには含まれていました) 5.9.2 のイメージでは /etc/ldap/ldap.conf があることが確認できますので、確認してみたい方は 5.9.2 でお試しいただけるかと思います。 調査ログ ここからはデバッグを行った調査ログを残しておきます。 LDAP サーバに openssl コマンドで接続するも、問題無し (ドメインは example.com で置換済) # echo | openssl s_client -connect example.com:636 | openssl x509 -noout -text depth=2 C = US, O = Internet Security Research Group, CN = ISRG Root X1 verify return:1 depth=1 C = US, O = Let's Encrypt, CN = R3 verify return:1 depth=0 CN = *.example.com verify return:1 DONE Certificate: Data: Version: 3 (0x2) Serial Number: xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx Signature Algorithm: sha256WithRSAEncryption Issuer: C = US, O = Let's Encrypt, CN = R3 Validity Not Before: May 31 14:55:53 2022 GMT Not After : Aug 29 14:55:52 2022 GMT Subject: CN = *.example.com WordPress plugin から通信があった際の LDAP サーバ側のログは下記の通り 62cfd66c conn=3662245 fd=26 ACCEPT from IP=XXX.XXX.XXX.XXX:33692 (IP=0.0.0.0:636) 62cfd66c conn=3662245 fd=26 TLS established tls_ssf=256 ssf=256 62cfd66c conn=3662245 fd=26 closed (connection lost) connection lost というログしか出ないため、「接続時に何らかのトラブルが起きているんだろう」ということしかわからず。 ここで一旦調査に行き詰まる。 LDAP Login for Intranet Sites plugin のソースコードを改変して、デバッグ作業の開始 /opt/bitnami/wordpress/wp-content/plugins/ldap-login-for-intranet-sites/class-mo-ldap-config.php ( GitHub へのリンク ) のファイルに下記のコードを仕込み、container のログを確認する # stdout に変数の内容を出力するコード ($err, $error_no は ldap_bind 関数から取得したもの $fp = fopen('php://stdout', 'w'); fwrite($fp, $err . "\n"); fwrite($fp, $error_no . "\n"); fclose($fp); ldap_bind 関数にて Can't contact LDAP server というメッセージが出力されていることを確認 再度調査が行き詰まる その後、 ldap_set_option という関数があることが分かり、そのコードを仕込む ldap_set_option(NULL, LDAP_OPT_DEBUG_LEVEL, 7); コード適用後、下記のようなログが出力される ldap_ndelay_off: 12 ldap_pvt_connect: 0 TLS: peer cert untrusted or revoked (0x42) TLS: can't connect: (unknown error code). 「証明書周りで問題がありそうだ」というところまで特定 putenv('LDAPTLS_REQCERT=never') というコードを plugin に書き加えて、接続ができることを確認 (あくまで仮の対処法) しかし、 LDAPTLS_REQCERT=never という workaround は筋が悪い。セキュリティ的にも問題がある その後、調査を進めると ssl - Can not connect to server via ldaps using Let's Encrypt certificates - Stack Overflow という記事を他のメンバーが発見 bitnami/wordpress 5.9.3 時点では /etc/ldap/ldap.conf ファイルが存在し、6.0.0 では存在しないことが分かる ### 5.9.3 image $ docker run -it --rm bitnami/wordpress:5.9.3 --entrypoint /bin/bash -c 'cat /etc/ldap/ldap.conf' # # LDAP Defaults # # See ldap.conf(5) for details # This file should be world readable but not world writable. #BASE dc=example,dc=com #URI ldap://ldap.example.com ldap://ldap-master.example.com:666 #SIZELIMIT 12 #TIMELIMIT 15 #DEREF never # TLS certificates (needed for GnuTLS) TLS_CACERT /etc/ssl/certs/ca-certificates.crt ### 6.0.0 image $ docker run -it --rm --entrypoint /bin/bash bitnami/wordpress:6.0.0 -c 'cat /etc/ldap/ldap.conf' cat: /etc/ldap/ldap.conf: No such file or directory image 起動時の環境変数に TLS_CACERT=/etc/ssl/certs/ca-certificates.crt を指定 上記の LDAPTLS_REQCERT=never は削除し、 TLS_CACERT にて対応完了 最後に 今回は他の docker image でも応用が利きそうなトラブルシューティングを紹介しました。 NextCloud なども PHP で作られているのでこの問題に該当しているならば、同じような対処を行うことで意図した挙動になると思います。
アバター
皆さんこんにちは! WESEEK ソフトウェアエンジニアの 増山 です。 今回のブログでは、Python と 音声認識ライブラリ CMU Sphinx を使って簡単な音声認識をやってみます。 目次 ソースコード 動くコードは https://github.com/hakumizuki/python_sphinx_sample にあります。 こちらのレポジトリをベースに説明していくので、実際に手元で動かして見たい方は git clone https://github.com/hakumizuki/python_sphinx_sample をお願いします。 また説明では devcontainer を使用しますが、マイクなどの外部機器を使うときにはコンテナより実機の方が使いやすいと思いますので、その場合は Dockerfile の依存しているパッケージのインストール部分などを参考して直接 OS 上で実行してみてください。 SpeechRecognition ライブラリ CMU Sphinx を Python で扱うには、さまざまな音声認識 API を司る SpeechRecognition ライブラリ を使用します。 CMU Sphinx は OSS として開発されている音声認識ツールです。Google Cloud Speech API などと違って完全にオフラインで動くのでインターネットを必要しないことが強みの一つだと思います。言語モデルを用意することでどんな言語でも音声認識できます。詳しくは こちら 他の手段を試してみたい方は こちら を参考にしてください。 環境構築 Docker をインストール https://www.docker.com/ VSCode をインストール https://code.visualstudio.com/ $ git clone https://github.com/hakumizuki/python_sphinx_sample $ cd ./python_sphinx_sample $ code . Ctrl+P or Command+P を押し、Reopen in container と入力して出てきた候補をクリック これで devcontainer が起動して環境構築が完了しました。ではプログラムを見ていきましょう。 プログラム説明 主に使用するライブラリは SpeechRecognition という Python の音声認識用ライブラリです。 ファイルパスなどの定数は constants.py に、音声認識のプログラムは recognize.py に書きました。 constants.py import os BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) MP3_TEMP_OUT=f'{BASE_DIR}/audios/speech.temp.mp3' WAV_OUT=f'{BASE_DIR}/audios/speech.wav' recognize.py import speech_recognition as sr import sys from constants import BASE_DIR, WAV_OUT def main(): mode = None # *1 # Get mode from args if len(sys.argv) > 2: raise Exception(f'Too many args. (pass "mic" or don\'t pass to use "{WAV_OUT}" file)') elif len(sys.argv) == 2: mode = sys.argv[1] recognize_result = recognize(mode) print(recognize_result) def recognize(mode): text = None # *1 audio_src = sr.Microphone() if mode == 'mic' else sr.AudioFile(WAV_OUT) with audio_src as audio_file: # *2 # Initialize a Recognizer r = sr.Recognizer() # Remove noise # r.adjust_for_ambient_noise(audio_file, duration=10) # Convert audio source to a recognizable object recognizable = r.record(audio_file, duration=10) text = r.recognize_sphinx(recognizable) return text *1 python recognize.py <mode> という使い方を想定しています。mode には mic を渡すとマイクから入った音声をを音声ソースとして使用し、何も渡さないと constants.py の WAV_OUT にある音声ファイルを音声ソースとして使用します。 マイクを使用するには以下を参考にしてください。 コンテナの外でスクリプトを実行する この記事 などを参考にコンテナ上でマイクを使えるように設定する *2 Recognizer インスタンスを生成 record メソッドで音声ファイルから音声認識用オブジェクトを生成 recognize_sphinx メソッドに音声認識用オブジェクトを渡して実行することで音声認識が実行される 動かしてみる 今回はレポジトリに内包している speech.py で音声ファイルを作成して、それを認識させてみたいと思います。speech.py は英語の文字列を受け取ってそれを音声ファイルに変換します。 $ mkdir ./audios src と同じ階層 $ python speech.py "apple banana" 好きな英語に変更できます $ python recognize.py $ python recognize.py apple banana と表示されれば成功です。 終わりに ここまでお読みいただきありがとうございました。 質問等ありましたら 増山の Twitter に DM 送っていただければ答えられる範囲でお答えします。
アバター
皆さんこんにちは! WESEEK ソフトウェアエンジニアの 増山 です。 今回のブログでは、時系列データベース VictoriaMetrics でデータを上書きしたように見せる方法を解説します。 VictoriaMetrics をまだご存知ない方には こちらの記事 で入門していただくことをおすすめします。 目次 VictoriaMetrics ができないこと そもそも VictoriaMetrics は v1.80 の時点では時系列データの上書きをサポートしていません。また、ある特定の時系列データの、ある期間におけるデータの削除もできません。 よって、上書き前のデータと上書き後のデータが vmstorage 上に存在する状態で、 擬似的に 上書き後のデータのみが取得できるようにする方法を考えます。 想定ユースケース 時系列データを一定間隔で投入する運用 間違ったデータが投入される可能性があり、あとから backfill することで正しいデータで擬似的に上書きしたい 前提条件 この解説では、10分ごとにデータを投入する場合を想定しています。 データが上書きされたように見せる 方法 vmselect のオプションに -search.setLookbackToStep=true , -dedup.minScrapeInterval=1ms を設定します 編集したいとするデータ、を前回投入したデータのタイムスタンプに 1ms を加えたタイムスタンプで投入します /query_range をクエリパラメータ ?start=<ある 10m 間隔における最初のデータポイントの timestamp + 10m59s>&end=<start と同じ>&step=10m で GET リクエストします 解説 -search.setLookbackToStep=true この設定値のとき、 Range query の step パラメータが Lookback の値になります Lookback = データポイントが存在しないタイムスタンプを対象に Range query をリクエストしたとき、Lookback に設定された時間分遡ってデータポイントを発見しに行く仕組みです この設定のときは step と Lookback が同じ値になるため、 step=10m では最大 10分遡ってデータを見つけに行きます。そのため、常に 最初のデータ投入時間+10m59s の時刻を start に設定してリクエストしておくことで、擬似的に上書き後のデータのみが取得できます また他にも、step に設定された時間を超えた範囲のデータを取得しようとしたときには結果を返さなくなるので、間違った start/end を設定してしまった際にミスに気づくことができるというメリットもあります -dedup.minScrapeInterval=1ms この設定値にすると、 vmselect は "全く同じタイムスタンプ" に異なるデータ(Sample)が複数存在するときにランダムな値を deduplicate します。この "全く同じタイムスタンプ" でしか deduplicate されないことを利用して、ある 10 分の間に 1ms 間隔で擬似的な上書き値を挿入することが可能になっています -dedup.minScrapeInterval=0 にすると重複排除されないため注意 これが 1ms で無いときは "異なるタイムスタンプ" のデータでも deduplication の対象になってしまいます 最大擬似上書き可能回数 最大 600,000 - 1 = 599,999回上書きできます(10min は 600,000 ms) Range query Range query は start と end でまず区間を指定し、 step の値に応じて複数の時刻で promQL(MetricsQL) を評価するエンドポイントです。 start の時刻では必ずクエリが評価されます。 step が end-start より大きいとき、さらに start+step の時刻でクエリが評価されます。この評価ごとに Lookback が発生します 詳しくは: https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries 注意点 この方法で VictoriaMetrics を運用した場合、データの投入には全く影響はないが、データを集計して取得する際には影響があります。 生のデータが上書きされているわけではない 新しいデータを少し時間をずらして入れることで擬似的な上書きを実現しているため、余計なデータが vmstorage に残ったままになります データの平均値を計算したいなどのときには、PromQL の range と resolution を正しく設定しないと擬似的な上書き前のデータが集計に使用される場合があります まとめ ここまでお読みいただきありがとうございました。 質問等ありましたら 増山の Twitter に DM 送っていただければ答えられる範囲でお答えします。 関連記事 VictoriaMetrics 入門 【2022年保存版】気になるTSDBプロダクトを比較してみました
アバター
要求 React の以下のようなコンポーネントがあり、 onKeyDownHandler で"文字の変換を確定したときのエンターキー押下"と"それ以外のエンターキー押下"を区別したい。 const Component = () => { const onKeyDownHandler = (e) => { if (e.key === 'Enter') { // これだと全てのエンターキー押下でこのブロックに到達する } }; return <input onKeyDown={onKeyDownHandler} />; }; 解決法 onCompositionStart と onCompositionEnd で変換を検知する。詳しくは こちら const Component = () => { const [isComposing, setComposing] = useState(false); const onKeyDownHandler = () => { if (e.key === 'Enter' && !isComposing) { // 変換のためのエンターキー押下のときはここに到達しない } }; return <input onKeyDown={onKeyDownHandler} onCompositionStart={() => setComposing(true)} onCompositionEnd={() => setComposing(false)} />; };
アバター
要求 React DnD でネストされたコンポーネントがあるとき、子要素の範囲をドラッグしようとしてもドラッグされないようにすること。 const Child = () => <div style={{width: 25, height: 25}}></div>; // この要素が存在する部分ではドラッグできないようにしたい const Parent = () => <div style={{width: 50, height: 50}}><Child /></div>; 解決法 Child コンポーネントを以下のように変更することでドラッグが発生しなくなる。 const Child = () => <div draggable onDrag={e => e.preventDefault()} style={{width: 25, height: 25}}></div>; 参考 https://github.com/react-dnd/react-dnd/issues/335
アバター
はじめに こんにちは WESEEK でわりと何でもやっている haruhikonyan です。 皆さんは日々 GitHub 使ってますでしょうか?仕事では GitHub やその他を使っている方でもエンジニアであれば GitHub でコードを見たりすることはあると思います。 しかしなかなかコードの全文検索がうまくいかなかったり、見たいだけなので clone するまでもなくちょっとした作業とかをしたいこともあるあるなんじゃないかなと思います。 そんな時には clone せずとも VSCode とほぼ同じ使用感でコードを参照できる GitHub のモードです。 同僚で知らなかった人がいたので便利さを簡単に紹介してみようと思います。 起動方法 起動する方法は大きく二つあります。あたりまえですがどちらも共通して開きたい GitHub 上のリポジトリをブラウザで開いておく必要があります。 . を押す これが間違いなく一番楽ですね。ほんと . をただ押すだけでブラウザ上に VSCode が展開されます。 URL の .com を .dev に切り替える これもわかりやすさでは一番かと思います。URLを自分で書き換えリロードするとエディタになります。 便利ポイント 全文検索 VSCode と同じで ctrl(command) + shift + f もしくは左側の虫眼鏡ボタンを押すと出てくる検索ボックスをクリックします。 素の Github にある左上の検索ボックスよりはるかに楽で精度も高く、利用頻度ナンバー1なんじゃないかなと思います。 もちろん検索対象はリポジトリのコードのみなので、issue や wiki などの内容を検索したい場合は素の Github の検索ボックスを使いましょう。 ファイル検索 こちらも VSCode と同じく ctrl(command) + p にて開かれる検索ボックスから指定します。 目的のファイル名さえわかっていればエクスプローラーを辿らずにすぐ開いたり検索することができます。 ファイル参照共有 コードを参照して有益な情報などを得たら共有したくなるのは常です。 しかしブラウザ版 VSCode で開いているタブは URL とは連動していないのでそのまま URL コピペではだめなので以下手順を踏みましょう。 ファイルの URL を取得したい場合 エクスプローラーの特定のファイルもしくは、開いているファイルのタブを右クリック Copy Github (Head Link|Permalink) どちらかを選択 ファイルの特定の行数を取得したい場合 特定の行数もしくは複数行数をドラッグして右クリック 形式を指定してコピー > Copy Github (Head Link|Permalink) どちらかを選択 いずれの方法も VSCode の URL がコピーされるので、通常の Github のファイルへのリンクが欲しい場合は URL の .dev を .com に変更しましょう。 他にもいろいろ 拡張機能が入ったり 各種 git 操作ができたり コードが実行できたり 一旦はコードが簡単に参照できるだけで超便利なんでこの辺の詳細は割愛させていただきます。 詳しくは公式ドキュメントを参照しましょう。 https://docs.github.com/ja/codespaces/the-githubdev-web-based-editor おまけ 実は GitLab でもそのままエディタが開ける GitLab 利用者に朗報です。(自分も一応利用者) GitLab でも . を押すことで VSCode ライクなエディタがブラウザで使えます。 Web IDE と呼ばれてるようです。 しかしファイル検索はできますが、いつも使ってるような全文検索は現状無いようです。 https://docs.gitlab.com/ee/user/project/web_ide/ 詳しくは公式ドキュメントを参照しましょう。 終わりに OSS のコードとかパッといつも使ってる VSCode と同じ使用感で見たいときには超便利!
アバター
こんにちは takayuki です。 Google データポータル は、Google が提供するサービスを中心に様々なデータソースを利用して、ダッシュボードの構築ができます。 データソースが用意されていないものでも自分でコネクタを作成して、 Google データポータルにデータを取り込めます。コネクタは Google Apps Script で記述し、 Web API、 CSV 、 JSON 、 XML 、Apps Script Services 、 JDBC API などからデータを取得できます。 今回は、 REST API からデータを取り込むコネクタを作りたいと思います。 REST API のサービスとして、 Redmine をベースに説明していきます。 基本的にどんな REST API でも構築の仕方は同様にできると思いますので、適宜ご自身のご利用したいサービスに置き換えてお読みください。 はじめにデータポータルの完成形のイメージをお見せすると、このような形になります。 WESEEK の開発手法 弊社では、アジャイル開発の管理ツールとして、この Redmine を使用しています。 sprint ごとにどれくらい速度(ベロシティ)が出せているかや、プロジェクトによってはある期間においてどれくらいストーリーポイントを消化しているかは気になるところです。 Redmine の REST API を利用して実績情報を取得し、データポータルで閲覧できるようになったら便利だなというのが、このコネクター作成のきっかけです。 Redmine の API を使う API の有効化 管理 > 認証 > RESTによるWebサービスを有効にする にチェックを入れて、 Redmine で REST API を利用できるようにします。 API アクセスキーの確認 Redmine REST API を呼び出すときは、 API アクセスキーが必要です。 個人設定 > APIアクセスキー から確認しておきます。 チケット一覧の取得 試しに REST API を利用して、チケット一覧を取得してみます。 HTTP Header に X-Redmine-API-Key を追加し、 API アクセスキーをセットします。 curl で呼び出してみると、下記のようにチケット一覧が取得できました。 $ curl -s --request GET 'https://[REDMINE HOSTNAME]/issues.json?project_id=1&limit=2' --header 'X-Redmine-API-Key: [REDMINE API TOKEN]' | jq . { "issues": [ { "id": 3503, "project": { "id": 1, "name": "[FILTERED]" }, "tracker": { "id": 5, "name": "タスク" }, "status": { "id": 1, "name": "新規" }, "priority": { "id": 2, "name": "通常" }, "author": { "id": 141, "name": "[FILTERED]" }, "fixed_version": { "id": 943, "name": "sprint-25" }, "parent": { "id": 103421 }, "subject": "動作確認", "description": "", "start_date": "2022-05-19", "done_ratio": 0, "created_on": "2022-08-29T07:36:06Z", "updated_on": "2022-08-29T07:36:06Z" }, { "id": 3502, "project": { "id": 1, "name": "[FILTERED]" }, "tracker": { "id": 5, "name": "タスク" }, "status": { "id": 1, "name": "新規" }, "priority": { "id": 2, "name": "通常" }, "author": { "id": 141, "name": "[FILTERED]" }, "fixed_version": { "id": 943, "name": "sprint-25" }, "parent": { "id": 103421 }, "subject": "PR & Merge #3240", "description": "PR\r\nタスク\r\n#3240", "start_date": "2022-05-19", "done_ratio": 0, "created_on": "2022-08-29T07:35:58Z", "updated_on": "2022-08-29T07:35:58Z" } ], "total_count": 82, "offset": 0, "limit": 2 } Redmine REST API の詳細は こちら を参照してください。 データポータル コミュニティ コネクタを作る Google データポータルの Codelab を参考に構築していきます。 Apps Script のプロジェクトを作り、下記の4つの関数を定義して、コネクタを作成していきます。 getAuthType() getConfig() getSchema() getData() Apps Script プロジェクトを作る Google Apps Script にアクセスします。 新しいプロジェクト をクリックします。 クラシック エディタを使用する をクリックします。 クラシック エディタは 2022 年 9 月までに廃止される予定ですが、 Codelab ではまだ クラシック エディタ を対象とした内容となっているため、こちらで解説します。新しいエディタでの構築方法が公開されましたら、内容をアップデートします。 これから、 コード.gs の中に機能を実装していきます。 最上部をクリックして、このプロジェクトに名前をつけます。 今回は RedmineDataStudioConnector としました。 getAuthType() の定義 コードを先に示します。 var cc = DataStudioApp.createCommunityConnector(); function getAuthType() { var AuthTypes = cc.AuthType; return cc .newAuthTypeResponse() .setAuthType(AuthTypes.NONE) .build(); } getAuthType() は、 データポータルがコネクタが使用する認証方法を知る必要があるときに呼び出されます。 認証方法は下記があります。 列挙値 説明 NONE コネクタに認証が必要ないことを示します。 OAUTH2 コネクタが認証に OAuth 2.0 を使用することを示します。 KEY コネクタが認証に API キーを使用することを示します。 USER_PASS コネクタが認証にユーザー名とパスワードを使用することを示します。 USER_TOKEN コネクタが認証にユーザー名とトークンを使用することを示します。 作成したコネクタを一般にも公開する場合には、適切な認証方法を選択することが必要です。今回は Codelab でも説明している NONE を利用します。 認証方法の詳細は こちら を参照してください。 getConfig() の定義 データポータルで作成したコネクタをデータソースとして追加する際に、コネクタに任意のパラメータを渡すようにできます。 このようなものです。 コードを下記に示します。 getAuthType() の後に続けて記載してください。 function getConfig(request) { var config = cc.getConfig(); config.newInfo() .setId('instructions') .setText('Enter the Redmine project ID to get a list of Issues for the project.'); config.newTextInput() .setId('project') .setName('Enter a project id'); config.setDateRangeRequired(true); return config.build(); } config.newInfo() では、ユーザーに指示や情報を提供するためのテキストを定義しています。 config.newTextInput() では、 1 行のテキストボックスを定義しています。今回は、 Redmine の プロジェクト ID の入力を促しています。 使用できる ConfigType の一覧は こちら を参照してください。 getSchema() の定義 getSchema() は、 データポータルにデータを読み込む際のスキーマを定義します。 この関数が返した結果は、データポータルのデータソースの編集で表示されるフィールド一覧に対応します。 コードを示します。 function getFields(request) { var cc = DataStudioApp.createCommunityConnector(); var fields = cc.getFields(); var types = cc.FieldType; var aggregations = cc.AggregationType; // データポータル上で `id` と一意に設定し、表示名を `ID` に、そのデータ型を `NUMBER` 型に設定しています fields.newDimension() .setId('id') .setName('ID') .setType(types.NUMBER); fields.newDimension() .setId('projectId') .setName('プロジェクト ID') .setType(types.NUMBER); fields.newDimension() .setId('project') .setName('プロジェクト') .setType(types.TEXT); fields.newDimension() .setId('trackerId') .setName('トラッカー ID') .setType(types.NUMBER); fields.newDimension() .setId('tracker') .setName('トラッカー') .setType(types.TEXT); fields.newDimension() .setId('statusId') .setName('ステータス ID') .setType(types.NUMBER); fields.newDimension() .setId('status') .setName('ステータス') .setType(types.TEXT); fields.newDimension() .setId('priorityId') .setName('優先度 ID') .setType(types.NUMBER); fields.newDimension() .setId('priority') .setName('優先度') .setType(types.TEXT); fields.newDimension() .setId('authorId') .setName('作成者 ID') .setType(types.NUMBER); fields.newDimension() .setId('author') .setName('作成者') .setType(types.TEXT); fields.newDimension() .setId('assignedToId') .setName('担当者 ID') .setType(types.NUMBER); fields.newDimension() .setId('assignedTo') .setName('担当者') .setType(types.TEXT); fields.newDimension() .setId('fixedVersionId') .setName('対象バージョン ID') .setType(types.NUMBER); fields.newDimension() .setId('fixedVersion') .setName('対象バージョン') .setType(types.TEXT); fields.newDimension() .setId('parent') .setName('親チケット ID') .setType(types.NUMBER); fields.newDimension() .setId('subject') .setName('題名') .setType(types.TEXT); fields.newDimension() .setId('description') .setName('説明') .setType(types.TEXT); fields.newDimension() .setId('startDate') .setName('開始日') .setType(types.YEAR_MONTH_DAY); fields.newDimension() .setId('createdOn') .setName('作成日') .setType(types.YEAR_MONTH_DAY_SECOND); fields.newMetric() .setId('doneRatio') .setName('進捗率') .setType(types.PERCENT) .setAggregation(aggregations.SUM); fields.newDimension() .setId('updatedOn') .setName('更新日') .setType(types.YEAR_MONTH_DAY_SECOND); fields.newMetric() .setId('storyPoints') .setName('ストーリーポイント') .setType(types.NUMBER) .setAggregation(aggregations.SUM); return fields; } function getSchema(request) { var fields = getFields(request).build(); return { schema: fields }; } チケット一覧の取得 で取得した内容をもとに、スキーマを定義しています。 データポータル上で、ディメンションとして定義したい場合は newDimension() を、 指標として定義したい場合は newMetric() を定義します。 setId() は、一意のキーを定義します。これは後述する getData() で、 REST API から取得した結果をパースするときに必要となります。 getName() はフィールド一覧に表示されるフィールド名を定義します。 setType() はフィールドのデータ型を指定します。 使用できるデータ型は こちら を参照してください。 getData() の定義 データポータル上で下記のイベントが発生すると、コネクタの getData() が呼び出されます。 ユーザーがダッシュボードにグラフを追加するとき ユーザーがグラフを編集するとき ユーザーがダッシュボードを表示するとき ユーザーが関連付けられたフィルタまたはデータ コントロールを編集するとき データポータルがデータサンプルを必要としたとき getData() はその名前の通り、対象とするデータベース(今回は Redmine REST API)から実際にデータを取得し、定義されたスキーマにパースしてデータポータルに返す処理を行います。 先にコードを示します。 function getData(request) { // 後述の「要求されたフィールドのスキーマを作成する」で詳しく説明します var requestedFieldIds = request.fields.map(function(field) { return field.name; }); var requestedFields = getFields().forIds(requestedFieldIds); // 後述の 「API からデータを取得して解析する」で詳しく説明します // Fetch and parse data from API var url = [ 'https://[REDMINE HOSTNAME]/issues.json?', 'project_id=', request.configParams.project, '&limit=100' ]; var headers = { 'X-Redmine-API-Key': PropertiesService.getScriptProperties().getProperty("REDMINE_API_KEY") }; var options = { "headers": headers }; var response = UrlFetchApp.fetch(url.join(''), options); var parsedResponse = JSON.parse(response).issues; var rows = responseToRows(requestedFields, parsedResponse); return { schema: requestedFields.build(), rows: rows }; } // 後述の「解析されたデータを変換し、要求されたフィールドをフィルタする」で詳しく説明します function responseToRows(requestedFields, response) { // Transform parsed data and filter for requested fields return response.map(function(issue) { var row = []; requestedFields.asArray().forEach(function (field) { switch (field.getId()) { case 'id': return row.push(issue.id); case 'projectId': return row.push(issue.project.id); case 'project': return row.push(issue.project.name); case 'trackerId': return row.push(issue.tracker.id); case 'tracker': return row.push(issue.tracker.name); case 'statusId': return row.push(issue.status.id); case 'status': return row.push(issue.status.name); case 'priorityId': return row.push(issue.priority.id); case 'priority': return row.push(issue.priority.name); case 'authorId': return row.push(issue.author.id); case 'author': return row.push(issue.author.name); case 'assignedToId': // レスポンスの issue.assigned_to のキーがなければ null を、そうでなければ id を返します return row.push(issue.assigned_to && issue.assigned_to.id); case 'assignedTo': return row.push(issue.assigned_to && issue.assigned_to.name); case 'fixedVersionId': return row.push(issue.fixed_version && issue.fixed_version.id); case 'fixedVersion': return row.push(issue.fixed_version && issue.fixed_version.name); case 'parentId': return row.push(issue.parent && issue.parent.id); case 'subject': return row.push(issue.subject); case 'description': return row.push(issue.description); case 'startDate': // レスポンスの issue.start_date のキーがなければ null を、そうでなければ 2022-08-29 という形式の日付を 20220829 に変換して値を返します return row.push(issue.start_date && issue.start_date.replace(/-/g, '')); case 'doneRatio': return row.push(issue.done_ratio/100); case 'createdOn': return row.push(issue.created_on && issue.created_on.replace(/-|:|T|Z/g, '')); case 'updatedOn': return row.push(issue.updated_on && issue.updated_on.replace(/-|:|T|Z/g, '')); case 'storyPoints': return row.push(issue.story_points); default: return row.push(''); } }); return { values: row }; }); } request オブジェクト getData() で参照されている request オブジェクトについて説明します。 データポータルからコネクタの getData() が呼び出されると、この request オブジェクトが渡されます。 request オブジェクトは下記のような構造になっています。 { configParams: object, scriptParams: object, dateRange: { startDate: string, endDate: string }, fields: [ { name: Field.name } ] } 例えば、 configParams は、 getConfig() の定義 で説明したフィールドに入力した内容が格納されます。このような構造です。 { configParams: { project: '1' }, ... } コネクタの getData() を実装する際は、 request オブジェクトの中身を適切に処理してデータを返します。 request オブジェクトの詳細は こちら を参照してください。 要求されたフィールドのスキーマを作成する request オブジェクトの fields から、データポータルからリクエストされたフィールドのスキーマを作成します。コネクタはAPIに問い合わせたデータのうち、これらのフィールドにフィルタして、結果をデータポータルに返すようにします。 var requestedFieldIds = request.fields.map(function(field) { return field.name; }); var requestedFields = getFields().forIds(requestedFieldIds); API からデータを取得して解析する 実際に API に問い合わせてデータを取得します。 チケット一覧の取得 で Redmine REST API を呼び出したのと同様のエンドポイントを url に定義します。 project_id には、 request オブジェクトの中にある configParams.project を渡しています。これはデータポータルのデータソースの設定時に入力されたプロジェクト ID の値です。 Redmine REST API は、 1 回のリクエストで 100 件までのデータしか取得できません。 100 件を超えるデータに対応するためには、ページを再帰的に追いかける実装が必要です。 今回は、シンプルにするためにこの実装を割愛しています。 // Fetch and parse data from API var url = [ 'https://[REDMINE HOSTNAME]/issues.json?', 'project_id=', request.configParams.project, '&limit=100' ]; Redmine REST API を呼び出す際には、 X-Redmine-API-Key ヘッダーの指定が必要でした。これは秘匿情報なため、コード上に直接記載するのは望ましくありません。 Google Apps Script のプロパティ サービスを使用して、秘匿情報を管理します。 ファイル > プロジェクトのプロパティ を開きます。 スクリプトのプロパティ タブをクリックし、下記のように追加します。 プロパティ には REDMINE_API_KEY を、 値 には Redmine API アクセスキー を入力します。 下記のように、 PropertiesService.getScriptProperties().getProperty() を使用して、定義したプロパティを参照できます。 var headers = { 'X-Redmine-API-Key': PropertiesService.getScriptProperties().getProperty("REDMINE_API_KEY") }; var options = { "headers": headers }; var response = UrlFetchApp.fetch(url.join(''), options); var parsedResponse = JSON.parse(response).issues; 解析されたデータを変換し、要求されたフィールドをフィルタする switch case 文を使い、リクエストされたフィールドの結果を返すようにパースしていきます。 case に記載しているキーは、 getSchema() で setId() した値を指定します。 function responseToRows(requestedFields, response) { // Transform parsed data and filter for requested fields return response.map(function(issue) { var row = []; requestedFields.asArray().forEach(function (field) { switch (field.getId()) { case 'id': return row.push(issue.id); case 'projectId': return row.push(issue.project.id); ... default: return row.push(''); } }); return { values: row }; }); } REST API の結果にキーが含まれない場合がある場合は、下記のようにして null safe にします。 case 'assignedToId': return row.push(issue.assigned_to && issue.assigned_to.id); REST API の結果をデータポータルのデータ型に変換することが必要になる場合があります。 createdOn は、 2022-08-29T07:35:58Z のような形で REST API から返されますが、データポータル上では 20220829073558 のような形式のデータが必要です。 下記のようにして、データの変換を行います。 case 'createdOn': return row.push(issue.created_on && issue.created_on.replace(/-|:|T|Z/g, '')); マニフェストの作成 マニフェストを作成して、データポータルからコネクタを追加する際のコネクタの情報を定義します。 表示 > マニフェスト ファイルを表示 をクリックします。 ファイルに application.json が表示されるようになりました。 application.json を下記のように書き変えます。 { "timeZone": "Asia/Tokyo", "dependencies": { }, "runtimeVersion": "DEPRECATED_ES5", "dataStudio": { "name": "Redmine", "logoUrl": "https://www.redmine.org/attachments/download/3462/redmine_fluid_icon.png", "company": "WESEEK, Inc.", "companyUrl": "https://weseek.co.jp/", "addonUrl": "https://weseek.co.jp/", "supportUrl": "https://weseek.co.jp/", "description": "Get a list of Issues in a Redmine project.", "sources": ["redmine"] } } dataStudio object について説明します。これらは、データポータルでデータソースを追加する際に表示される情報です。 name は、コネクタの名前です。 logoUrl はアイコン画像で、ここでは Redmine のロゴを設定しました。 company companyUrl はこのコネクタを製作した組織の情報を入力します。 addonUrl は、このコネクタの専用の詳細ページの URL を記載します。今回は作成していないため、 companyUrl と同じにしています。 supportUrl は、このコネクタのサポートページの URL を記載します。今回は作成していないため、 companyUrl と同じにしています。 description には、コネクタの説明を記載します。 sources には、このコネクタが利用できるデータソースのリストを列挙します。今回は、 redmine とのみ記載しています。 マニフェストの詳細は こちら を参照してください。 コネクタをデプロイする 公開 > マニフェストから配置 をクリックします。 Get ID をクリックし、表示された Deployment ID をコピーしておきます。 データポータルからコネクタを利用する 作成したコネクタに接続する データポータル を開き、 作成 > レポート をクリックして、新しいレポートを作成します。 データのレポートへの追加 で、 独自に作成 をクリックします。 Deployment ID に、先ほどコピーした Deployment ID を入力し、検証をクリックします。 下部にこのように表示されたら、 Redmine の枠をクリックします。 データポータルに表示したい Redmine プロジェクトの ID を入力し、追加をクリックします。 グラフを追加してデータを表示してみる ここでは、例として Redmine から取得したデータを元に表を描画してみます。 グラフを追加 > 表 をクリックし、任意の場所に配置します。 それぞれ下記のように選択します。 データソース Redmine ディメンション 題名 対象バージョン ステータス 作成日 指標 SUM: ストーリーポイント このような表が構築できます。 その他に棒グラフや、ウォーターフォールグラフなどを使用して、このようなダッシュボードを構築できます。
アバター
こんにちは takayuki です。 Google Anatlytics 4 (以下、GA4) 、 Google Tag Manager (以下 GTM) 、 Google データポータル で、サイトのスクロール率を計測し、確認できる環境を構築する方法を紹介します。 本記事は、 GA4 を対象としています。 ユニバーサル アナリティクス を利用中の場合は、先に GA4 へ移行 してください。 はじめに完成形を紹介すると、下記のようなものを構築していきます。 この図では、閲覧したユーザーがページ中のどの位置までスクロールして離脱したかがわかり、記事の内容を改善する箇所を特定するのに役立ちます。 記事のいちばん最後の「完成」のところで詳しく説明しています。 GA4 のスクロールイベント GA4 は標準で スクロール数 イベントを計測しています。 これは、ユーザーがページの90%までスクロールしたときにイベントが発生します。 ユーザーがページのどこまで閲覧しているか、細かなスクロール率を測りたい場合は、 GA4 だけでは実現できません。 GTM を使うことでこれを解決します。 GTM の設定 アカウントとコンテナの作成 GTM を初めて利用する場合は、先にアカウントとコンテナの作成を行ってください。 こちら を参考に、アカウントとコンテナの作成を行います。 コンテナが作成できたら、 GA4 設定 タグ を追加します。 これまで GA4 の Google タグで直接計測していたものを、 GTM の Google タグ経由で GA4 に計測されるようにします。 タグ を選択し、 新規 をクリックします。 タグタイプを選択して設定を開始… をクリックし、 Google アナリティクス: GA4 設定 を選択します。 測定 ID に GA4 の対象プロパティの 測定 ID を入力します。 G-XXXXXXXXXXX のような文字列です。 トリガー は、 All Pages を選択します。タグの名前は GA4 設定 タグ とします。 公開 をクリックし、 送信設定 を バージョンの公開と作成 にし、必要であれば バージョン名 と バージョンの説明 を入力します。未入力でも大丈夫です。よければ 公開 をクリックします。 GTM タグの設置 サイトに GA4 の Google タグが設置されている場合は、 これを GTM のタグに置き換える必要があります。 GA4 の Google タグはこのようなものです。 <!-- Google tag (gtag.js) --> <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script> <script> window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', 'G-XXXXXXXXXX'); </script> GTM の 管理 をクリックし、 Google タグマネージャーをインストール をクリックします。 表示されたタグの内容をコピーし、サイトの GA4 の Google タグと置き換えます。 トリガーの作成 スクロールが発生したら、タグが発火できるように、トリガーを作成します。 トリガー を選択し、 新規 をクリックします。 トリガーのタイプを選択して設定を開始… をクリックし、 スクロール距離 をクリックします。 縦方向スクロール距離 にチェックし、 割合 に 10,20,30,40,50,60,70,80,90,100 のようにカンマ区切りで計測したいスクロール率を入力します。 サイトのすべてのページでスクロール率を測りたい場合は、 このトリガーの発生場所 は すべてのページ を選択します。 一部のページ を選択すると、任意の条件を設定して、特定のページのみスクロール率のイベントが発生するようにできます。 組み込み変数の設定 後述する タグの設定 で、スクロール率の位置(10%,20%,30%…など)を計測できるように、変数の設定を行います。 変数 を選択し、 設定 をクリックします。 組み込み変数の設定 で、 Scroll Depth Threshold を選択します。 タグの設定 タグ を選択し、 新規 をクリックします。 タグタイプを選択して設定を開始… をクリックし、 Google アナリティクス: GA4 イベント を選択します。 設定タグ には、 アカウントとコンテナの作成 で作成した GA4 設定 タグ を選択します。 イベント名 は任意に設定します。 ここでは scroll_less_than_100 と設定します。 イベント パラメータ に、どこまでスクロールしたか割合ごとに計測できるようにパラメータを追加します。パラメータ名は任意で、ここでは percent_scrolled とします。値は {{Scroll Depth Threshold}} とします。 これは 組み込み変数の設定 で追加を行ったもので、 {{}} で囲んで変数を参照します。 トリガーを選択してこのタグを配信… をクリックし、作成済みのトリガー スクロール距離 を選択します。 タグを確認して公開 プレビュー をクリックし、設定したタグが正しく動作するか確認します。 サイト内の任意のページの URL を入力し、 Connect をクリックします。 入力した URL のページが開いたら、実際にスクロールしてみます。 Summary の下に Scroll Depth が表示され、 Tags Fired で作成したスクロールイベントが発火していれば成功です。発火したタグをクリックすると、設定した percent_scrolled パラメータの内容も確認できます。 ここで 公開 をして、 GTM の設定を反映させます。 Google Analytics の設定 カスタム ディメンション の設定 後述するデータポータルで、スクロール率の位置(10%,20%,30%…など) を参照できるように、 カスタム ディメンションを追加します。 GA4 の 設定 > カスタム定義 をクリックし、 カスタム ディメンションを作成 をクリックします。 ディメンション名 に percent_scrolled を入力し、 イベント パラメータ は percent_scrolled を選択します。 イベントの確認 レポート > エンゲージメント > イベント をクリックし、 GTM で設定したスクロールイベントが計測できているか確認します。なお、タグを設定してから GA4 で確認できるようになるまでは、 1 日程度時間がかかります。 データポータルの作成 レポートの作成とデータの追加 データポータル にログインし、 作成 > レポート をクリックします。 はじめにデータソースを選択します。 Google アナリティクス をクリックします。 対象のプロパティを選択し、 追加 をクリックします。 スクロール率の出し方 まず、スクロール率の出し方を考えてみます。スクロール率は、 各スクロール率の位置(10%,20%,30%…など)において、 スクロール率/ページビュー とすることで算出できます。 ページビューは、データポータルでは 視聴回数 という指標で取得できます。これは内部的には GA4 の page_view というイベント名から取得しています。スクロール率は、 タグの設定 で作成した scroll_less_than_100 というイベント名で取得できます。 イベント名ごとにイベント数を表示してみると、このような形で記録されていることが確認できます。具体的な表示の仕方は後述します。 scroll_less_than_100 イベントはさらに、 percent_scrolled パラメータで各スクロール率の位置(10%,20%,30%…など)のイベント数を保持しています。 視聴回数(page_view) の一覧データと、 percent_scrolled パラメータの値の一覧データを結合して1つの表にすれば、ページごとのスクロール率を表示できそうです。 データの結合 データポータルの統合の機能を使ってこれを実現します。 まず、 視聴回数(page_view) の表を作成します。 グラフを追加 > 表を選択します。 任意の場所をクリックして表を追加します。 ディメンション に ページ タイトル 、 ページの完全な URL を、 指標 に 視聴回数 を選択します。 次に、 percent_scrolled パラメータの値の表を作成します。もう 1 つ表を追加し、今度は、 ディメンション に ページ タイトル 、 ページの完全な URL 、 percent_scrolled を、 指標 に イベント数 を選択します。 イベント数 はすべての種類のイベント数が表示されます。 percent_scrolled パラメータを持っているイベントは scroll_less_than_100 だけですので、フィルタ機能を使ってこのイベントだけに制限します。 フィルタを追加 をクリックします。下記のように、 一致条件 イベント名 次に等しい (=) scroll_less_than_100 と選択します。 AND をクリックし、 除外条件 percent_scrolled null である も追加し、保存します。 すると、この表が完成します。 ctrl キーを押しながら作成した 2 つの表を選択し、右クリックして データを統合 をクリックします。 新しい表が作成されたら、グラフの種類を 表 から ヒートマップ付きピボットテーブル に変更します。 列のディメンション は、 percent_scrolled を選択します。 指標 は複数表示されている場合は削除して 1 つにし、残った 1 つをクリックして フィールドを作成 をクリックします。 下記のように入力します。 計算式 に SUM(イベント数)/SUM(視聴回数) とすることで、スクロール率を算出しています。 タイプ は % にします。 完成 データポータルで GA4 で計測したページごとのスクロール率が確認できるようになりました。 各行は、ページタイトルごとにスクロール率を表しています。 10~100 の列で各スクロール率の位置を 10% 間隔で表しています。例えば、次の記事ではこのようなことがわかります。 ページ上部から 10% までの高さのコンテンツを閲覧したユーザーは、このページへアクセスしてきたユーザーのうち 90% ページ上部から 20% までの高さのコンテンツを閲覧したユーザーは、このページへアクセスしてきたユーザーのうち 80% ページ上部から 30% までの高さのコンテンツを閲覧したユーザーは、このページへアクセスしてきたユーザーのうち 80% ページ上部から 40% までの高さのコンテンツを閲覧したユーザーは、このページへアクセスしてきたユーザーのうち 50% このスクロール率の一覧によって、ユーザーが記事のどの位置で離脱してしまっているのかが分かるようになります。スクロール率を活用することで、離脱率の高い位置を中心に、記事内容の改善を図ることができるようになります。
アバター
はじめに こんにちは、WESEEK にてエンジニアをしている藤澤です。 この記事では keepalived と real server を同一筐体にのせ、 VRRP(Virtual Router Redundancy Protocol) による冗長構成, LVS による負荷分散を行う方法について解説します。 冗長構成/負荷分散を行う方法はいくつかありますが、今回は keepalived を使って実現します。 keepalived のメリット/デメリット keepalived を使うメリットとして 無料 LVS がカーネル空間で処理するので比較的早い。 また、デメリットとしては nginx 等で行われるような L7 header による処理等ができない などがあります 前提知識 構築の話をメインとするので概要のみ説明します。 VRRP(Virtual Router Redundancy Protocol) VRRP はサーバやデフォルトゲートウェイ等を冗長化するためのプロトコルです。 複数台ある VRRP を適用したサーバに対して同一の仮想 IP (VIP)を割り当てることで一台のサーバのように見せかけることが出来ます。 ある 1 台のサーバが master となり VIP へのリクエストを受け付けます。他のサーバは backup となり、master、backup が相互に死活監視しあうことで master が死んだ際には backup が master に昇格することで冗長性が保たれています。 今回の構成では keepalived が持っている VRRP の機能を利用します LVS(Linux Virtual Server) LVS とは L4 負荷分散機能を提供するソフトウェアです。 IPVS(IP Virtual Server) という Linux カーネルモジュールで提供された機能をもとに動作するので比較的速いです。 LVS の構成として NAT と DR があります。以下ではその概要とメリット/デメリットについて述べます。 また、実際にアプリケーションがいるサーバを real server と呼びます。 NAT VIP 宛のパケットを受け取った LVS は real server へパケットを適切に(ラウンドロビン等)送信し、real server はパケットを処理し、再び LVS へ送信します。 応答のパケットを受け取った LVS は送信元アドレスを LVS のものに変換しクライアントへ送信します。 つまり LVS が NAT パケットを転送する構成になります。 メリット DR に比べて設定が楽 real server は外部と疎通がなくても LVS のあるサーバが疎通を持てば良い デメリット LVS へ負荷が集中する DR VIP 宛のパケットを受け取った LVS は real server へパケットを適切に(ラウンドロビン等)送信し、real server は直接パケットを処理してクライアントへ返します。 メリット NAT と違い LVS へ返さないので LVS の負荷が低い デメリット 特に同一筐体では特殊な設定が必要 今回はクライアント、実サーバ、LVS が同一セグメントにある場合を想定し、こちらの DR の方式を採用します。 Amazon EC2 で検証 EC2 でインスタンスを 3 台建てる スペック Canonical, Ubuntu, 22.04 LTS, amd64 jammy image build on 2022-06-09 64bit (x86) t2.micro storage: 8GiB gp2 VPC で同一サブネットに作成後、セキュリティグループ等で相互に通信できるようにしましょう 今回の構成 今回のシステム構成です、クライアントは VIP へリクエストすることで keepalived によって VRRP master となっているノードへリクエストが届きます。 その後 LVS によって各ノードの application へ適切に振り分けられます。 今回の記事では real server を vrrp_instance に属するノード, application を real server 上で 8000番ポートで動く http server として説明します。 注: 今回の構成では VRRP グループに属するノード内部からのリクエストは正常に処理できません。 注: application を docker で起動すると正常に振り分けが出来ない場合があります。今後時間があれば対処法を調べて追記します。 keepalived 導入 以下からは実際の設定方法と設定項目について解説します。 各ノード共通 各ノードで共通に実行してください。 ただし、keepalived.conf の priority のみ変更してください。 keepalived 関連の設定 $ sudo apt-get update $ sudo apt-get install -y keepalived $ keepalived --version # インストール出来ていることを確認 ここまでで keepalived のインストールが完了 $ sudo vi /etc/keepalived/keepalived.conf # 以下のコンフィグを入れる # priority はノードごとに変えてください global_defs { enable_script_security } vrrp_instance LVS_SETTINGS { state BACKUP # state は priority を見て自動で MASTER BACKUP 切り替えをしてくれるので全て BACKUP で指定 interface eth0 # 受け付けるインターフェースを指定 virtual_router_id 50 # VRRP に属するグループをユニークに指定する id priority 100 # Master state にするノードの優先度(ノードごとに変更してください) advert_int 1 # VRRP による生存確認パケットの間隔 notify_master "/etc/keepalived/notify_master.sh" # 後述 notify_backup "/etc/keepalived/notify_backup.sh" # 後述 virtual_ipaddress { 10.0.0.5 # VIP を指定してください } } include virtualserver-backup.conf # 後述 $ sudo vi /etc/keepalived/virtualserver-backup.conf # 空で ok global_defs 各ノード共有の設定を入れるところ enable_script_security: 今回は master/backup で設定を切り替えるスクリプトを走らせるのでセキュリティ上 root で実行出来ないようにしている $ sudo vi /etc/keepalived/virtualserver-master.conf # 以下のコンフィグを入れる virtual_server 10.0.0.5 8000 { # 対象の VIP を指定 delay_loop 6 # アプリの生存確認間隔 lb_algo rr # 負荷分散手法 lb_kind DR # パケット転送方式 protocol TCP # 対象のプロトコル real_server 10.0.0.12 8000 { weight 1 # 負荷分散で使う重み HTTP_GET { # 生存確認のプロトコル url { path /health # 生存確認のパス } connect_timeout 10 } } real_server 10.0.0.14 8000 { weight 1 HTTP_GET { url { path /health } connect_timeout 10 } } } $ sudo vi /etc/keepalived/notify_master.sh # 以下のコンフィグを入れる #!/bin/bash -u sudo sed -i -e "s/include virtualserver-backup.conf/include virtualserver-master.conf/g" /etc/keepalived/keepalived.conf sudo service keepalived reload state が MASTER になったときに virtualserver-master.conf を使うようにすることで、virtualserver を適用する。 $ sudo vi /etc/keepalived/notify_backup.sh # 以下のコンフィグを入れる #!/bin/bash -u RELOAD_FLG=0 grep "include virtualserver-backup.conf" /etc/keepalived/keepalived.conf > /dev/null # Normally the exit status is 0 if a line is selected, 1 if no lines were selected, and 2 if an error occurred. see: man grep if [ $? -ne 0 ] ; then RELOAD_FLG=1 fi sudo sed -i -e "s/include virtualserver-master.conf/include virtualserver-backup.conf/g" /etc/keepalived/keepalived.conf # reload のタイミングで BACKUP state に移行して再度このスクリプトが実行されるため、無限実行を防ぐためにチェックを行う if [ ${RELOAD_FLG} -eq 1 ] ; then sudo service keepalived reload fi state が BACKUP になったときに virtualserver-backup.conf を使うようにすることで、virtualserver の設定を削除する。 今回は同一筐体にアプリケーションと keepalived(LVS) を載せる構成になっています。その場合 backup で LVS を起動していると master の LVS によって backup へ振り分けられたパケットが再び LVS に捕まり別のノードへ振り分けられループします。 そのため、notify_master/backup (VRRP state が切り替わった際に実行されるスクリプト)によって keepalived の設定を書き換えています。 keepalived が script を実行する user を作成 master/backup 切り替え時に keepalived が script を実行するためのユーザを作成します。 $ sudo useradd keepalived_script $ sudo visudo # 以下を追記 # keepalived_script ALL=(ALL:ALL) NOPASSWD: ALL スクリプトへ実行する権限をつけます。 $ cd /etc/keepalived $ sudo chmod u+x notify_backup.sh notify_master.sh $ sudo chown keepalived_script:keepalived_script notify_backup.sh notify_master.sh DR 用の設定 同一筐体で DR を使う場合いくつかネットワークの設定が必要になります。 sysctl.conf sudo vi /etc/sysctl.d/98-keepalived.conf # ファイル名はアルファベット順に読み込まれるのでそこまで気にしなくても大丈夫です # 以下の設定を入れる # Do not rename this file so it is run at the end of /etc/sysctl.d/*.conf files load. # Run `sysctl -p` to apply # パケットを転送するために IP フォーワードを許可 net.ipv4.ip_forward = 1 # ARP リクエストが eth0 に来たときに eth0 に vip が設定されていなかったとしても、 # lo に vip が設定されていると ARP レスポンスを返してしまうため、 # それを防ぐために eth0 に設定されていないアドレスに対する ARP リクエストには応答しないようにしている # see: https://www.valinux.co.jp/technologylibrary/document/linux/arp0001/ net.ipv4.conf.eth0.arp_ignore = 1 net.ipv4.conf.eth0.arp_announce = 2 # LVS 間での通信とクライアントとの通信で異なる NIC を使用する場合は rp_filter を無効化(0)に # net.ipv4.conf.eth0.rp_filter = 0 $ sudo sysctl -p # 適用する lo に VIP をつける DR を同一筐体で用いる場合、real server が VIP 宛の通信を処理する必要があります。 STATE が MASTER である real server は keepalived によって NIC に VIP が割り当てられるのですが、STATE が BACKUP である real server では NIC に VIP が割り当てられないので、今回は lo に VIP を割り当てることで VIP 宛のパケットを処理できるようにします。 $ sudo vi /etc/netplan/99_lo_config.yaml # 以下のコンフィグを入れる # This config is used for applying vip to lo interface. # By this, each real server is able to use vip as its own ip address. # Run `netplan apply` to apply network: version: 2 renderer: networkd ethernets: lo: addresses: - 10.0.0.5/32 $ sudo netplan apply # 適用する 以下のように lo に VIP (10.0.0.5) がついていることが確認できれば ok です。 ubuntu@ip-10-0-0-12:~$ ip a show lo 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet 10.0.0.5/32 scope global lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 検証 最初に keepalived を再起動してください。 $ sudo systemctl restart keepalived # keepalived の再起動 適当なアプリケーションを 8000 番で起動 本題とは外れるので詳しくは説明しませんが、参考までに今回使用したサンプルを載せておきます。 test-webserver.rb require 'webrick' srv = WEBrick::HTTPServer.new( DocumentRoot: './', BindAddress: "0.0.0.0", Port: 8000, ) srv.mount('/', WEBrick::HTTPServlet::FileHandler, 'index.html') srv.start index.html hello world (real server (区別するためにノードごとに別の文字を入れてください)) 上記を ruby で実行 まず設定がうまくいっているかどうか確認 $ sudo journalctl -u keepalived Entering MASTER/BACKUP STATE のような記述があれば ok です。 VIP 宛の通信が冗長/負荷分散されていることを確認する 検証用のクライアントから VIP へ向けて curl し、負荷分散されていることを確認する $ curl http://10.0.0.5:8000/ hello world(real server 1) $ curl http://10.0.0.5:8000/ hello world(real server 2) $ curl http://10.0.0.5:8000/ hello world(real server 1) $ curl http://10.0.0.5:8000/ hello world(real server 2) 以上のように交互に real server へ送られることが確認できれば ok です。 real server のアプリケーションを落とし、冗長化されていることを確認する real server でアプリケーションを落として、前項目同様 curl を実行します。 生きている方の real server の通信のみが返ってくれば ok です。
アバター
調べたい単語を入力すると wikipedia の検索結果が返ってくる LINE Bot を作る 概要 GROWI エンジニアの 宮沢 です。今回は、LINE bot を開発することができる、line-bot-sdk-nodejs と Javascript で wikipedia の情報を取得できる WIKIJs を組み合わせて、調べたい単語を入力すると、Wikipedia の検索結果が返してくれる LINE bot を作ってみたいと思います。 LINE Messaging API SDK について 今回利用する line-bot-sdk-nodejs は LINE社が提供している Messaging API を Javascript で簡単に操作できるできるライブラリとなっています。 Messaging API 概要 LINE Messaging API SDK を使ってオウム返しを実装してみる まずは line-bot-sdk-nodejs を使って、入力した言葉をそのまま返すオウム返しを作ってみます。 例) オウム返し 準備 以下の準備を済ませておきましょう。 LINE bot コンソールの登録、Bot の作成、アクセストークンとアクセスシークレットの取得 -参考: https://developers.line.biz/ja/docs/messaging-api/getting-started/ ngrok のインストール トンネリングを用いてローカルサーバーをインターネット上に公開するためのもの Bot をテストする用の簡易サーバー的なやつ Homebrew をお使いの場合は brew install ngrok でインストールできます オウム返し Bot の実装 適当なプロジェクトを作成して、実装していきます。 # 適当なディレクトリの作成 $ mkdir wikipedia-linebot-sample # Change Directory $ cd wikipedia-linebot-sample # 適当なプロジェクトの作成 $ yarn init # 必要なライブラリのインストール $ yarn add @line/bot-sdk dotenv express .env ファイルをプロジェクトルートに作成して、先ほど取得したアクセストークン、アクセスシークレットを環境変数にセットします。 CHANNEL_ACCESS_TOKEN=<Your Access Token> CHANNEL_SECRET=<Your Access Secret> ちょうど line-bot-sdk-nodejs にオウム返しの サンプルコード があったのでこれをそのまま使ってみます。(一部コード、コメントの修正をしています) const line = require('@line/bot-sdk'); const express = require('express'); require('dotenv').config(); // アクセストークン、アクセスシークレットを入れる const config = { channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN, channelSecret: process.env.CHANNEL_SECRET, }; // LINE Bot SDK クライアントの作成 const client = new line.Client(config); // Express APP の作成 const app = express(); app.get('/', (req, res) => { res.send('Hello line bot sample!'); }); // ミドルウェアにウェブフックハンドラを登録 // line.middleware は LINE の Webhook からのアクセスを検証する // X-Line-Signature ヘッダーのないリクエストは処理されない app.post('/callback', line.middleware(config), (req, res) => { Promise .all(req.body.events.map(handleEvent)) .then((result) => res.json(result)) .catch((err) => { console.error(err); res.status(500).end(); }); }); // イベントハンドラー const handleEvent = (event) => { if (event.type !== 'message' || event.message.type !== 'text') { // イベントタイプがテキストメッセージ以外のものは処理しない return Promise.resolve(null); } // クライアントから入力された文字列をセット const echo = { type: 'text', text: event.message.text }; // リプライ API を用いてクライアントから入力さレた文字列をそのまま返信 return client.replyMessage(event.replyToken, echo); } // listen on port const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`listening on ${port}`); }); プロジェクトルートに index.js を作成、以上のコードを記述します。 現在の /wikipedia-linebot-sample の中身は以下のようになっていると思います。 . ├── index.js ├── node_modules ├── package.json └── yarn.lock 次にサーバーの起動、ngrok を使ってインターネット上に公開します # サーバー起動 $ node index.js # 3000 番で起動しているサーバーをインターネット上に公開 $ ngrok http 3000 ngrok コマンドが無事成功すると接続状況などが書かれた画面が表示され、公開された URL( https://5d8d-113-41-130-33.ngrok.io みたいなやつ) が取得できると思います。この URL を LINE bot コンソールで作成した Bot の Webhook URL に設定します。 これでオウム返しの Bot が完成です。LINE で作成した Bot を友達追加して遊んでみましょう。 WIKIJs を使って Javascript で Wikipedia を検索してみる ドキュメント: https://dijs.github.io/wiki/ 何はともあれライブラリのインストール(/wikipedia-linebot-sample) $ yarn add wikijs 単語を入れるとその概要を出力するコードを書いてみます。 const wiki = require('wikijs'); // 日本語の検索結果が返ってくる API const apiUrl = 'https://ja.wikipedia.org/w/api.php'; const getWikipediaSummary = async(searchText) => { try { // 引数で調べたい単語を取得、wikipedia で検索する const page = await wiki.default({apiUrl}).page(searchText); // Wikipedia の検索結果の概要を取得し返却 const pageInfo = await page.summary(); return pageInfo } catch (err) { console.log('err'); } }; const main = async() => { const wikipediaSummary = await getWikipediaSummary('チェンソーマン'); console.log(wikipediaSummary); }; main(); プロジェクトルートに wikipedia.js を作成、以上のコードを記述して実行してみます。 出力結果 『チェンソーマン』(Chainsaw Man)は、藤本タツキによる日本の漫画作品である。『週刊少年ジャンプ』(集英社)にて第1部「公安編」が2019年1号から2021年2号まで連載され、第2部「学園編」は『少年ジャンプ+』(同社)にて2022年7月13日より連載中。2022年7月時点でコミックス計11巻の累計部数は1300万部を突破している。 数行のコードでここまでやってくれるのは便利ですね〜。      組み合わせる では最後に、wikipedia の検索結果が帰ってくる LINE bot に改造してみます。先ほど作成した wikipedia.js と index.js を書き換えます。 wikipedia.js const wiki = require('wikijs'); const apiUrl = 'https://ja.wikipedia.org/w/api.php'; const getWikipediaSummary = async(searchText) => { try { const page = await wiki.default({apiUrl}).page(searchText); const pageInfo = await page.summary(); return pageInfo } catch (err) { console.log('err'); } }; module.exports = getWikipediaSummary; index.js const line = require('@line/bot-sdk'); const express = require('express'); require('dotenv').config(); const getWikipediaSummary = require('./wikipedia'); const config = { channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN, channelSecret: process.env.CHANNEL_SECRET, }; const client = new line.Client(config); const app = express(); app.get('/', (req, res) => { res.send('Hello line bot sample!'); }); app.post('/callback', line.middleware(config), (req, res) => { Promise .all(req.body.events.map(handleEvent)) .then((result) => res.json(result)) .catch((err) => { console.error(err); res.status(500).end(); }); }); const handleEvent = async(event) => { if (event.type !== 'message' || event.message.type !== 'text') { return Promise.resolve(null); } // クライアントから入力された文字列から wikipedia の概要を取得しクライアントに返却 const wikipediaSummary = await getWikipediaSummary(event.message.text); const message = { type: 'text', text: wikipediaSummary != null ? wikipediaSummary : '検索結果が見つかりませんでした' }; return client.replyMessage(event.replyToken, message); } const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`listening on ${port}`); }); ひとまずこれで、wikipedia の検索結果が帰ってくる LINE Bot の完成です。LINE で調べたい単語を入力して遊んでみてください。 今回は Javascript で作成しましたが、筆者が学生自体に Python で作ったもの が Heroku で動いているので友達登録をして遊んでみてください。
アバター
皆さんこんにちは!WESEEK ソフトウェアエンジニアの 増山 です。 今回はブラウザ上で実行される JavaScript の正規表現がテーマです。正規表現リテラルと RegExp クラスは何が違うのか、どんなメリットデメリットがあるのかについて解説します。また、ブラウザ上で実行する際の注意点についても説明します。 目次 はじめに JavaScript の実行環境は主にサーバーサイドで使われる Node.js だったり、ブラウザで使われる Chrome の v8 engine や Safari の JavaScriptCore などがあります。 ウェブアプリケーションを開発していると特にブラウザ環境の差異によって Chrome では動くけど Safari では動かない!なんて場面に遭遇することもあります。 正規表現も、ブラウザの影響を受ける要因の一つです。今回は、JavaScript の正規表現をブラウザで使うときに筆者の 増山 が考えていること、気をつけていることを含めて書いていきます。 また、記事の最後には GROWI で実際に行われている対策についても紹介します。 JavaScript で正規表現を扱う JavaScript で正規表現を利用する方法は主に2種類あります。 正規表現リテラル 正規表現リテラルは JavaScript の構文として用意されている書き方です。以下のように書くことで RegExp オブジェクトと同等のオブジェクトとして扱うことができます。 const regexp = /^START[1-9]{1,3}END$/; console.log(regexp.test('START123END')); // true RegExp クラス RegExp クラスは JavaScript が標準で用意している正規表現を扱うためのクラスです。以下のように初期化して使います。 const regexp = new RegExp('^START[1-9]{1,3}END$'); console.log(regexp.test('START1234END')); // false メリットデメリット比較 正規表現リテラルと RegExp クラスを比較します。 RegExp は動的に変更可能 RegExp クラスは初期化のときに正規表現を文字列として渡すことができます。ここでテンプレートリテラルを使用することで、動的に正規表現を変えることが可能です。一方で正規表現リテラルでは値を動的に変更できません。 let userInput = '入力'; const regexp = new RegExp(`^START${userInput}END$`); 正規表現リテラルはパフォーマンス良 正規表現リテラルで定義した正規表現はブラウザで JavaScript がロードされる際のコンパイル時に同時にコンパイルされます。逆に RegExp クラスを使用した場合は RegExp オブジェクト初期化時に正規表現がコンパイルされるため、初期化して実行するまでにコンパイル分の遅延が発生します。 よって、動的に値を変更しない場合は正規表現リテラルを使うほうがパフォーマンスが良くなります。 注意点 正規表現リテラルが JavaScript ロード時にコンパイルされるのにはデメリットも存在します。正規表現の文法が間違っていたり、ブラウザがその正規表現をサポートしていなかったりする場合はコンパイルエラーが発生します。これは JavaScript 実行前に発生するので、実行中の例外処理などで対処できません。 実際に正規表現リテラルを使用する際には、利用が想定されているブラウザで一度コンパイルしてエラーが発生しないことを確かめるようにしましょう。続けて GROWI の開発で行われている対策についてもみていきましょう。 GROWI でやってること 正規表現の一つである 後読み は Safari ~v15.6 ではサポートされていないため、正規表現をコンパイルしたときにエラーが発生します。よって正規表現リテラルの代わりに RegExp クラスを使用して、その初期化処理を try-catch で囲んで例外処理を実施します。 // 安全な正規表現 let PATTERN_DEFAULT = /^((.*)\/)?(.+)$/;https://regex101.com/r/jpZwIe/1 try { // 後読みを含む正規表現 PATTERN_DEFAULT = new RegExp('^((.*)(?<!<)\\/)?(.+)$'); // https://regex101.com/r/HJNvMW/1 } catch (err) { // 例外処理 } GROWI の実際のコード これでプログラムが正常に実行できます。 さらに GROWI では、開発中に間違えて危険な正規表現を混合させてしまわない様に eslint を設定しています 。 これで後読みを含む正規表現を記述した場合は lint error が出てくれるので、気づいてその場で修正できます。 まとめ 今回は JavaScript の正規表現で正規表現リテラルと RegExp クラスを使うときの違いや、ブラウザ上の JavaScript で正規表現を扱うときの注意点について触れてみました。もしコメントやご意見等ありましたら 増山の Twitter の DM にて教えてください。 参考文献 https://stackoverflow.com/questions/36911960/javascript-regexp-constructor-vs-regex-literal https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp https://docs.microsoft.com/en-us/dotnet/standard/base-types/compilation-and-reuse-in-regular-expressions
アバター
はじめに 今回の記事では WordPress REST API を使用して、ホームページにブログの一覧を表示する方法をお伝えします。 ブログの一覧を表示するだけでなく、投稿日時で絞り込んだり、カテゴリーで絞り込む方法についても詳しく解説します。 前半は WordPress REST API の解説を行い、後半は WESEEK ホームページの WordPress REST API を用いたブログページの実装を紹介します。 WordPress REST API とは まず REST API について解説します。 REST (Representational State Transfer) API は、API (アプリケーションプログラミングインターフェイス) の一種で、Web アプリケーション同士の通信をサポートします。 REST は多くの場合 HTTP を使用しており、JSON(JavaScript Object Notation)という形式でデータを転送します。 REST を用いることでシンプルかつ効率的なやり取りを行えるため、Web 上のデータを取得する際に最もよく使用される選択肢となっています。 REST で用いる代表的な HTTP コマンドには以下のものがあります。 GET: 指定したリソースを取得する POST: 新しいリソースを作成する PUT: 既存のリソースを更新する DELETE: 既存のリソースを削除する WordPress REST API とは、これらの HTTP リクエストを送信することで WordPress の記事のデータを取得したり、記事の投稿を行える API です。 WordPress REST API での操作 では実際に WordPress REST API を用いて様々な操作を行います。 今回の解説では HTTP のリクエストに curl コマンドを使用するので、実際に試してみたいという方はダウンロードしておきましょう。 記事データの取得 WordPress REST API で最も良く実行されるであろう、記事データの取得について解説します。 記事データの取得は、後述する記事の投稿・更新・削除と異なり、記事が公開されている状態であれば誰でも実行できます。 記事データ取得のための基本的なコマンドは以下のようになります。 $ curl -X GET https://[API route]/wp-json/wp/v2/posts?[オプション] (curl のデフォルトの HTTP メソッドは GET なので、 -X GET の部分は省略可) API route の部分には、弊社の場合 会社ブログ: weseek.co.jp/blog 技術ブログ: weseek.co.jp/tech が入ります。 (WordPress REST API では、厳密には /wp-json/ まで含むものを API route と言います。 詳細 ) この記事では、操作対象の WordPress に https://wpdemo.net/ をお借りしてコマンドを紹介します。 では実際にコマンドを打ち込んで実行結果を確認します。 $ curl -X GET https://wpdemo.net/wp-json/wp/v2/posts [{"id":970,"date":"2021-10-07T11:00:38","date_gmt":"2021-10-07T15:00:38","guid":{"rendered":"https:\/\/wpdemo.net\/?p=970"},"modified":"2021-10-07T11:01:07","modified_gmt":"2021-10-07T15:01:07","slug":"update-override-demo-parameters","status":"publish","type":"post","link":"https:\/\/wpdemo.net\/news\/update-override-demo-parameters-p970","title":{"rendered":"Update: Override Demo Parameters"},"content":{"rendered":"<div class='app_site_dl_wrapper'> ...(省略) json 形式で記事のデータが取得できていることが確認できます。 オプションを何も渡さなかった場合は記事の中身の HTML まで取得できるので、データの分量はなかなかの大きさになります。 次にオプションについて解説します。 URL のパラメータに値を渡すことで、より具体的な条件で記事のデータを取得できます。 どのようなパラメータを設定できるのか、よく使われるものから順に解説します。 全てのパラメータを知りたい場合はこちらをご覧ください。 https://developer.wordpress.org/rest-api/reference/posts/#arguments パラメータ名 初期値 説明 _fields author, id, excerpt, title, link を指定することができる。指定したフィールドのデータのみ取得する。 _embed 通常のデータに加えてアイキャッチ画像などのデータを得られる。このパラメータは値を必要としない。 per_page 10 得られる記事データの最大数を設定する。 page 1 指定したページの記事を取得する。 order desc 取得できるデータを昇順か降順で並び替える、asc or desc を指定する。 orderby date author, date, id, include, modified, parent, relevance, slug, include_slugstitle のいずれかから指定可能で、指定した属性でソートされる。 categories 指定したカテゴリーで絞り込まれた記事を取得する。 tags 指定したタグで絞り込まれた記事を取得する。 author 指定した著者名で絞り込まれた記事を取得する。 after 指定した日時より後に投稿された記事を取得する。 before 指定した日時より前に投稿された記事を取得する。 例えば3つ分の記事の id, title, author の情報のみを取得したい場合は、以下のようなコマンドを実行することで取得できます。 $ curl -X GET https://wpdemo.net/wp-json/wp/v2/posts?_fields=author,id,title\&per_page=3 (curl コマンドで複数のクエリパラメータを指定する場合は & をエスケープする必要がある) [{"id":970,"title":{"rendered":"Update: Override Demo Parameters"},"author":2},{"id":920,"title":{"rendered":"How to Create a Demo WordPress Site"},"author":2},{"id":832,"title":{"rendered":"Why You Shouldn’t Use Your Existing Hosting to Host Your WordPress Theme or Plugin Demo"},"author":2}] また、posts の後ろに記事の id を指定することで、単一の記事データを取得できます。 $ curl -X GET https://wpdemo.net/wp-json/wp/v2/posts/1 記事の投稿 WordPress REST API で記事を投稿する方法について解説します。 先ほどの記事データの取得は記事が公開されている状態であれば誰でも取得できました。 しかし記事の投稿・更新・削除は認証を行わなければ実行できません。 WordPress の認証はいくつかの種類が存在し、Cookie 認証や OAuth 認証などが存在しますが、この記事では curl コマンドで記事の投稿などの操作を行うため、最も分かりやすい Basic 認証を用いて認証を行います。 記事を投稿する場合、さらに Content-Type の指定や、title, contents などの投稿内容の情報を渡す必要があります。 Content-Type を指定しなかった場合は 変数名=値 の形式で値を送信することになります。 curl コマンドで記事を投稿する場合は以下のようになります。 $ curl -X POST --user ユーザー名:パスワード https://wpdemo.net/wp-json/wp/v2/posts -H "Content-Type: application/json" -d '{"title": "タイトル", "contents": "コンテンツ"}' --user オプションで WordPress のユーザー名とパスワードを指定して認証します。 今回は json 形式でデータを渡すため、Content-Type には application/json を指定しています。 この例では記事のタイトルとコンテンツのみ指定していますが、著者名やタグ、カテゴリーなど非常に多くの項目を渡せます。 詳しく知りたいという方はこちらをご覧ください。 https://developer.wordpress.org/rest-api/reference/posts/#schema 記事の更新 すでに投稿されている記事を更新する場合について解説します。 方法は記事の投稿とほとんど同じで、違いはエンドポイントに更新対象記事の ID を含めることくらいです。 curl コマンドで記事を更新する場合は以下のようになります。 $ curl -X POST --user ユーザー名:パスワード https://wpdemo.net/wp-json/wp/v2/posts/対象記事のID -H "Content-Type: application/json" -d '{"title": "更新後のタイトル", "contents": "更新後のコンテンツ"}' 記事の削除 投稿されている記事を削除する場合について解説します。 削除のやり方は非常にシンプルで、対象記事の ID が含まれたエンドポイントに対して HTTP の DELETE メソッドでリクエストを投げるだけです。 curl コマンドで記事を削除する場合は以下のようになります。 $ curl -X DELETE --user ユーザー名:パスワード https://wpdemo.net/wp-json/wp/v2/posts/対象記事のID 弊社ホームページのブログ一覧画面の紹介 前半では WordPress REST API の解説を行いました。 ここからは、弊社 (株式会社WESEEK) のホームページのブログ一覧画面を紹介します。 https://weseek.co.jp/ja/blog/ 主に4つの機能があります。 ブログ切り替え 投稿年による絞り込み カテゴリーによる絞り込み ページネーション ブログ切り替え 弊社には2種類のブログがあり「WESEEK BLOG」は会社の紹介や社内イベントなどの記事が投稿され、「TECH BLOG」は弊社の社員が外部に向けて公開する技術ブログとなっています。 画面上部のボタンから、この2種類の記事を切り替えられるようになっています。 投稿年による絞り込み デフォルトの状態では全ての記事が表示されますが、左上のドロップダウンから年を選択することにより、その年に投稿されたブログのみを表示させられます。 カテゴリーによる絞り込み 記事のタイトル下にあるカテゴリー名をクリックすることで、そのカテゴリーの記事を絞り込めます。 カテゴリー選択後、右上の×ボタンを押すことによりカテゴリーの選択を解除できます。 ページネーション 弊社のブログ一覧画面では最大で1度に9つの記事が表示されるように設定されており、記事数が9つよりも多い場合は下部のボタンを押すことでページの切り替えが行えるようになっています。 また、このページネーション機能は年やカテゴリーによる絞り込みを考慮したものとなっています。 弊社ホームページのブログページの実装について解説 最後に、弊社のホームページではどのようにしてブログページを作成しているのかについて解説します。なお、今から紹介するコードは全て vue.js で書かれています。 記事データを取得する async getBlogListAndMaxPage(page, selectedBlogType, selectedYear, selectedCategory) { let requestURL = `https://weseek.co.jp/${selectedBlogType}/wp-json/wp/v2/posts?_embed&per_page=9&page=${page}`; if (selectedCategory) { requestURL += `&categories=${selectedCategory}`; } if (selectedYear !== 'ALL') { requestURL += `&after=${selectedYear}-01-01T00:00:00.00&before=${selectedYear}-12-31T23:59:59.99`; } try { const res = await fetch(requestURL); this.blogList = await res.json(); this.maxPage = Number(res.headers.get('x-wp-totalpages')); } catch (error) { throw new Error(error); } } まず、記事データと最大ページ数を取得するためのメソッドとして getBlogListAndMaxPage メソッドを定義しています。 引数には取得するページ、選択されているブログの種類、選択されている年、選択されているカテゴリーを渡します。 let requestURL = `https://weseek.co.jp/${selectedBlogType}/wp-json/wp/v2/posts?_embed&per_page=9&page=${page}`; 最初に requestURL を定義しています。 弊社には2種類の WordPress があるので selectedBlogType で出し分けています。 オプションでは、サムネイルを表示させたいため _embed のパラメータで追加の情報を取得しています。 さらに、1ページあたり9つの記事を表示させるため、 per_page には9を指定し、 page には表示するページを指定します。 if (selectedCategory) { requestURL += `&categories=${selectedCategory}`; } カテゴリーが選択されている場合は categories パラメータを requestURL に追加することでカテゴリーによる絞り込みを行います。 if (selectedYear !== 'ALL') { requestURL += `&after=${selectedYear}-01-01T00:00:00.00&before=${selectedYear}-12-31T23:59:59.99`; } 次は年による絞り込みで、 selectedYear は ALL という文字列もしくは年が渡るので、ALL 以外の時に before と after のパラメータを追加します。 そしていよいよ requestURL を元にデータの取得を行います。 データの取得には Fetch API を使用します。 最大ページ数だけは少し特殊で、レスポンスの body ではなく header に情報が含まれているので、 res.headers.get('x-wp-totalpages') で取得してそれを数字に変換しています。 const res = await fetch(requestURL); this.blogList = await res.json(); this.maxPage = Number(res.headers.get('x-wp-totalpages')); これにより this.blogList に現在のページのブログ一覧の情報が格納されました。 最大ページ数を取得することにより、ページネーション機能で最大ページ数を超えるページを選択できないようにしています。 サムネイルを表示する 先ほど取得した blogList から、このようにしてリンクやタイトルを表示させられます。 <div v-for="blog in blogList" :key="blog.id"> <a :href="blog.link" target="_blank"> {{ blog.title }} </a> </div> 取得したデータから、サムネイルを表示させる方法を解説します。 サムネイルなどの情報はオプション無しでは取得できず、リクエストのパラメータに _embed を含めることで取得できます。 これにより新しく追加されたデータは blog._embedded の中に含まれます。 サムネイルの source_url を取得できればサムネイル画像を表示できます。 source_url は非常に深い位置にあり、 blog._embedded['wp:featuredmedia'].[0].media_details.sizes.サイズ.source_url で取得できます。 1つ注意点があり、サイズの部分には medium or large or full が入りますが、サムネイルの画像サイズによっては medium が無かったりします。 かと言って全て full で指定してしまうと、サムネイルの画像サイズが大きかった場合に表示速度などで悪影響が出てしまうので、 medium -> large -> full のように小さい順で探して表示するのが良いと思われます。 取得した source_url を img タグの src に渡すことで、記事のサムネイルを表示できます。 <img :src="source_url"> カテゴリーIDからカテゴリー名を取得して表示する 取得したデータから、タイトルやURL、サムネイルを表示できました。 しかし、取得した記事データの author, categories, tags に関しては、それぞれの値が直接取得できるのではなく ID が取得されます。 ここでは、取得したカテゴリーの ID から、その ID に対応するカテゴリーの値を取得して表示させる方法について解説します。 これまで、 /wp/v2/posts を指定して記事の情報を取得してきました。 しかし WordPress REST API には他にもさまざまな種類のエンドポイントが存在します。 カテゴリー ID をカテゴリー名に変換するために、ID と名前の対応表が必要ですが、これは /wp/v2/categories のエンドポイントから取得できます。他のエンドポイントについては こちら から確認できます。 どのエンドポイントも /wp/v2/posts と同様にオプションを指定できます。 今回必要なのは id と name のフィールドだけなので、 _fiedls パラメータを指定します。 curl -X GET https://wpdemo.net/wp-json/wp/v2/categories?_fields=id,name [{"id":1,"name":"General"},{"id":6,"name":"News"},{"id":4,"name":"WordPress"}] カテゴリー情報を取得するコードと表示するコードを紹介します。 async getCategoryList() { const res = await fetch('https://weseek.co.jp/tech/wp-json/wp/v2/categories?_fields=id,name&per_page=100'); this.categoryList = await res.json(); }, showCategoryName(categoryID) { return this.categoryList.find(c => c.id === categoryID)?.name; } /wp/v2/categories エンドポイントも他と同様に per_page の初期値は10です。 全てのカテゴリー情報を取得するために、 per_page に設定可能な最大値である100を指定しています。 もしカテゴリーの種類が100よりも多い場合、ページを分けて取得した後に連結しなければならないので少し面倒になります。 id と name の対応表を取得した後は、find メソッドを使用して id に対応する name を取得しています。 さいごに WordPress REST API を用いることで WordPress の情報を取得してホームページに表示できました。 WordPress REST API では、直接 WordPress を操作するのと同じくらい非常に多くの操作が行えるため、工夫次第ではホームページにもっと面白い機能を追加できそうです。 興味を持った方はぜひ触ってみてください。
アバター
はじめに こんにちは、システムエンジニアの かおり です。 今回は、弊社が提供するナレッジ共有サービス「GROWI」のカスタマイズ方法について取り上げます。 これから GROWI を組織に導入しようとしている方や既にGROWIを運用している管理者向けの記事となっています。 ワクワクするような自分好みの GROWI にカスタマイズしたり、作業効率アップに役立てていただければ幸いです。 設定画面(カスタマイズ) への行き方 カスタマイズ設定画面へ行くには 管理者としてGROWIにログイン サイドバーの歯車アイコンをクリックし、管理画面に遷移 画面左側にあるナビゲーションバーの「カスタマイズ」をクリック もしくは、 https://{GROWI のドメイン名}/admin/customize に遷移 のどちらかを実行することで、設定画面を表示できます。 本記事では以下の6つの設定について紹介していきます。 レイアウト テーマ デフォルトのサイドバーモード コードハイライト カスタム Title カスタムロゴ 1. レイアウト 全ページのレイアウトを「default」 と 「width 100%」 の2種類から選択できます。 Default ページが中央寄せになり、左右に空白がうまれるのでスッキリとした見た目になります width 100% ページいっぱいまで広がるので、テーブルなどの横幅が長いコンテンツの場合、レイアウトを大幅に崩すことなく表示できます。 2. テーマ GROWIでは15種類ものテーマを選ぶことができます。 管理者が選んだテーマが全ユーザーが利用するテーマとして反映されます。 これは複数のGROWIを利用する組織の場合、各GROWI を異なるテーマに設定することで、それぞれがどのGROWIなのかを一般ユーザーに瞬時に識別させるというメリットがあります。 最近追加されたテーマを一部抜粋して紹介します。(v5.1.0現在) hufflepuff (ハッフルパフ) 背景イラストは当時開発してくれたインターン生の書き下ろしです。 Light Dark Light / Dark モードの切り替えは、ナビバーの歯車アイコンから操作できます。 jade-green (ジェイドグリーン) 配色がかっこいいです!! Light Dark blackboard (ブラックボード) 黒板をモチーフにした遊び心あるテーマも用意しておりますので、ぜひお試しください! Dark (Light モードなし) 3. デフォルトのサイドバーモード サイドバーの表示方法は Drawer mode / Dock mode の2種類あり、全ユーザーがページを訪れた際の表示方法を設定できます。 Dock mode (ドックモード) Dock mode はサイドバーを常に表示させます。中央の6角形のボタンをクリックするとコンテンツの開閉ができます。 また、長押しした状態でマウスを左右に移動させるとサイドバーコンテンツの横幅を自由に調整できます。 Drawer mode (ドロワーモード) drawer は英語で引き出しの意味で、サイドバーが隠れた状態で格納されており、左上のハンバーガーメニューをクリックすることで表示できます。 Drawer mode は Dock mode のように コンテンツの横幅調整や、サイドバー関連以外の操作はできませんが、ページをスッキリ見せることができます。 ユーザーがサイドバーの設定変更をする方法 ナビバーの歯車アイコンから変更できます。 4. コードハイライト コードハイライトとは、意味のある文字列や構文に色を付けることで、ソースコードの可読性を向上させるものです。 ページ編集時に以下の写真のように記述してあげることで コードに色付けがされ、見やすくさせることができます。 5. カスタム Title カスタムTitle では ブラウザのタブに表示するテキストを変更することができます。 デフォルトでは 「現在表示中のページ名」 - 「アプリ設定で設定したサイト名」 が表示されていますが、「現在表示中のページパス」を表示させることもできます。 6. カスタムロゴ 画面右側の「Choose file」 ボタンをクリックして新しいロゴをアップロード、更新ボタンをクリックしてページをリロードするとナビバー左側の画像を変更することができます。 最後に 以上、GROWI のカスタマイズ方法でした! クローズドなwikiかオープンなwikiかでも最適な設定は変わってきそうですね。 目的にあったカスタマイズをし、より使いやすい GROWI を目指しましょう!
アバター
皆さんこんにちは! WESEEK ソフトウェアエンジニアの 増山 です。 今回のブログでは、時系列データベース VictoriaMetrics に入門したいと思います。 目次 はじめに VictoriaMetrics は公式ドキュメントがしっかりと整備されているので基本的にはこちらを読んでいただければ大体のことはわかるかと思います。この記事では、入門するにあたって必要最低限わかっていればいいことだけに絞って手短に解説します。 また、VictoriaMetrics を使うときには Single version か Cluster version のうちどちらか1つの種類を選ぶことになりますが、Cluster の方さえ理解していれば Single も理解しているのと同じなのでこの記事では Cluster のことについてしか言及しません。 VictoriaMetrics 概要 原文: VictoriaMetrics is a fast, cost-effective and scalable monitoring solution and time series database. 和訳: VictoriaMetrics は、高速で費用対効果が高く、拡張性の高いモニタリングソリューションであり、時系列データベースです。 公式ドキュメント より引用。 VictoriaMetrics 本体は vminsert vmstorage vmselect の3つのコンポーネントで構成されています。それぞれの役割が明確に分かれていてとても理解しやすい印象です。 また、メトリックスを収集するコンポーネントとして vmagent があります。 vminsert vmstorage にデータを書き込む役割を持つコンポーネント。 vminsert は取り込んだデータをそのメトリック名とラベルに基づいてハッシュを決定し、そのハッシュに対応する vmstorage にデータを書き込みます。 必須の起動時オプションは、 --storageNode=<vmstorage の URL, ...> です。 データのレプリケーション 起動時のオプションを --replicationFactor=N と設定することで、書き込むデータを N 個に複製してそれぞれ違うストレージに書き込むことができます。これにより、 N - 1 個の vmstorage が停止したとしても VM クラスタは完全なデータを維持できます。 --replicationFactor=N のとき 2*N - 1 個の vmstorage を用意する必要があることに注意してください。 詳しくは こちら をご覧ください。 vmstorage データを保存したり、 vmselect からのクエリに応じてデータを返したりする役割を持つコンポーネント。 リテンション 起動時のオプションを --retentionPeriod=N と設定することでデータの保存期間を設定できます。 vmselect vmstorage にクエリを発行してクライアントに目的のデータを返す役割を持つコンポーネント。 重複排除 vmselect はクライアントから検索の命令を受け取ると、全ての vmstorage に同じクエリを発行して検索データを受け取ります。その後、特定のルールに従って重複排除を行うことで被ってしまったデータを排除したあと、 PromQL で集計などを行い、最終的にクライアントに返します。 また、 vmui という簡易的な web ui を持っているので、Grafana などの web ui ツールを入れなくてもメトリックスを確認できます。 検索のチューニング VictoriaMetrics はデフォルト値で十分にチューニングされているとドキュメントでは言われています。 ただし大きなデータを検索したくなった場合には --search.max* オプションの値を大きくすることで限界を調整できます。 vmagent メトリックスを収集して、ストレージに書き込む役割を持つコンポーネント。 Prometheus の scrape_config に基づいた設定ファイルをもとにスクレイピング対象やスクレイピングの間隔などを設定できます。 書き込みのフォーマットとして Prometheus remote_write を使用するので、Prometheus 互換のストレージに対して書き込みを行うことができます。 起動して使ってみる 簡単に使えるように docker-compose.yml を用意したのでこちらを使っていきます。 $ git clone https://github.com/hakumizuki/victoriametrics-docker-compose.git でクローンしてお使いいただけます。 クローンができたら $ docker compose up -f /path/to/docker-compose.yml で起動します。victoriametrics コンテナのポート番号は 9999 にしています。 vmui http://localhost:9999/vmui/ にアクセスすることで vmui を使用できます。 "DASHBOARDS" タブに切り替えるだけで、 vmagent と victoriametrics の "Per-job CPU usage", "Per-job RSS usage", "Per-job disk read", "Per-job disk write" を確認できます。 また、"CUSTOM PANEL" タブでは PromQL または MetricsQL を入力して実行するとそのグラフを描画してくれます。 vmui の詳しい説明は こちら ややこしいオプションについて補足 ドキュメントを読んでいる中でいくつか理解が難しかったオプションがあったので少しだけ触れたいと思います。 --replicationFactor と --dedup.minScrapeInterval 公式ドキュメントの Replication and data safety のセクションで登場するオプションです。 公式の文章だけを読むと、cluster version の VictoriaMetrics で --replicationFactor=N と設定した場合は、必ず --dedup.minScrapeInterval を 1ms で設定しなければならないとありますが、実際にはこれより大きな値を設定することも可能です。 また、これら2つのオプションには直接的な関わりはありません。"開発者の Roman さんの回答" にもある様に、あくまで vminsert はデータを複製して投入し、vmselect は取得したデータを重複排除してクライアントに返すという責務のみを持っています。 より具体的な処理の手順を確認したい場合は 重複排除のソースコード と そのテストコード をお読みいただくことをお勧めします。 開発者の Roman さんの回答 実際のスレッド -dedup.minScrapeInterval=1ms and replicationFactor flags aren't connected. It is quite simple. Replication factor configures vminsert to send N copies of data, where N is the replication factor. vminsert also ensures, that each copy will be sent to different storage. That's it. Dedup flag configures vmselect to leave only one data point for each specific time series on configured interval. So if you request metric foo and it returns datapoints 1000 1 , 1010 2 , 1020 1 (where first number is a timestamp, and second is value) and your -dedup.minScrapeInterval=30ms - then only 1020 1 will be returned and the rest is dropped. If you set dedup.minScrapeInterval=5ms - all values will be returned, because interval of 5s does not intersect more than 1 datapoint. When replication factor is set, we recommend setting dedup.minScrapeInterval=1ms because vmselect will get N copies of identical data with equal timestamps. If you omit setting dedup.minScrapeInterval=1ms - you'll receive all datapoints without deduplication. まとめ ここまでお読みいただきありがとうございました。 今回は時系列データベース VictoriaMetrics に入門してみました。 質問等ありましたら 増山の Twitter に DM 送っていただければ答えられる範囲でお答えします。 皆さんも時系列データベースの候補として VictoriaMetrics を検討してみてはいかがでしょうか。 関連記事 【2022年保存版】気になるTSDBプロダクトを比較してみました <= こちらの WESEEK ソフトウェアエンジニアの 田村 がまとめた記事が参考になると思いますので興味があるかたはぜひご覧ください。
アバター
皆さんこんにちは!WESEEK ソフトウェアエンジニアの 増山 です。 今回は、MongoDB (mongoose) で木構造のモデルの作成と、そのドキュメントの取得戦略について考えます。 JavaScript の AsyncGenerator を使った方法と、MongoDB の $graphLookup (aggregation) を使った方法の2つを紹介します。 目次 前提知識 以下の前提知識があるとより楽に読み進められると思いますので参考にしてください。 JavaScript の AsyncIterator, AsyncGenerator https://javascript.info/async-iterators-generators MongoDB https://www.mongodb.com/ mongoose https://mongoosejs.com/ 今回扱う木構造のモデル 今回は以下のような木構造データモデルを扱います。 ドキュメントが他のドキュメントの ObjectId を親ドキュメントの参照として持つ構造になっています。 const nodeSchema = new Schema({ parent: { type: mongoose.Schema.Types.ObjectId, ref: 'Node' }, }); MongoDB のドキュメントでは他にもいくつかの木構造データモデルの例が挙げられていますので興味があれば こちら をご覧ください。 ゴール ある Node を指定した時にその子孫の Node を全て取得するというシチュエーションを考えて見ます。 方法1 方法1では、順序関係なしに、特定の Node の全ての子孫 Node を Iterable として取得すること」を目指します。 実装 コード上のコメントで実装の解説しています。 最終的に generateIterable を呼ぶことで使用できます。 // 空配列であるかどうかを確かめるユーティリティ const isEmptyArray = (val: any): val is never[] => { return 'length' in val && val.length === 0 } class IterableTreeDocsFactory { async generateIterable(initialNode: NodeDocument): Promise<AsyncGenerator<NodeDocument> | never[]> { // ルートとなる initialNode から Cursor を作成 const initialCursor = await this.getCursor(initialNode) return this.generateDescendantNodes(initialCursor) } private async* generateDescendantNodes(cursor: Cursor<NodeDocument>): AsyncGenerator<NodeDocument> { // 1. cursor に含まれる node をイテレートしてさらに cursor を生成 // 2. 新たな cursor で再帰的に generateDescendantNodes を呼び出す // 3. node 自体は yield する for await (const node of cursor) { const nextCursor = await this.getCursor(node) if (!isEmptyArray(nextCursor)) { yield* this.generateDescendantNodes(nextCursor) // 再帰的に yield する } yield node } } private async getCursor(node: NodeDocument): Promise<Cursor<NodeDocument>> { const Node = mongoose.model('Node') const cursor = Node .find({ parent: node }) .lean() .cursor({ batchSize: 10 }) return cursor } } 5.0.x 系までの GROWI でも似たような実装がされていました。 ソースコードは こちら 使い方 使い方の例を紹介します。 ここではより実践向けになるように、 Writable stream に pipe してその中で node を使った処理をする例を紹介します。 ルートとなる NodeDocument を取得 Node.findOne() に条件を渡すことで、特定の NodeDocument を取得できます。 const Node = mongoose.model('Node'); const initialNode = await Node.findOne(); IterableTreeDocsFactory を初期化して generateIterable メソッドを呼ぶ これによって AsyncGenerator iterableTree が取得できます。 const factory = new IterableTreeDocsFactory(); const iterableTree = await factory.generateIterable(initialNode); Readable に変換 4 の Writable に pipe するために Readable に変換します。 const readableStream = Readable.from(iterableTree); Writable で node を受け取って目的の操作をする const writeStream = new Writable({ objectMode: true, async write(node, encoding, callback) { // node を使った処理 callback(); }, async final(callback) { callback(); }, }); Stream を pipe する 最後に readableStream と writeStream を pipe で繋げることでデータが流れます。 readStream.pipe(writeStream); 妥協点 今回実装した AsyncGenerator を使った木構造ドキュメント取得処理は大きく2つの課題が残っています。 順番を制御できない 方法1では、取得する順番を Node の名前順にしたり Node の作成された順にしたりすることができません。なので、もしソートしたい場合はアプリケーション側で行う必要があります。 クエリ数 = 取得するドキュメントの数になってしまう 方法1では、 generateDescendantNodes ジェネレータメソッド内で再帰的にリクエストを送っています。1 Node 取得するために 1リクエストを発行する必要があるので、MongoDB にリクエストを送ることによるリソース的・時間的なオーバーヘッドが発生してしまいます。 少数のドキュメントを取得してくる場合は問題にはなりませんが、これが大きくなってくると取り回しづらくなってしまうでしょう。 そこで次の方法2ではこれらの妥協点を克服していきます。 方法2 方法2では、 $graphLookup (aggregation) を使って方法1の妥協点を改善します。 実装と解説 Aggregation ではパイプラインを組み合わせることでデータを変形してから取得できます。 それぞれのパイプラインの詳しい説明は MongoDB Aggregation pipeline をご覧ください。 $match $parentID を _id にもつドキュメントを次のステージに流す ここでマッチした全てのドキュメントに対して $graphLookup が走る $graphLookup _id を起点に、 _id = parent なドキュメントを探す このとき深さは ∞ まで その結果を descendants として次のステージに流す $unwind descendants を展開 $replaceRoot 各ドキュメントの descendants の配列に変換 $project 必要なプロパティのみにプロジェクション pathLength フィールドに path の長さを格納 $sort pathLength (降順) のあとに path (昇順) でソート 結果として 1. のページの子孫全てがパスの降順になって取得できます。 const Page = mongoose.model('Page'); const descendantPages = await Page.aggregate([ { $match: { _id: $parentID, // $parentID はルートとなるページの ObjectID }, }, { $graphLookup: { from: 'pages', startWith: '$_id', connectFromField: '_id', connectToField: 'parent', as: 'descendants', }, }, { $unwind: '$descendants', }, { $replaceRoot: { newRoot: '$descendants', }, }, { $project: { _id: 1, parent: 1, path: 1, pathLength: { $strLenCP: '$path' }, children: 1, }, }, { $sort: { pathLength: -1, path: 1, }, }, ]); 結果 [ { "_id":"62dba2d15322d2c95410eead", "parent":"62dba2d15322d2c95410eeab", "path":"/$parent/child1/grandChild1", "pathLength":27 }, { "_id":"62dba2d15322d2c95410eeae", "parent":"62dba2d15322d2c95410eeab", "path":"/$parent/child1/grandChild2", "pathLength":27 }, { "_id":"62dba2d15322d2c95410eeab", "parent":"62dba2d15322d2c95410eeaa", "path":"/$parent/child1", "pathLength":15 }, { "_id":"62dba2d15322d2c95410eeac", "parent":"62dba2d15322d2c95410eeaa", "path":"/$parent/child2", "pathLength":15 } ] これで1クエリのみで目的のドキュメントを取得できました。 この方法は近々 5.1.x 系の GROWI にも反映される予定です。 テストコードは こちら 方法2のデメリット 方法2 では木構造データの探索やソートなどの計算を全て MongoDB に肩代わりしてもらいます。なので、方法1よりは MongoDB への負荷が大きくなるというデメリットがあります。 一般的に、RDBMS の stored procedures に代表されるように集計系の処理は DB 側に寄せたほうが高パフォーマンスを生むと言われているので、GROWI の最新系では方法2を採用しています。(5.1.x 系を予定) まとめ 今回は2つの方法を使って MongoDB の木構造モデルのデータを取得してみました。ぜひ参考にしてみてください。また、より良い方法がありましたら 増山の Twitter に DM で教えてください。
アバター
はじめに こんにちは。エンジニアの takayuki です。 最近、とあるプロジェクトで大量の時系列データを投入し解析する必要性が出てきたため、 TSDB プロダクトについて比較を行いました。 もともとはプロジェクト内で留めるつもりの資料でしたが、せっかく作ったので公開したいと思います。 TSDB の概要など基本的な情報については割愛します。 比較の方法 各プロダクトのドキュメントを読み、後述する観点で比較を行いました。 比較にあたって、実際に各プロダクトを動かしての検証は行っていません。 比較の観点 今回の比較は、プロジェクトの要件に特化しているため、観点は網羅的ではありません。 比較した観点は下記です。 数百万のデータポイントを15分以内に投入できるか 1ヶ月間の集計されたデータを30分ごとにデータストアから取り出せるか オンプレで構成を完結できるか スケールアップ、スケールアウトできるか 欠落したデータを後から投入できるか 1台のデータストアに障害が発生しても継続してデータの投入・取得が行えるか REST APIなどで操作できるか 観点1, 2 については実際に検証してみないとわからない部分だと思いますので、観点 3 以降を調べて比較しました。 比較対象の選定 TSDB プロダクトの選定は、下記のように行いました。 まず、Prometheus の長期保存ストレージ系として、 Thanos, Cortex, Grafana Mimir を選択しました。Prometheus + Thanos 構成は弊社の別プロジェクトでも運用実績があります。 次に TSDB プロダクトで、比較的よく目にするということで VictoriaMetrics, InfluxDB, TimescaleDB を選択しました。 今回は、この 6 プロダクトに対して比較を行いました。 さっそく、比較 下記の画像をクリックするとPDFで開きます。 実際に動作して確認したものではないため、一部内容に誤りがある場合があります。その際はご指摘いただけると嬉しいです。 総評に記載されているポイントは、あくまでも今回のプロジェクトの要件に特化した値であるため、網羅的なものではありません。 それでは、それぞれの観点について細かく見ていきたいと思います。 オンプレで構成を完結できるか 最近ではクラウドサービスを利用することが多いと思います。今回のプロダクトでは機微情報を扱うため、オンプレで構成を組むことができるかについて調べました。 結果は、 VictoriaMetrics, InfluxDB, TimescaleDB, Thanos, Cortex, Grafana Mimir の6つすべてのプロダクトで、オンプレで構成の完結ができるようです。 スケールアップ、スケールアウトできるか InfluxDB を除く、VictoriaMetrics, TimescaleDB, Thanos, Cortex, Grafana Mimir ではスケールアップ、スケールアウト共に対応しているようです。 InfluxDB は、 OSS 版ではスケールアップはできますが、クラスター構成を組むためには Enterprise 版の利用が必要であるため、スケールアウトはできません。 欠落したデータを後から投入できるか 一般的に backfill と呼ばれる機能です。 これができるものは、 VictoriaMetrics, TimescaleDB, InfluxDB となりました。 任意の過去の時間帯にデータを投入できます。 Cortex, Grafana Mimir, Thanos については、ドキュメント上は backfill についての記載は見つからず、できないようです。この機能については、各 GitHub Issue にて議論が進められています。 1台のデータストアに障害が発生しても継続してデータの投入・取得が行えるか VictoriaMetrics, Thanos, Cortex, Grafana Mimir ができる、 TimescaleDB はできるが懸念あり、 InfluxDB はできないという結果となりました。 VictoriaMetrics, Thanos, Cortex, Grafana Mimir は、各コンポーネントを冗長化でき、ストレージも重複保存が可能です。 前段に nginx や keepalived などを設置すれば、ロードバランシングやフェイルオーバー構成を組むことができます。 TimescaleDB は、 PostgreSQL をベースに開発されています。 PostgreSQL は標準で自動フェイルオーバーに対応していないため、 TimescaleDB もこの影響を受けます。 TimescaleDB で自動フェイルオーバー構成を組みたい場合は、 Patroni などを構築する必要があります。また、この方法はドキュメント上では実験的機能とされています。 REST APIなどで操作できるか プラグインを入れるなどすれば、すべてのプロダクトで HTTP 経由で PromQL でデータを取得できます。 その他の比較観点 プロジェクトでの比較観点は以上になるのですが、その他の観点も追加で調べました。 主なものを紹介します。 任意の時間帯のデータの更新・削除ができるか TimescaleDB, Influx DB では、更新・削除ができ、それ以外のプロダクトの VictoriaMetrics, Thanos, Cortex, Grafana Mimir ではできない結果となりました。 なお、 VictoriaMetrics については、現在「更新」機能が実装中のようです。 https://github.com/VictoriaMetrics/VictoriaMetrics/pull/2885 データストアのバックアップ・リストアができるか VictoriaMetrics, TimescaleDB, InfluxDB はバックアップ・リストアツールを提供しており、可能です。 Thanos, Cortex, Grafana Mimir については、そもそもストレージの管理が関心の対象外のようです。環境構築者自身が長期保存用のブロックストレージを用意し、バックアップ・リストアをする手法も自身で確立して運用する必要があります。 終わりに DB-Engines Ranking によると、 InfluxDB が TSDB プロダクトで最も人気のようです。 たしかにInfluxDBは、調査した限りほとんどの観点で要件を満たす機能が提供されていました。 ただ、 OSS 版ではクラスタ構成を組めないのが難点です。素直に Enterprise 版を利用するのも1つの選択肢ではあります。 今回は、ほとんど同様の機能を提供している VictoriaMetrics をプロジェクトで採用することにしました。 各プロダクトで機能の追加がありましたら、しばらくして新しい記事にて執筆させていただこうとおもいます。 もし、今回の内容に誤りがありましたら、修正しますので、ご指摘いただけると嬉しいです。
アバター