見出し画像

Skipがついにリリース - 「バックエンドのためのReactみたいなもの」

11,801 文字

Reactは少なくともクライアントサイドにおいて、アプリケーションの構築方法を大きく変えたと言えるでしょう。コンポーネントモデルとリアクティビティモデルは、たとえReact自体が好きではない人でも、優れた構築方法であることが判明しました。このモデルは広く受け入れられ、Reactの世界から遠く離れたフレームワーク、さらにはReactが登場する前から存在していたフレームワークでも採用されています。
しかし、バックエンドは同じような大きな変化を見せていません。新しい言語やパターン、クールな機能を見出してはいますが、バックエンドとフロントエンドの関係性は、GraphQL以降、実質的な進化を遂げていないように感じます。確かに、TypeScriptやtRPCのような素晴らしいものはありますが、データベースから実際のユーザーがデータをロードするまでのリアクティビティグラフという考え方は、まだ実現されていませんでした。
少なくとも、今までは。FacebookとMetaが懸命に取り組んできた成果として、私の大好きな開発者の一人であるVueのChristopher Chauが、リアクティブフレームワーク「Skip」を発表しました。彼の説明によると、これは「バックエンドのためのReactのようなもの」だそうです。
これは本当にクールです。Vは単なるランダムな開発者ではありません。彼はExcalidrawやPrettier、そしてReactの中でも最も強力な状態管理ライブラリの一つであるRecoilの作者です。彼は可能な限り深い知見を持っています。「深い」と言ったのには理由があります。なぜならSkipはプログラミング言語として始まったからです。
このクレイジーな新フレームワークとパラダイムの歴史について、ある意味GraphQLの続編のようなものについて詳しく知りたい方は、このスポンサーブレイクを挟んでからお話ししましょう。
最近、多くの人々が開発ツールを構築していますが、パーツは単純に見えても、その多くはそうではありません。私が人々が最も間違えているのを目にするのは、APIアクセスです。そこで、今日のスポンサーであるUnkeyが存在する理由です。彼らはAPIの構築をはるかに容易にし、認証からキー管理、レート制限、DoS保護まで、あらゆるものを扱います。
彼らはあらゆるものをサポートしています。当然、TypeScriptをサポートしていますが、会社全体がTypeScript開発者によって構築されているためです。また、Python、Rust、Go用のSDKもあり、直接curlで使用することもできます。Elixirでも使いたい場合は?はい、ElixirのSDKもあります。超クールですね。Javaもサポートしています。
先に言うべきでしたが、これは完全にオープンソースです。これは素晴らしいことです。コードを信頼してセルフホストできるだけでなく、適切なフルスタックのオープンソースアプリケーション管理について多くのことを学べるからです。これは複雑なコードベースで、内部で多くのことが行われているからです。
このようなAPIを自分で設定すると、マルチクラウドの扱い方から環境変数の無効化の方法、IPアドレスだけでなくキーによるレート制限の管理まで、多くのことを見落としがちです。それは楽しいものではありません。私もそこを経験してきました。そしてそこを避けたいなら、今日Unkeyをチェックすべきです。soy.linkuでチェックしてください。
私はこれらすべてについて多くの時間を深く掘り下げてきました。その深掘りの全体は、私のもう一つのチャンネルTheo throwawaysで公開される予定です。リンクは説明欄にあります。
まず、このウェブサイトをお見せしなければなりません。比較的新しいプロジェクトにしては素晴らしいものです。とはいえ、そんなに新しくもありません。Skipの一部はMetaで長らく使用されており、彼らは実際に多くのものに使用しています。
Skipは興味深いフレームワークです。なぜなら、これはExpressやNest、Next.jsなどの代替品ではないからです。これは、バックエンドサービス、データソースなどとクライアントの間に位置するレイヤーです。GraphQLサービスが他のサービス、データベース、マイクロサービスなどとクライアントの間に存在するのと同様です。
彼らの説明によると、これは「効率的でデバッグ可能なリアルタイム機能を構築するためのシンプルで包括的な抽象化」です。簡単な道のりではありませんが、確かにそれを実現しています。
彼らの目標は、Reactがコンポーネントのグラフによってこの種のバグのカテゴリー全体を解決したように、状態を単純化することです。おそらく以前にReactのツリーのような画像を見たことがあるでしょう。アプリコンポーネントがあり、それには2つの子要素であるinspiration generatorとfancy textがあり、そしてinspiration generatorには独自の子要素があります。
これで上から下まで明確な依存関係ツリーができました。inspiration generatorに何らかの状態があり、その状態をfancy textに渡したい場合、Reactでは状態は親に存在する必要があります。なぜなら、子要素がアクセスするためにはそうである必要があるからです。子要素が自身で所有している場合、それを好きなように扱うことができますが、より上位や横にあるものはアクセスできません。子要素のみがその状態にアクセスできます。
これにはコストがあります。私たちも経験したように、状態を高すぎる場所に移動させると、すべてが再レンダリングされてしまいます。しかし、このモデルが解決した問題の数を十分に評価していないのです。
Reactの以前は、ボタンをクリックして何かが変更される機能に取り組む際、手動で変更する場所をすべて指定する必要がありました。例えば、Amazonでカートから商品を削除するボタンを押した場合、15の異なる場所で状態を更新する必要がありますが、コーナーにあるアイコンを生成するものを再実行することを忘れてしまうかもしれません。状態を適切に更新していても、コーナーには13個のアイテムが表示されたままで、実際には12個しかないということがありえました。
これこそがReactがこのリアクティビティモデルによって基本的に解消したものです。レンダリングのコストはありますが、モデル自体にはメリットとデメリットがあります。宣言的である必要があり、変更が発生するたびにすべてを再実行する必要があるというコストはありますが、何も同期が外れることはないというメリットがあります。
更新のロジックを定義する必要すらありません。なぜなら、グラフ自体が更新を定義するからです。これは間違いなく私たちの状態を単純化します。私たちはこれをReactで見てきました。React Queryのようなツールでも見てきました。他のフレームワークや領域でも見てきました。これは非常に強力です。
では、Skipとは実際には何なのでしょうか?彼らは「ランタイム」という用語を使い続けていて、それは奇妙に感じますが、特に彼らのカスタム言語Skipと純粋なCの組み合わせで書かれていることを考えると、適切だと思います。はい、このリポジトリの20%は純粋なCコードです。これはかなり驚くべきことです。
ランタイムは興味深いものです。これは、これらのコレクション(彼らはそう呼んでいます)とその関係を定義する方法です。このSkipランタイムは、外部サービスとユーザーが実際に使用するアプリケーションの間に存在します。
動作の簡単な説明は以下の通りです。他の場所からサービスやストリーム、およびデータベースをすべてコレクションとしてインポートします。それらをTypeScriptで、より具体的に必要なものを持つ新しいコレクションにマッピングします。すべてのユーザーを持つデータベーステーブルがあり、フレンドリストが欲しい場合、フレンドリストはユーザーテーブルから派生させることができますが、それは新しいコレクションです。
これらのマップを作成した後、それらから再利用可能なより複雑なロジックを作成でき、それがクライアントにパイプされ、クライアントはリアルタイム更新を購読できます。そのため、友人の一人がアカウントを削除すると、ユーザーテーブルが変更され、それは派生したフレンドコレクションが変更されることを意味し、ユーザーが見ているフレンドリストも変更される必要があります。または、友人が名前を変更した場合、その変更をユーザーのフレンドリストまで伝播させることができます。
コードに深く潜る前に、いくつかのことを非常に明確にしておきたいと思います。まず第一に、これは私が最近話した中で最も本番環境に近いものの一つです。なぜなら、彼らは実際にFacebookで本物のものに使用しており、これは何年もの間取り組まれてきたプロジェクトだからです。これは何らかのランダムな趣味のプロジェクトではありません。Next.js 14がリリースされた時の私のサーバーアクションの扱い方のような使い捨てのものでもありません。これは実際の正当なプロジェクトで、さらに多くの時間が投入されることでしょう。
とはいえ、Meta以外でほとんど誰もこれをまだ使用すべきではないと思います。本番ワークロードを処理できるという意味では洗練されていますが、一般公開されたばかりという意味では新しく、意図された特定の方法以外での使用には準備ができていません。私は過去1時間半このリポジトリで多くの時間を費やしてきたので、それが分かります。それは一つの旅路です。
では、彼らのメンタルモデルを簡単に見ていきましょう。Skipランタイムの2つの重要な部分は、コレクションとマッパーです。コレクションはデータのソースです。それらは単なるデータの集まり、配列のようなものと考えてください。そしてマッパーは、コレクションから新しいコレクションを作成するものです。配列にmapを実行するようなもので、新しい配列を得ます。
マッパーは既存のコレクションの内容を変更することはできません。できるのは、既存のコレクションから新しいコレクションを作成することだけです。そしてそれを行う時、例えばユーザーコレクションからフレンドリストコレクションを作成する場合、ユーザーコレクションからフレンドリストコレクションへの直接的なパスができます。
また、コレクションは常にすべてのデータを持っている「eager」か、ダウンストリームの何かによって要求されるまで実際にデータを取得しない「lazy」のいずれかであることも注目に値します。
彼らのコードベースの例を見てみましょう。エラーはすべて無視してください。前述の通り、彼らはまだあなたが実際にここに深く潜ることを想定していないようです。しかし、だからこそあなたたちはここにいるのです。私が苦労できます。
これは楽しい小さなサービスです。データベースサーバーから見ていきましょう。Skipサービスを定義し、データベースを取得するためにSQLiteをインポートします。ここには小さなヘルパーrun関数がありますが、その大部分は無視できます。
実際のエンドポイントを見てみましょう。これはすべてExpressを使用しています。usersエンドポイントがあり、service.gstreamUIDを呼び出します。これはSkipの動作方法の興味深い特徴の一つです。/skip/stream/user/idのようにリクエストするのではなく、すべてのストリームは同じエンドポイント/v1/stream/uidから来ます。このUUIDはSkipによって作成され、特定のコレクションの特定のインスタンスを表します。
私たちはSkipサービスに、データベースからすべてのユーザーのためのユーザーコレクション(ユーザーに公開したくないものかもしれませんが、この場合はそうです)を欲しいと伝えます。そしてユーザーのUIDを取得し、ユーザーのリクエストをlocalhost:8080/v1/streamsこのUIDにリダイレクトします。このリダイレクトによって、ユーザーはすべてのユーザーデータをストリーミングするものに送られます。そのため、何かが変更された場合、それをキャッチするイベントリストを持つことができます。
そのコードは本当にシンプルです。このコードはサーバーでもクライアントでも、JavaScriptとEventSourceを持つものなら何でも動作します。イベントソースを作成します。文字通り、URLとして/usersを指定するだけで、それがリダイレクトされたストリームURLになります。
これでストリームのイベントソースができました。初期化された時のイベントリスナーがあり、それは初期データを素早くログ出力します。そして更新のためのイベントリスナーがあり、更新が発生すると、その更新を取得します。それだけです。これがそのコードのすべてです。後ほど実際のReactコードのクライアント例をお見せします。
さて、fetchJsonを使用します。fetchと言っていますが、実際にはput技術がfetchを通過しています。ここでは、ID 123のユーザーを更新し、名前をDanielに変更し、国をUKに設定しています。
そのコードを見てみましょう。app.putがあり、userIDを取得し、パラメータからキーを取得し、このputリクエストのボディからデータを取得します。つまり、そこで送信したもの、このボディです。これら両方を持っています。キーと、データベースに欲しい新しいデータです。
データベースランナーを使用し、現在のテーブルに挿入または置換します。これは名前、データ、IDを渡し、オブジェクトを見つけ、これらの値を渡します。クール、典型的なSQLです。私たちはこれを見たことがあります。
ここで面白くなります。データベースクエリが実行された後、service.opputusersを実行します。つまり、このキーが更新したい識別子であるユーザーコレクションでデータを更新します。この1行だけで、このユーザーに依存するすべてのものを更新するトリガーとなります。
データベースからのこのユーザーデータから来るすべてのものがそこからマッピングされているか、そこからマッピングされたコレクションである限り、グラフがどのように機能するかが分かります。私たちは20から100層下にいる可能性があり、このユーザーコレクションは依然として元の真実のソースです。このservice.put呼び出し一つで、すべての依存関係が更新されるトリガーとなります。
これについて書かなければならないコードはありません。このテーブルを更新し、次にこれを更新し、次にこれを更新するというコードを書く必要はありません。私たちが慣れているような方法で、React Queryでポストコール完了時に15のものを検証して、すべてが再フェッチするようにする必要はありません。そのようなものは全くありません。
実際にこのコードを実行すると、面白いことが起こります。initレスポンスが見えます。123がDanielであることが分かります。なぜなら、前にコードを実行した時にそう設定されていたからです。値が既にそれに設定されているため、更新は発生しません。
しかし、デフォルトのデータベース状態を削除すると、2回目のget呼び出しが来る前に更新が入ってくるのが分かります。まず、このinit呼び出しがあります。initには、EventSourceが作成された時に最初に送られてくるすべてのデータが含まれています。次に、set呼び出しがあります。これは、ユーザーの名前を別の名前、d ualに更新するためのポスト呼び出しを送信した時です。
get呼び出しを実行する前に、この更新がすぐに送られてきます。ここでは、ユーザー123を取得しています。つまり、実際にはデータベースからこのIDを持つユーザーを取得しようとしています。これはストリームが更新を送信した後に来ます。なぜなら、既存の接続が更新を送信する方が遥かに速いからです。超クール。
削除は失敗しました。なぜならエンドポイントの一つが死んでしまったからです。それについてはあまり心配する必要はありませんが、要点は分かります。カスタムコードを書くことなく更新データが送られてきます。これが魔法の部分です。一度すべてを適切に設定すれば(これは簡単ではありません)、結果として変更は常にすべての依存関係をトリガーし、それらの依存関係を持つすべてのクライアントがすぐに変更を確認できます。
しかし、そこに到達するのは簡単ではありません。これらのレイヤーをすべて手動で定義する必要があります。例えば、実際にdatabase.tsファイルを見ると、これらのコレクションがどのように定義されているかが分かります。typeユーザーがあり、これは名前と国です。userCollectionは、キーが文字列で値がユーザーであるeagerコレクションです。
クラスUserResourceがあり、これはResourceUserCollectionを実装しています。instantiateヘルパーがあり、ユーザーコレクションを受け取り、実際のユーザーを返します。ここからサービスを設定します。これはマッピングがない場合です。これは単一のコレクションだけですが、それでもこれだけのものがあります。
はい、前述の通り、これは1つのコレクションを作成するために必要なすべてですが、それでもかなりの量です。初期データ、リソース、そしてすべての依存関係を作成するcreateGraph関数でサービスを初期化する必要があります。ここでは依存関係はありませんが。
しかし、彼らが持っている例を見てみましょう。これは実際にかなり説得力のある例です。userID、groupIDの両方があり、退屈な標準的な型です。次にuser型とgroup型があります。ユーザーには名前、アクティブかどうかのブール値、そしてユーザーIDの配列であるfriends値があります。次にGroupがあり、これは名前とメンバー配列(これも再びユーザーID)を持っています。
現在のユーザーの友人を、アクティブかどうかに基づいて、彼らが所属するグループごとにグループ化して見たいとします。これは些細なグループ化ではありません。SQLでさえ、これは少し面倒です。ロジックは比較的簡単に見えます。グループのメンバーを、現在のユーザーの友人との1つの交差とアクティブステータスでフィルタリングします。
しかし、ユーザーが自分のユーザー名を変更したり、アクティブステータスが変更されたり、所属するグループが変更されたりした時に、リアルタイムで最新の情報を維持することは、全く楽しい問題ではありません。
彼らはこれを、groupID、group、userID配列を取るactiveUserマッパーを使用して定義します。これは1対1でそこからマッピングしています。グループを取り、userID配列を返しますが、アクティブではない人をフィルタリングします。ここでの目的は、特定のグループからアクティブなユーザーだけを取得することです。
次に初期データがあり、これはすべてのユーザーとすべてのグループです。そして、グラフで最初のレイヤーを作成します。入力からすべてのグループを取り、activeUsersでマッピングします。これにより、特定のグループのすべてのアクティブユーザーが得られます。
ここで、users:input.users、activesを返すことができ、これで初期データ、リソース、これらのマッピングから来るこれら2つの派生コレクションができました。
このサンプルサービスは、ユーザーとグループの2つの入力コレクションで動作します。リソースへのいくつかのリソース入力を渡し、これは各グループのアクティブユーザーのセットという名前の反応的に計算されたコレクションactivesと、ユーザー入力コレクションです。
activesコレクションは状態計算グラフの出力で、入力グループをマッピングし、アクティブフラグが設定されているユーザーをフィルタリングすることで生成されます。私たちのサービスは、クライアントがクエリまたは購読できる、ユーザーIDによってパラメータ化されたリソースを公開したいと考えています。これは各グループのユーザーのアクティブな友人を使用します。
ここにfilterFriendsマッパーがあります。このマッパーは再び、groupID、userID配列、もう1つのuserID配列、ここでCU(最終結果)を取り、uids(グループのユーザーID)を受け取り、このユーザーの友人であるものをフィルタリングします。つまり、this.userの友人である場合は保持し、そうでない場合は保持しません。
これにより、現在のユーザーの友人を取り、特定のグループから友人であるユーザーIDだけを返すことができます。これで、このマッピングを行うactiveFriendsコレクションができました。
instantiateでは、どのように行うかが分かります。ユーザーはinput.users.getet uniqueとthis.userIDです。なぜなら、this.userIDは、ここで情報を取得しているユーザーのために渡したものだからです。このユーザーを持っているので、彼らの友人も持っています。これをfilterFriendsマッパーのコンストラクタの一部として渡すことができ、それはすべてのアクティブな人々を通過し、あなたの友人であるものをマッピングします。そして、ここでユーザーを渡すときにあなたが誰であるかを知っています。
少し複雑で、制御フローはそれほど明確ではありません。おそらくこれが最初に来るべきだと思います。なぜなら、これがあなたがヒットするものであり、そしてそれが取得するユーザーIDをfilterFriendsに渡すからです。
これを頭の中でモデル化するのは少し難しいです。なぜなら、これは私が見た中で最も機能的なパラダイムの一つだからです。真実の単一のソースを持ち、それを副作用のない関数を通じて下に送ろうとしています。これらのクラスでは非常にオブジェクト指向的です。
これは乗り越えなければならない精神的なハードルの集まりのようなものですが、それが何を可能にしているかを見ると、非常にクールです。なぜなら、この確かに消化しにくいコードにより、クライアントでユーザーのアクティブな友人をシームレスにリクエストでき、ユーザーのステータスが変更されたり、ユーザーの名前が変更されたり、ユーザーのグループが変更されたりした時、追加のコードを書くことなくクライアントは更新を確認できるからです。
クライアントサイドのコードをお約束しましたので、見てみましょう。彼らのHacker Newsクローンの例があります。私はそれを動かそうとしましたが、このコードが動く世界はありません。申し訳ありません。しかし、フィードコンポーネントがあります。デフォルトでpulledバージョンがありますが、それは使用しません。代わりにreactiveバージョンを見てみましょう。
イベントソースを作成し、init イベントが送られてきた時のイベントリスナーがあり、postsを更新されたpostsに設定します。これを初期postsと名付けた方が少し明確だと思います。なぜなら、上にposts、setPosts、useState Post配列があるからです。
イベントソースが作成されると、initが発生し、これがpostsのデフォルト状態を設定します。そして更新が送られてきた時、それはすべてのpostsと共に送られてきます。そのため、単にsetPosts関数を新しいpostsのセットで再実行するだけです。
そして変更を加えるために、postコールがあります。postsを取得しているので紛らわしいですが、つまりpostメソッド呼び出しです。これが更新をトリガーし、バックエンドが適切に実装されている限り、これは自動的に送られてきて、ユーザーとしてあなたの端で更新を確認できるはずです。
これは面白い例です。なぜなら、3つの部分があるからです。実際のウェブアップであるwwwがあり、バックエンドの事物とフロントエンドの経験の間のバインディングを行うSkipレイヤーであるreactiveサービスがあります。しかし、このフェイクアップのバックエンドとなることを意図したPythonプロジェクトであるwebサービスもあります。
これはほぼ、元々どのように書かれていたかを想像できます。そして、アプリがより良いパフォーマンスを発揮する新しい要件、または新しいモバイルアプリを作成していて自動的に更新させたい場合、これが既存のバックエンドやマイクロサービスレイヤーとフロントエンドの間にSkipのようなものを追加する方法です。
実際にそのSkipレイヤーにpostsを取得するために、イベントストリームがサポートされているかをチェックし、サポートされている場合は単にリアクティブサービスURLからのイベントストリームに通過させ、そうでない場合はスナップショットを返します。
投稿を作成するためのapp.postpostsがあり、実際にデータベースに入れるためのDBコールがすべてありますが、Skipを通じて更新をトリガーする方法は、マイクロサービスがリアクティブサービスURL/input/post/postIDを、変更されたものと共に呼び出すことです。
これで、Skipサービスは、この特定のコレクションに新しいエントリが追加されたことを知り、そのグラフを通じてクライアントに適切な更新をトリガーできます。または、ユーザーが/posts/postID/upvotesを呼び出してアップボートしない場合も同様です。ここではユーザーIDを実際には使用しません。なぜなら、認証が全くないと思うからです。
しかし、データベースで更新を行った後、同じことを行います。リアクティブサービスを呼び出し、これらのプロパティを持つ新しいupvote IDがあることを伝えます。これにより、upvotesに依存するすべてのものも変更されるトリガーとなります。
そのため、フィードでレンダリングしている投稿が、データベースに格納されている投稿とデータベースに格納されているupvotesを組み合わせている場合、消費しているものに影響を与える変更が送られてきます。
しかし、このモデルで理解し、クリックさせようとすることが重要なのは、データを変更する時、データの変更を行い、次にSkipに「hey、これが変更されたデータです」と伝える必要があることです。そこから、Skipがすべての更新を処理します。
他のシステムでは、この部分が全くなく、変更が発生した時にクライアントが再フェッチすることを期待するか、このバックエンドコードが15の異なるサービスを叩き、20の異なるキャッシュを無効化し、このデータに依存するすべての人とこのデータが取りうるすべての形状がクライアントに到達することを確実にするために必要なすべてのことを行うことになります。
これにより、なぜこれがすべての人のためのものではないのか、そして何が面白いのかが明確になることを願います。これは「消化された」バージョンです。以前は、新しいプログラミング言語を出荷しようとしていましたが、それが難しいことが判明しました。
そのため、彼らは戦略を変更しました。コアライブラリをSkipを使用して構築しましたが、人々にはJSでコードを書かせています。これは採用の障壁がはるかに低いですが、同じメリットを持っています。この採用の障壁がいかに高いかを見てきたので、彼らが完全に新しいクレイジーな言語でこれを書くことを要求した時にはどれほど高かったかが推測できます。
はい、これが私がこれについて得たすべてです。これは私が実際にとてもワクワクしている野性的なプロジェクトです。ほとんど誰もこれを使用すべきではないと思いますが、私たち全員が少し学ぶことができる本当にクールなアイデアを紹介していると思います。
みなさんの意見を聞かせてください。次回まで、さようならナード達。

いいなと思ったら応援しよう!