Posts
github repo: https://github.com/hrfmmr/swiftui-sandbox
題材 「twsearch」という、Twitter APIのApplication’s only authenticationをつかったシンプルなtweet検索アプリ
モジュール構成 module type description twsearch application 本体アプリ AppFeature dynamic framework 各FeaturesをまとめあげるUmbrella的なFeatureモジュール SearchStatusFeature dynamic framework 検索機能のFeatureモジュール APIClient dynamic framework Twitter APIクライアント SwiftUIHelpers dynamic framework SwiftUIのヘルパ Core dynamic framework ドメインモデル定義など Umbrella static framework 共通で使用されるライブラリ群がembedされたモジュール 依存関係 Embedded Frameworkをつかったモジュール構成 XcodeGen project.yml
name: twsearch options: bundleIdPrefix: example.com deploymentTarget: iOS: 15.0 targetTemplates: Application: type: application platform: iOS sources: - path: ${target_name} EmbeddedFramework: platform: iOS type: framework sources: - path: ${target_name} - path: ${target_name} excludes: - Info.plist EmbeddedFrameworkTests: type: bundle.unit-test platform: iOS dependencies: - target: twsearch sources: - path: ${target_name} - path: ${target_name} excludes: - Info.plist buildPhase: resources FeatureFramework: templates: - EmbeddedFramework dependencies: - target: Core - target: APIClient - target: SwiftUIHelpers targets: twsearch: templates: - Application dependencies: - target: AppFeature AppFeature: templates: - FeatureFramework dependencies: - target: SearchStatusFeature SearchStatusFeature: templates: - FeatureFramework SearchStatusFeatureTests: templates: - EmbeddedFrameworkTests Core: templates: - EmbeddedFramework transitivelyLinkDependencies: true dependencies: - target: Umbrella Umbrella: platform: iOS type: framework.static dependencies: - package: swift-composable-architecture product: ComposableArchitecture APIClient: templates: - EmbeddedFramework dependencies: - target: Core APIClientTests: templates: - EmbeddedFrameworkTests dependencies: - target: twsearch SwiftUIHelpers: templates: - EmbeddedFramework schemeTemplates: EmbeddedFrameworkScheme: templates: - TestScheme build: targets: ${scheme_name}: all TestScheme: test: gatherCoverageData: true targets: - ${testTargetName} schemes: twsearch: build: targets: twsearch: all APIClient: templates: - EmbeddedFrameworkScheme templateAttributes: testTargetName: APIClientTests SearchStatusFeature: templates: - EmbeddedFrameworkScheme templateAttributes: testTargetName: SearchStatusFeatureTests packages: swift-composable-architecture: url: https://github.com/pointfreeco/swift-composable-architecture from: 0.33.0 これをベースに移行していく
Read moreツールを書いた。
https://github.com/hrfmmr/oasbuilder
Context シゴトでiOSアプリ開発をしていて、既存(REST)APIクライアントがSwiftyJSONやPromiseなど現在となってはSwift標準ライブラリで置き換えが可能なライブラリに依存している且つアプリケーションレイヤで扱うモデルにもこれらへの依存が発生していたので、これらをうまいことリファクタしたいなぁというモチベーションがあった、というコンテキスト。
APIクライアントとそれらにぶら下がる層、わりと巨大なので、地道なリファクタ以外の方法でシュッとやれないものか、と考えていたところ、(今更ながら)Open API Generatorの存在(+Swift w/ Alamofireでのクライアントコード生成も可能なこと)を知り、「APIスキーマさえ手に入ればコード生成したものでレガシーコード諸々置き換えイケそう」という考えに至った。一方で、API&モデルのボリュームがそこそこあるので、「動作しているアプリのAPIスキーマの自動生成」がテーマとなって今回のツール作成につながったかんじ。
どうやるか APIスキーマの自動生成、実はバックエンド側でREST APIサーバのライブラリでつかわれているGrape関連ツールのgrape-swaggerを用いたスキーマ生成が、deploy済APIを参照する用のSwagger UI定義としてすでに用いられていたので、このツールで吐かれたスキーマをつかえばクライアントコード生成も楽勝かとおもったが、実際のところpaginationなどの表現が欠けているなどして、そのまま流用することはできず、他の方法でスキーマを得る必要があった。
「アプリの実際の動き」を忠実に再現するスキーマデータを得るには、proxyツールを挟んで実際に流れるリクエスト/レスポンスのデータから生成するアプローチがいちばん手っ取り早いだろうと考えた。
イメージは以下
proxyツールとしてはmitmproxyを使い、以下のような流れでスキーマを生成
1 ~ 5 mitmproxyのPython APIフックで流れてくるリクエスト/レスポンス情報をElasticsearchにdumpするmitmproxy-elasticagentを介して、アプリに必要となるAPIスキーマのrawデータを収集する 6 ~ 8 Elasticsearchに保存されたデータをつかって、OpenAPI Specification(v3)準拠なyamlを生成する デモ Elasticsearch起動 $ elasticsearch ~/.mitmproxy/config.ymlにmitmproxy-elasticagent addonスクリプトを追加 scripts: - /path/to/mitmproxy-elasticagent/jsondump.py es_dest_url: http://127.0.0.1:9200/test-api/_doc es_target_host: api.example.com $ git clone https://github.com/hrfmmr/oasbuilder oasbuilder/.envをいじる ELASTICSEARCH_HOST=http://localhost:9200 ELASTICSEARCH_INDEX=test-api OAS_VERSION=0.1.0 OAS_TITLE=test api OAS_DESCRIPTION=test api OAS_SERVER_URLS=https://api.example.com make run && make previewでdumpされたJSONをOAS yamlにビルド後、プラウザでRedocドキュメント表示 実行風景 なにやってるの 右ペイン $ watch -n2 "curl -XGET -H 'Content-Type: application/json' 'http://127.0.0.1:9200/demo-api/_doc/_search?pretty=true' -d '{\"aggs\": {\"req_paths\": {\"terms\": {\"field\": \"request.path.keyword\", \"size\": 10000}}}, \"_source\": [\"request.path\"] }' | jq '.aggregations.req_paths.buckets[].key'" を実行しているのは、アプリ操作に連動してAPIリクエスト/レスポンの元データをproxy addon経由でElasticsearchに保存されてくる様子を、APIの endpointをpath単位でwatchコマンドで監視しているもの 左ペイン oasbuilderを実行し、APIスキーマをOASとして出力 -> ブラウザでRedocドキュメント表示
モバイルアプリ開発をしていて、REST APIのリクエスト/レスポンスのペアをログ保存しておいて、 あとでリクエスト内容やレスポンス内容別にサクッと検索したいことがあったので、 mitmproxyを介してサーバレスポンスをフックして、リクエスト/レスポンス情報をマシンlocalに立てたElasticsearch向けにdumpするといったaddonを書いた
どうやるの mitmproxyのHTTPイベントのEvent Hooksをつかう。 requestheaders/request/responseheaders/responseなどのフックが用意されていて、今回はレスポンス値を取り出したいので、responseフックをつかう。 responseフックは以下のようなシグネチャ def response(self, mitmproxy.http.HTTPFlow): HTTPFlowは、以下のようなclass class HTTPFlow(flow.Flow): """ An HTTPFlow is a collection of objects representing a single HTTP transaction. """ request: Request """The client's HTTP request.""" response: Optional[Response] = None """The server's HTTP response.""" error: Optional[flow.Error] = None """ A connection or protocol error affecting this flow. Note that it's possible for a Flow to have both a response and an error object. This might happen, for instance, when a response was received from the server, but there was an error sending it back to the client. """ websocket: Optional[WebSocketData] = None """ If this HTTP flow initiated a WebSocket connection, this attribute contains all associated WebSocket data. """ mitmproxy.http.Request, mitmproxy.http.Responseフィールドがそれぞれ生えているので、これら2つをdictにシリアライズできればよさそう 結論からいうと、mitmproxy.http.HTTPFlowには、オブジェクトのdict化に相当するget_stateメソッドが生えている get_state自体は、mitmproxy.http.HTTPFlowのsuper classを辿った先に行き着くmitmproxy.coretypes.serializable.Serializable抽象基底クラスに生えているメソッド mitmproxy.coretypes.serializable.Serializableを直接に実装するのがmitmproxy.stateobject.StateObjectで、以下のようなget_state実装が施されている class StateObject(serializable.Serializable): """ An object with serializable state. State attributes can either be serializable types(str, tuple, bool, ...) or StateObject instances themselves. """ _stateobject_attributes: typing.ClassVar[typing.MutableMapping[str, typing.Any]] """ An attribute-name -> class-or-type dict containing all attributes that should be serialized. If the attribute is a class, it must implement the Serializable protocol. """ def get_state(self): """ Retrieve object state. """ state = {} for attr, cls in self._stateobject_attributes.items(): val = getattr(self, attr) state[attr] = get_state(cls, val) return state _stateobject_attributesがsub classたるmitmproxy.flow.Flowやmitmproxy.http.HTTPFlowでoverrideされるカタチで定義されていてpolymorphicな実装になっている mitmproxy.flow.Flow _stateobject_attributes = dict( id=str, error=Error, client_conn=connection.Client, server_conn=connection.Server, type=str, intercepted=bool, is_replay=str, marked=str, metadata=typing.Dict[str, typing.Any], comment=str, ) mitmproxy.http.HTTPFlow _stateobject_attributes = flow.Flow._stateobject_attributes.copy() # mypy doesn't support update with kwargs _stateobject_attributes.update(dict( request=Request, response=Response, websocket=WebSocketData, mode=str )) というわけで、response hookメソッドの引数として渡されるflowオブジェクトのget_state呼出で、 flowオブジェクトのもつattributesがいいかんじにdict化されるしくみがすでに存在している 一方で、flowオブジェクトをdict化できたところで、そのままJSONとしてPOST可能な構造にはなっていない部分がある たとえば、 mitmproxy.http.Headersはリクエストヘッダの(key, value)のTupleの集合(Tuple)であったり、これらの値はそれぞれbytes型であったり mitmproxy.http.Responseのcontentはbytes型であったり これらをJSONとして扱いたい場合、配列や文字列に変換する必要がある 実は、ありがたいことに、これらの変換処理については、mitmproxy公式repositoryのexamples/contrib内に存在している https://github.com/mitmproxy/mitmproxy/blob/main/examples/contrib/jsondump.py これをベースに、flowに格納されているリクエスト/レスポンス情報を任意の構造にかんたんにカスタマイズできる 今回書いたaddon実装は以下のようなもの import asyncio import gzip import json import traceback from threading import Thread from typing import Optional, Any, Dict, List, Union import aiohttp from mitmproxy import ctx JSON = Any elasticsearch_url = "http://127.0.0.1:9200/apptraffic/_doc" dump_target_hosts = [ "api.example.com", ] class ElasticAgentAddon: fields = { "timestamp": ( ("error", "timestamp"), ("request", "timestamp_start"), ("request", "timestamp_end"), ("response", "timestamp_start"), ("response", "timestamp_end"), ), "headers": ( ("request", "headers"), ("response", "headers"), ), "content": ( ("request", "content"), ("response", "content"), ), } def __init__(self, url: str, hosts: List[str]): self.url: str = elasticsearch_url self.hosts: List[str] = hosts self.content_encoding: Optional[str] = None self.transformations: Optional[List[Dict[str, Any]]] = None self.worker_pool: Optional[ElasticAgentWorkerPool] = None def configure(self, _): self._init_transformations() self.worker_pool = ElasticAgentWorkerPool(self.url) self.worker_pool.start() def response(self, flow): """ Dump request/response pairs. """ if flow.request.host not in self.hosts: return for k, v in flow.response.headers.items(): if k.lower() == "content-encoding": self.content_encoding = v break state = flow.get_state() del state["client_conn"] del state["server_conn"] for tfm in self.transformations: for field in tfm["fields"]: self.transform_field(state, field, tfm["func"]) frame = self.convert_to_strings(state) self.worker_pool.put(frame) def _init_transformations(self): def map_content( content: Optional[bytes], ) -> Union[Optional[bytes], Any]: if self.content_encoding: content = Decoding.decode(content, self.content_encoding) try: obj = json.loads(content) except json.decoder.JSONDecodeError: return content else: return obj self.transformations = [ { "fields": self.fields["headers"], "func": dict, }, { "fields": self.fields["timestamp"], "func": lambda t: int(t * 1000), }, {"fields": self.fields["content"], "func": map_content}, ] @staticmethod def transform_field(obj, path, func): """ Apply a transformation function `func` to a value under the specified `path` in the `obj` dictionary. """ for key in path[:-1]: if not (key in obj and obj[key]): return obj = obj[key] if path[-1] in obj and obj[path[-1]]: obj[path[-1]] = func(obj[path[-1]]) @classmethod def convert_to_strings(cls, obj): """ Recursively convert all list/dict elements of type `bytes` into strings. """ if isinstance(obj, dict): return { cls.convert_to_strings(key): cls.convert_to_strings(value) for key, value in obj.items() } elif isinstance(obj, list) or isinstance(obj, tuple): return [cls.convert_to_strings(element) for element in obj] elif isinstance(obj, bytes): return str(obj)[2:-1] return obj class Decoding: class __Methods: @staticmethod def identity(content: bytes) -> str: return str(content) @staticmethod def decode_gzip(content: bytes) -> str: if not content: return "" return str(gzip.decompress(content), "utf-8") decoding_maps = { "none": __Methods.identity, "gzip": __Methods.decode_gzip, } @classmethod def decode(cls, encoded: bytes, encoding: str) -> Optional[str]: if encoding not in cls.decoding_maps: return None return cls.decoding_maps[encoding](encoded) class ElasticAgentWorkerPool(Thread): def __init__(self, url: str, num_workers: int = 10): super().__init__(name="ElasticAgentWorkerPool", daemon=True) self.url = url self.loop: Optional[asyncio.AbstractEventLoop] = None self.queue: Optional[asyncio.Queue[JSON]] = None self.num_workers = num_workers def run(self): loop = asyncio.new_event_loop() self.loop = loop asyncio.set_event_loop(loop) try: loop.run_until_complete(self._run_loop()) except Exception as e: ctx.log.error(e) ctx.log.error(traceback.format_exc()) else: if not loop.is_closed(): loop.close() ctx.log.info("ElasticAgentWorkerPool's event loop closed") def put(self, frame: JSON): if self.loop and not self.loop.is_closed(): self.loop.call_soon_threadsafe(self.queue.put_nowait, frame) async def _run_loop(self): self.queue = asyncio.Queue() await asyncio.gather( *(self.post_worker(i) for i in range(self.num_workers)) ) async def post_worker(self, id: int): while True: ctx.log.info(f"worker[{id}]:waiting...") frame = await self.queue.get() ctx.log.info(f"worker[{id}]:got task") async with aiohttp.ClientSession() as session: async with session.post(self.url, json=frame) as resp: ctx.log.info(f"worker[{id}]:Done") ctx.log.info(await resp.text()) addons = [ElasticAgentAddon(url=elasticsearch_url, hosts=dump_target_hosts)] 既存のcontrib実装との違いとしては、 client_connやserver_connといった、コネクション周りの詳細情報は不要だったので省いている(あくまでrequest/responseのみに関心があった為) response contentについては、JSON APIであることを前提としていたので、文字列としてではなく、JSON(dict型)に変換している ElasticsearchへのPOSTを行なう非同期I/Oをasyncioベースに(なんとなく) add-onを仕込む add-onスクリプトは外部依存を追加しているので、mitmproxyに属するpipでインストールしておく # install additional dependencies $ $(brew --prefix mitmproxy)/libexec/bin/pip install aiohttp # or if you installed mitmproxy with pipx $ pipx inject mitmproxy aiohttp ~/.mitmproxy/config.ymlに、scriptsとして指定する scripts: - /path/to/mitmproxy_elasticagent.py mitmproxyなりmitmdumpなり起動して通信傍受を開始 $ mitmproxy # or $ mitmdump mitmproxyの場合、EキーでEvents view表示することで、ログがみれる Events info: Loading script /path/to/mitmproxy_elasticagent.py info: worker[0]:waiting... info: worker[1]:waiting... info: worker[2]:waiting... info: worker[3]:waiting... info: worker[4]:waiting... info: worker[5]:waiting... info: worker[6]:waiting... info: worker[7]:waiting... info: worker[8]:waiting... info: worker[9]:waiting... ⇩ [0/0] [scripts:1] [*:8080] どううれしいか Kibanaも立ててると、
Read moreには、PyChromecastなどのライブラリをつかえばかんたんにできるが、 中身が気になったので、castデバイスとソケットを張ってやりたいこと(=今回はとあるmp3を再生させるだけ)ができるまでに必要となるプロトコルなどいろいろあったのでそのメモ(現時点でこのメモが未来に役立つイメージはゼロ。)
Chromecast API v2プロトコル senderとreceiver sender castデバイスを操作するクライアント receiver castデバイス castクライアントは、TCP(w/TLS) 8009ポートでcastデバイスに接続する TCPコネクション確立後、senderとreceiverは固定長のバイナリメッセージを送り合う パケット構造は単純で、 Packet length | Payload(message body) の2部構成 Packet length uint32(ビッグエンディアン) Payload protobufでシリアライズされたバイナリ protoファイルは以下のようなもの message CastMessage { // Always pass a version of the protocol for future compatibility // requirements. enum ProtocolVersion { CASTV2_1_0 = 0; } required ProtocolVersion protocol_version = 1; // source and destination ids identify the origin and destination of the // message. They are used to route messages between endpoints that share a // device-to-device channel. // // For messages between applications: // - The sender application id is a unique identifier generated on behalf of // the sender application. // - The receiver id is always the the session id for the application. // // For messages to or from the sender or receiver platform, the special ids // 'sender-0' and 'receiver-0' can be used. // // For messages intended for all endpoints using a given channel, the // wildcard destination_id '*' can be used. required string source_id = 2; required string destination_id = 3; // This is the core multiplexing key. All messages are sent on a namespace // and endpoints sharing a channel listen on one or more namespaces. The // namespace defines the protocol and semantics of the message. required string namespace = 4; // Encoding and payload info follows. // What type of data do we have in this message. enum PayloadType { STRING = 0; BINARY = 1; } required PayloadType payload_type = 5; // Depending on payload_type, exactly one of the following optional fields // will always be set. optional string payload_utf8 = 6; optional bytes payload_binary = 7; } このCastMessageを介してsender<->receiver間のデータの送受信が行われる source_id senderの識別子(sender-0などがつかわれる) destination_id receiverの識別子(receiver-0や、後述するreceiverアプリケーションのtransportIdに相当する) Namespace sender<->receiver間のメッセージは、Namespaceというチャネルを明示された上で行われる それぞれのNamespace毎にセマンティクスがあり、Payloadメッセージの内容が変わる たとえば以下のようなNamespaceが定義されている urn:x-cast:com.google.cast.tp.connection # コネクションに関するもの urn:x-cast:com.google.cast.receiver # receiver情報に関するもの urn:x-cast:com.google.cast.media # メディア再生に関するもの etc... 接続からreceiverでメディアファイル再生させるまでのシーケンス 1.) senderは、TCP(w/TLS)8009ポートでreceiverとコネクションを張る 2.) sender<->receiver間のメッセージを開始するには、まずurn:x-cast:com.google.cast.tp.connection Namespaceで仮想的なコネクションを確立するためのメッセージを送る必要がある。payload type: CONNECTのメッセージをsenderから送る。(この手順を省くと、senderから特定Namespaceセマンティクスのメッセージをreceiverにそもそも送れない(すぐにソケットが閉じられてしまう) 3, 4.) urn:x-cast:com.google.cast.receiver NamespaceでGET_STATUSメッセージを送り、receiverのアプリケーション情報を取得する(RECEIVER_STATUSメッセージを受け取る) RECEIVER_STATUSメッセージは以下のようなもの { "requestId": 8476438, "status": { "applications": [ { "appId": "CC1AD845", "displayName": "Default Media Receiver", "namespaces": [ "urn:x-cast:com.google.cast.player.message", "urn:x-cast:com.google.cast.media" ], "sessionId": "7E2FF513-CDF6-9A91-2B28-3E3DE7BAC174", "statusText": "Ready To Cast", "transportId": "web-5" } ], "isActiveInput": true, "volume": { "level": 1, "muted": false } }, "type": "RECEIVER_STATUS" } 5, 6.) 4で取得したstatus情報に、起動中アプリケーションが存在しなければ、urn:x-cast:com.google.cast.receiver namespaceでLAUNCHメッセージを送る LAUNCHメッセージに付与したrequestIdに対応するRECEIVER_STATUSメッセージを受け取る 7.) RECEIVER_STATUSメッセージに含まれる、transPortIdをdestinationIdとして指定した上で、urn:x-cast:com.google.cast.tp.connection namespaceでCONNECTメッセージを送り、以降のsender(cast client)<->receiver(cast device)間の仮想的なコネクションを張る 8.) urn:x-cast:com.google.cast.media NamespaceでLOADメッセージを送り、receiverにメディアファイル再生させる このときpayloadに再生するメディア情報を付与する 以下のようなデータフォーマット type LoadMediaCommand struct { PayloadHeader Media MediaItem `json:"media"` Autoplay bool `json:"autoplay"` CustomData interface{} `json:"customData"` } type MediaItem struct { ContentID string `json:"contentId"` ContentType string `json:"contentType"` StreamType string `json:"streamType"` Metadata interface{} `json:"metadata"` } contentIdでメディアファイルのURLを指定 contentTypeでMIMEを指定するなど castクライアント実装 以上のシーケンスを実装したものが以下(今回はGoで書いた。protocが対応している言語なら何でも書けるとおもう)
Read moreこれはなに BigQueryでFirebase Analyticsイベントをファネル分析する上で、これをwalkthroughしたメモ。
デモアプリ 分析事、実務プロジェクトでやってもよいが、リアルに課金が発生するのを気にしてしまうのと、個人プロジェクトでは分析対象のデータがそもそも揃えるのが難しい。 sandboxな環境で分析事を試せる十分なデータセットがあるプロジェクト、Google先生が公開してくれているFirebaseデモプロジェクトがある。親切。 Firebase デモ プロジェクト - Firebase ヘルプ デモプロジェクトのiOSアプリでは、Flood-It!という実在するパズルゲームアプリのアナリティクスデータをつかって分析を行う 指定された手数以内で盤面を一色に塗りつぶすパズルゲームです ゴール no_more_extra_stepsを受けたユーザの内、どれだけspend_virtual_currencyにつながったか、を知りたい 2stepファネルをつくる カダイ はFirebase ConsoleでつくるとOpen Funnelになること(以下画像は上記ブログ記事から拝借したもの) 実際にデモプロジェクトのFirebase Consoleでファネルをつくろうとしたが、現在は権限が制限されているのか、新規の目標到達プロセスの作成ができなかった.. ファネルをみると、step1よりstep2の方が大きい値になってしまっている。step1に関係なくstep2イベントがカウントされている。 いわゆるオープンファネルになってしまっている。いまつくりたいのはクローズドファネル。 ちなみに、GA4ではクローズドな目標到達プロセスも作成できる(後述のイベント間の時間区間指定もできるし、イベントパラメータ指定などもできる。GUIポチポチで完結するので便利。) GA4 目標到達プロセスの分析 - アナリティクス ヘルプ 今回はFirebase EventsログをBigQueryで弄ぶ主旨なのでBQでやっていく BigQueryでどうやる 以降はFlood-It!アプリイベントのBigQueryデータセットのクエリエディタで叩いて進めていく
1.) まずは1日分のデータでユーザIDベースで発生イベントを時系列に出してみると SELECT event_name, user_pseudo_id, event_timestamp FROM `firebase-public-project.analytics_153293282.events_20180720` ORDER BY user_pseudo_id, event_timestamp LIMIT 1000 こんなふうに 2.) 次に、no_more_extra_stepsとspend_virtual_currencyイベントに絞ってみると、 SELECT event_name, user_pseudo_id , event_timestamp FROM `firebase-public-project.analytics_153293282.events_20180720` WHERE (event_name = "no_more_extra_steps" OR event_name = "spend_virtual_currency") ORDER BY user_pseudo_id, event_timestamp こんなふうに クエリ結果の4,5行目をみると、たしかに同一ユーザによってno_more_extra_steps -> spend_virtual_currencyの順でイベント発生してることがわかる ので、こういうかんじのをいい感じに抽出していく 「次のアレがアレ」という条件がほしい 3.) LEADウィンドウ関数をつかって、「次の結果レコード」についての条件を追加する SELECT event_name, user_pseudo_id, event_timestamp, -- PARTITION BYで擬似ユーザID毎のグループに分ける(でないと同一ユーザによる「次」が担保できない) -- ORDER BYで時系列的な並びを担保する LEAD(event_name, 1) OVER (PARTITION BY user_pseudo_id ORDER BY event_timestamp) AS next_event FROM `firebase-public-project.analytics_153293282.events_20180720` WHERE (event_name = "no_more_extra_steps" OR event_name = "spend_virtual_currency") ORDER BY user_pseudo_id, event_timestamp こんなふうに 「次のイベント」をnext_eventとして抽出できるようになったので これに対してファネル条件追加してやればよい 4.) no_more_extra_stepsイベントの直後にspend_virtual_currencyイベントが来るケースをカウントするクエリ SELECT COUNTIF (event_name = "no_more_extra_steps") AS funnel_1_total, COUNTIF (event_name = "no_more_extra_steps" AND next_event = "spend_virtual_currency") AS funnel_2_total FROM ( SELECT event_name, user_pseudo_id, event_timestamp, LEAD(event_name, 1) OVER (PARTITION BY user_pseudo_id ORDER BY event_timestamp) AS next_event FROM `firebase-public-project.analytics_153293282.events_20180720` WHERE (event_name = "no_more_extra_steps" OR event_name = "spend_virtual_currency") ORDER BY user_pseudo_id, event_timestamp ) こんなふうに 25回のno_more_extra_stepsの内22回がspend_virtual_currencyっぽい 5.) ユーザ数ベースでみると、 SELECT COUNT(DISTINCT funnel_1_total) AS f1_users, COUNT(DISTINCT funnel_2_total) AS f2_users FROM ( SELECT IF (event_name = "no_more_extra_steps", user_pseudo_id, NULL) AS funnel_1_total, IF (event_name = "no_more_extra_steps" AND next_event = "spend_virtual_currency", user_pseudo_id, NULL) AS funnel_2_total FROM ( SELECT event_name, user_pseudo_id, event_timestamp, LEAD(event_name, 1) OVER (PARTITION BY user_pseudo_id ORDER BY event_timestamp) AS next_event FROM `firebase-public-project.analytics_153293282.events_20180720` WHERE (event_name = "no_more_extra_steps" OR event_name = "spend_virtual_currency") ORDER BY user_pseudo_id, event_timestamp ) ) こんなふうに 6.) この抽出には問題がある。 たとえばno_more_extra_steps後何時間か経過した後にspend_virtual_currencyイベントが発生していた場合 この場合、no_more_extra_stepsが直接spend_virtual_currencyに寄与したかというと考えにくい。 ので、こうしたイベント間の時間経過も考慮したフィルタ条件を書く必要がある(かもしれない。実際は要件による。) ここでは、1分以内に後続のファネルイベントが起きた場合、を条件に追加したいとする 7.) 「次のイベントのtimestamp」カラムをSevent_name同様追加した上で、timestampの差の範囲指定条件を追加する SELECT COUNT(DISTINCT funnel_1) AS f1_users, COUNT(DISTINCT funnel_2) AS f2_users FROM ( SELECT IF (event_name = "no_more_extra_steps", user_pseudo_id, NULL) AS funnel_1, IF ( event_name = "no_more_extra_steps" AND next_event = "spend_virtual_currency" AND -- `no_more_extra_steps`の後1分以内に`spend_virtual_currency`イベントが発生したかどうか -- timestampはmicrosecond単位なので1000 * 1000 next_timestamp - event_timestamp < 60 * 1000 * 1000, user_pseudo_id, NULL) AS funnel_2 FROM ( SELECT event_name, user_pseudo_id, event_timestamp, LEAD(event_name, 1) OVER (PARTITION BY user_pseudo_id ORDER BY event_timestamp) AS next_event, -- next_timestampを追加 LEAD(event_timestamp, 1) OVER (PARTITION BY user_pseudo_id ORDER BY event_timestamp) AS next_timestamp FROM `firebase-public-project.analytics_153293282.events_20180720` WHERE (event_name = "no_more_extra_steps" OR event_name = "spend_virtual_currency") ORDER BY user_pseudo_id, event_timestamp ) ) こんなふうに イベント時間区間指定なしの場合より減ってるが、こっちがリアルな数値。 8.) 1ヶ月分のデータを日別で取得してデータポータルで可視化してみる SELECT day, COUNT(DISTINCT funnel_1) AS f1_users, COUNT(DISTINCT funnel_2) AS f2_users FROM ( SELECT -- 日別をとる FORMAT_TIMESTAMP('%F', TIMESTAMP_MICROS(event_timestamp)) as day, IF (event_name = "no_more_extra_steps", user_pseudo_id, NULL) AS funnel_1, IF ( event_name = "no_more_extra_steps" AND next_event = "spend_virtual_currency" AND next_timestamp - event_timestamp < 60 * 1000 * 1000, user_pseudo_id, NULL) AS funnel_2 FROM ( SELECT event_name, user_pseudo_id, event_timestamp, LEAD(event_name, 1) OVER (PARTITION BY user_pseudo_id ORDER BY event_timestamp) AS next_event, LEAD(event_timestamp, 1) OVER (PARTITION BY user_pseudo_id ORDER BY event_timestamp) AS next_timestamp FROM `firebase-public-project.analytics_153293282.events_*` WHERE (event_name = "no_more_extra_steps" OR event_name = "spend_virtual_currency") AND _TABLE_SUFFIX BETWEEN '20180629' AND '20180728' ORDER BY user_pseudo_id, event_timestamp ) ) GROUP BY day ORDER BY day こんなふうに せっかくなのでデータポータルに展開して 可視化 以上、BigQueryでFirebase Analyticsイベントの2stepなクローズドファネルやっていきでした。
がわからなかったが、わかった気がした人間のメモ “わかる"とは 定義がわかる 作用のしくみがわかる ありがたみがわかる Context すごいHaskell本を読んだ Functional Swiftを読んだ わかっていること(たぶん) ファンクター(関手) 「文脈をもつ」値(以降、「箱」で表現する。個人的にイメージがしっくりくる為) 文脈what 「値があるかもしれないし、ないかもしれない」文脈 HaskellのMaybe SwiftのOptional 「成功しているかもしれないし、失敗しているかもしれない」文脈 HaskellのEither SwiftのResult fmap実装をもつ fmap Haskell class Functor f where fmap :: (a -> b) -> f a -> f b Swift Optional func map<U>(_ transform: (Wrapped) throws -> U) rethrows -> U? 通常の関数を受け取って、箱の中身に適用した結果で箱の中身を差し替える わかった気がすること a.) アプリカティブ 「ある関数を複数の箱に対して適用」できるようにする関数 コード(swift) precedencegroup Applicative { associativity: left } infix operator <^>: Applicative infix operator <*>: Applicative func fmap<T, U, R>(f: @escaping ((T, U) -> R), x: T?) -> ((U) -> R)? { x.map { _x in { u in f(_x, u) } } } func <^><T, U, R>(f: @escaping ((T, U) -> R), x: T?) -> ((U) -> R)? { fmap(f: f, x: x) } func <*><U, R>(f: ((U) -> R)?, x: U?) -> R? { guard let f = f else { return nil } return x.map(f) } do { let x: Int? = 2 // (+)のような、2引数関数を適用しようとすると、 // 箱の中に関数((Int) -> Int)が入ってしまう let f: Optional<(Int) -> Int> f = x.map { lhs in { rhs in lhs + rhs }} let y: Int? = 3 // mapではできない // Optional<Optional<Int>>になって箱が2重になってしまう let result = f.map { _f in y.map(_f)} print(result) // Optional(Optional(5)) // Applicative(<*>)を定義してアプリカティブスタイル // 関数適用可能な形に(2引数 -> 1引数に)curry化するfmapと、 // fmap結果の箱の中身が関数になったものと更に適用する箱を受け取って、 // 2引数目の箱にfmap結果の関数を適用する<*>(アプリカティブ)を定義 // fmap内の1引数目のmapでnilなら箱の関数はnilになり、<*>のguard節で結果がnilになる // 1引数目が.someで箱の関数が.someで返却された上で、2引数目の箱がnilなら、やはり結果がnilになる // つまり関数適用対象の「いずれかがnilなら結果がnilになる」ことを保証しつつ、<*>をつなぎあわせるだけで、 // 箱として扱ったまま関数適用できる let result2 = fmap(f: (+), x: x) <*> y print(result2) // Optional(5) // fmapをinfix operator化したもの。Swiftでは`$`をoperatorとして使えないので、<^>を定義している let result3 = (+) <^> x <*> y print(result3) // Optional(5) } なにがうれしいか 箱を箱として扱いつつ(unwrap操作不要で)、関数適用できるのがうれしい b.) モナド 「flatMapできる箱」 flatMap Haskell (>>=) :: m a -> (a -> m b) -> m b Swift Optional flatMap<U>(_ transform: (Wrapped) throws -> U?) rethrows -> U? 「箱」と、「箱の中身を受け取って別の箱を返却する関数」を受け取って、別の箱を得る関数 なにがうれしいか 箱の世界を保ったまま、箱の中身に対してふつうの関数適用(map)のみならず、箱自体を差し替えることも可能になるのがうれしい コード(swift) func possibleFoo(_ x: Int) -> Int? { x } func possibleBar(_ x: Int) -> Int? { x } func possibleBaz(_ x: Int) -> Int? { nil } let x: Int? = 2 x.flatMap(possibleFoo).flatMap(possibleBar) // Optional(2) x.flatMap(possibleFoo).flatMap(possibleBar).flatMap(possibleBaz) // nil unwrapせず、箱(Optional)のまま引き回せる 通しで成功したら値が得られて、どこかで失敗したらnilが返る わかっていないこと モノイドがよくわかっていない🤔 参考リンク SwiftのOptional型を極める - Qiita Swiftでアプリカティブスタイル - Qiita Swiftのエラーハンドリングはなぜ最先端なのか
Situation 調べ物をしてる際、記事URLのメモを取ることがある メモはmarkdown記法をサポートしているエディタをつかっている 記事URLだけでは後で見返すときに識別しづらいので、[title](url)の形式で貼ることが多い ブラウザ(Chrome)で[title](url)をclipboardにコピーするには以下の手間を伴う 記事タイトルコピー 👈 キーボードで完結させづらくいちばんつらい アドレスバーへフォーカス [title](url)へ整形 全選択&コピー 億劫になってきた Motivation もっとラクに[title](url)をcopyしたい 念じるだけでできるのが理想 せめてコマンド1発 Solution Shift+Yで[title](url)をclipboardにコピーするChrome拡張を書く Alternative Considered 代替手段はとくにみてない chrome拡張書いたことなかったし、書いてみるよい機会とおもったのが大きい Action manifest.jsonを追加 { "manifest_version": 2, "name": "ymdlink", "author": "hrfmmr", "version": "0.0.1", "description": "yank current tab's title and link as markdown notation" } chrome://extensionsでロードする Load Unpackedでfile explorerを起動 ロードするextension prjディレクトリを選択 extensionsに追加される permissionsを設定 Chrome extensionアプリがchromeのAPIアクセスするには、パーミッションを明示的に設定する必要がある 📖 see Declare permissions 今回は「現在のタブ」のtitleとurlを「clipboardにコピー」するので、以下をmanifest.jsonに追記する { ... "permissions": [ "activeTab", "clipboardWrite" ] } commandsを定義 キーマップ割当の設定をmanifest.jsonに追記する { ... "commands": { "yank": { "suggested_key": { "default": "Alt+Shift+Y" }, "description": "yank current tab's title and link as markdown notation" } } } Shift+Yを割り当てたかったが、commandsは「modifier」+ 「secondary modifier」 + 「key」の3つから成るという基本ルールがあり、 modifierはAltやCtrl, secondary modifierは(定義するなら)Shiftを割り当てるルール なので、「secondary modifier」+「key」のShift+keyはキーマップ定義できない模様 なので、Alt+Shift+Yとすることに 📖 see MDN Web Docs - commandss background scriptを書く 今回GUIは特に不要なショートカットキー割当に特化した拡張なので、popup.htmlは設置せず、background scriptのみ書いていく manifest.jsonに以下を追記 { ... "background": { "scripts": [ "background_scripts/main.js" ] }, } background_scripts/main.js // manifest.jsonのcommandsで定義したキー押下のイベントハンドラを定義 chrome.commands.onCommand.addListener(function(command) { if (command == "yank") { console.log("ymdlink: yank command is triggerd👉"); chrome.tabs.query({ active: true, lastFocusedWindow: true }, function(tabs) { // 現在のタブ情報を取得 var tab = tabs[0]; const yanktxt = `[${tab.title}](${tab.url})` // clipboardにコピー copy(yanktxt); console.log(`ymdlink: yanked ${yanktxt}🔗`); }); } }); // copy対象のテキスト選択するためのtextarea要素を生成 const createTextArea = () => { const textArea = document.createElement("textarea"); textArea.style.position = "absolute"; textArea.style.left = "-100%"; textArea.contentEditable = "true"; return textArea; } // copy用textareaにテキストをセットし、選択状態にした上でclipboardへコピー実行 const copy = (data) => { const textArea = createTextArea(); textArea.value = data; document.body.appendChild(textArea); textArea.select(); document.execCommand('copy'); document.body.removeChild(textArea); } background scriptのデバッグ chrome://extensionsからInspect views backgronud pageをクリックして、デバッガコンソールを開く
Read morePrerequisites foo.comのドメイン AWSアカウント blog.foo.com(ないしワイルドカード)のACM証明書(us-east-1 regionのもの) aws cli jq Goal Hugoで生成したstatic siteコンテンツをS3でホストしてCloudFrontで独自ドメイン配信 独自ドメイン= blog.foo.com 配信はhttpsへリダイレクト Hugoプロジェクト作成 brew install hugo hugo new site site cd site hugo --minify static site用S3バケット作成 s3bucketpolicy.json { "Version": "2012-10-17", "Statement": [ { "Sid": "PublicReadGetObject", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::blog.foo.com/*" } ] } aws s3 mb s3://blog.foo.com --region="ap-northeast-1" aws s3api put-bucket-policy --bucket blog.foo.com --policy file://s3bucketpolicy.json aws s3 website s3://blog.foo.com/ --index-document index.html aws s3 sync "public/" "s3://blog.foo.com" --delete --acl "public-read" http://blog.foo.com.s3-website-ap-northeast-1.amazonaws.com/へアクセスできるか確認
Read more