OAuth 2.0 (Authorization Code Flow / Implicit Flow)とOpenID Connectについて調べた
概要
OAuth 2.0のAuthorization Code FlowとImplicit Flowについて自分なりに整理した。あと、OpenID Connect(主にImplicit Client)も調べた。
いつもなんとなく調べて、ざっくり理解するも、時間が経つと忘れるので、アウトプットしておく。とはいえ、仕様や用語とかの理解が甘くて自信はない。ベースはオライリーの本で勉強して、OAuth 2.0の仕様もちょこちょこみてた。
用語
用語の理解があいまいな気がするけど、この記事内では統一しておきたい…
リソースサーバ
GoogleやFacebookなどが提供しているAPIサーバ
認可サーバ(Authorization Server)
GoogleやFacebookなどが提供している認可用のサーバ
リソースオーナー(Resource Owner)
GoogleやFacebookにリソースおいてるユーザ。API認可を必要とするアプリのユーザ。
クライアント(Client)
OAuthクライアント。Authorization Code Flowならサーバで動くアプリ、Implicit Flowならブラウザで動くアプリという認識。
ユーザエージェント (User-Agent)
多分Authorization Code FlowとImplicit Flowならブラウザってことになるのかな。
OAuthについて3行
OAuth 2.0 Authorization Code Flow
Authorization Code Flowだけではないが、OAuthエンドポイントとAPI間はHTTPS通信を行う。
以下でフローのステップを説明している。この説明は、リソースオーナーがAPIプロバイダにサインインしていることを前提としてる。
1. リソースオーナーに実行内容を知らせて、認可を要求
- OAuth認可ページを表示(またはウインドウを開く)
- リソースオーナーが認可
- (ユーザエージェントが)OAuth認可エンドポイント(認可サーバ)にリクエスト
リクエストには以下のようなクエリパラメータが必要
- client_id: クライアントID
- redirect_uri: 認可後、リダイレクトされるクライアントのURL
- scope: アクセス要求するデータの種類
- response_type: レスポンスの種類。codeを指定。
- state: ローカルステート。CSRF対策用の一意の値。
2. 認可コードをアクセストークンに交換
- 認可サーバがユーザエージェントをredirect_uriにリダイレクト
- クライアントが認可サーバにアクセストークン取得をPOSTリクエスト
- レスポンスでアクセストークンが返ってくる
認可サーバによってリダイレクトされるURLに、以下のクエリパラメータが付与されてる。
- code: 認可コード
- state: 認可リクエストのときに送ったローカルステート
クライアントから認可サーバへのPOSTリクエストには以下のパラメータが必要。
- client_id: クライアントID
- client_secret: クライアントシークレット
- code: 認可コード
- redirect_uri:
- grant_type: グラントタイプ。authorization_codeを指定。
- アクセストークンに期限がある場合は、expires_inやrefresh_tokenも必要
レスポンスではaccess_tokenに加えて、token_type、expires_in、refresh_tokenなども返ってくる。
3. API呼び出し
- クライアントがリソースサーバのAPIにリクエスト
- レスポンスでクライアントがリソースを取得する
リクエスト時に、HTTPのAuthorizationヘッダでアクセストークンを送付する。
OAuth 2.0 Implicit Flow
こっちはメインのウインドウで画面遷移をせずに認可を得られるのが特徴だと思った。
以下でフローのステップを説明している。
1. リソースオーナーに実行内容を知らせて、認可を要求
- OAuth認可用ウインドウを開く(iframeでもいいかも)
- リソースオーナーが認可
- (ユーザエージェントが)OAuth認可エンドポイント(認可サーバ)にリクエスト
認可がクライアントで簡潔するので、子ウインドウやiframeを使うのが特徴。
リクエスト時のクエリパラメータは以下である。Authorization Code Flowとはresponse_typeの指定が異なる。
- client_id: クライアントID
- redirect_uri: 認可後、リダイレクトされるクライアントのURL
- scope: アクセス要求するデータの種類
- response_type: レスポンスの種類。tokenを指定。
- state: ローカルステート。CSRF対策用の一意の値。
2. URLからアクセストークンを解析
- 認可サーバがユーザエージェント(認可用ウインドウ)をredirect_uriにリダイレクト
- クライアントがハッシュフラグメントのアクセストークンを解析
Implicit Flowでは認可サーバによるリダイレクトのURLに、ハッシュフラグメントとしてaccess_token、token_type、expires_in、stateなどが付加されている。
URLから解析したアクセストークンは親ウインドウに渡される。親ウインドウへの送付には、グローバル関数やwindow.postMessageを使う。親ウインドウでCSRF対策を行うべき。
3. API呼び出し
- クライアントがリソースサーバのAPIにリクエスト
- レスポンスでクライアントがリソースを取得する
リクエスト時に、HTTPのAuthorizationヘッダでアクセストークンを送付する。Authorization Code Flowと同じである。
また、Authorization Code Flowと違ってリフレッシュトークンがないので、アクセストークンを更新することはできない。アクセストークンを取得するには毎回上記のステップふむ必要がある。
OAuth 2.0を利用した認証
OAuthは認可の技術だけど、認証でも使われている。簡単に言うと、プロフィールのAPIあったとして、そのAPIの認可を得たら認証もできる、といった感じだと思う。
一見大丈夫そうだけど、リスクがあるとのこと。それは、OAuth 2.0 Implicit Flowを使った場合。詳しく説明してある記事があったので、そちらを参照するといい。僕もその記事で勉強しました。
OAuth 2.0 Implicit Flowをユーザー認証に利用する際のリスクと対策方法について #idcon
Implicit Flowだとアクセストークン置き換え攻撃ができるというリスクがある。例えば、誰かのアクセストークンを使えば、その誰かとして認証できるということ。URLのハッシュとしてアクセストークンが送られてきて、それを使って単純にJSでリクエストしてるから、まあそうだよねといった感じ。
このリスクを回避するために、OpenID Connect使うといいよとも上記の記事に書いてある。
OpenID Connectについて3行
- OAuth 2.0を利用した認証技術
- IDトークンというアクセストークンとは別のトークンがある
- 実装方法として、Basic ClientとImplicit Client、その2つをあわせたHybrid Flowがある
OpenID Connect Implicit Client
OAuth 2.0 Implicit Flowを利用した認証。Implicit Flowを利用しているだけあって、それと似たようなステップになってる。
1. ユーザ認可の取得
- 認証用ウインドウを開く(iframeでもいいかも)
- (ユーザエージェントが)OAuth認可エンドポイント(認可サーバ)に認証リクエスト
- 認可サーバーがエンドユーザを認証
- エンドユーザが承認/認可
認証リクエストはImplicit Flowの認可リクエストと似たようなもの。以下のようなクエリパラメータが必要。scopeとresponse_typeの指定とnonceがある点で認可リクエストとは異なる。
- client_id: クライアントID
- redirect_uri: 認可後、リダイレクトされるクライアントのURL
- scope: アクセス要求するデータの種類。openidを含む必要がある。
- response_type: レスポンスの種類。id_tokenおよびtokenを空白で区切りで指定。
- state: ローカルステート。CSRF対策用の一意の値。
- nonce: クライアントのセッションとIDトークンを紐付ける文字列。
エンドユーザとはOAuth 2.0の説明で出てきたリソースオーナーと同じ。エンドユーザの認証は、OAuth 2.0の説明ではサインイン済みとしてスキップした。実際には、認可リクエストでも認証は必要である。
2. URLからIDトークンとアクセストークンを解析
- 認可サーバがユーザエージェント(認証用ウインドウ)をredirect_uriにリダイレクト
- クライアントがハッシュフラグメントのアクセストークンを解析
こちらもOAuth 2.0 Implicit Flow同様に、リクエストの結果がURLのハッシュ部分に返される。以下のような内容になる。
- access_token
- token_type
- id_token
- state
- expires_in
3. IDトークンの検証
- 解析したIDトークンをCheck ID Endpointに送って検証
- 検証をパスしたら認証とする
Check ID EndpointとはIDトークンの検証サーバーみたいなもの。検証にCheck ID Endpoint使う決まりはないみたい。このIDトークンの検証があるので、アクセストークン置き換えによるなりすましリスクを防げるようだ。
検証後、IDトークンやアクセストークンは親ウインドウに渡しておく必要がある。
さいごに
OAuth 2.0のAuthorization Code Flow、Implicit Flowは共にシンプルなプロトコルということが実感できた。OpenID ConnectはOAuth 2.0をベースとしてるだけあって、OAuth 2.0をざっくり理解しておけばなんとかなりそう。
勉強してるうちに仕様書を読めるようになってきたので、まとめるのが面倒になった。かっちり書いたら仕様書に近づくだけなので、ゆるく書いたつもり。致命的な間違いがなければいいな…
Atomコードリーディング Part 3
rendererプロセス側の初期化
前回は、mainプロセスの初期化からBrowserWindow
でhtmlがロードされるとこまでみた。今回はそのhtmlから始まるrendererプロセスの初期化周りをおってみる。
static/index.html
mainプロセスでAtomWindow
(BrowserWindow
)からロードされるhtml。唯一のhtmlだったのでAtomは完全にSPAだと思う。
<!DOCTYPE html> <html style="background: #fff"> <head> <title></title> <meta http-equiv="Content-Security-Policy" content="default-src *; script-src 'self'; style-src 'self' 'unsafe-inline';"> <script src="index.js"></script> </head> <body tabindex="-1"> </body> </html>
中身はこれだけ、static/index.js
が起点になる。cssはどこでロードするんだろう?
static/index.js
window.onload
のハンドラを設定してる。
ATOM_HOME
の設定
setupAtomHome();
関数の中を見るとprocess
オブジェクトにアクセスしてる。rendererプロセスでもアクセスできることがわかった。もちろんmainプロセスとは別プロセスなので違うもの。
ロード設定のデコード
var rawLoadSettings = decodeURIComponent(location.hash.substr(1)); // デコード var loadSettings; try { loadSettings = JSON.parse(rawLoadSettings); } catch (error) { console.error("Failed to parse load settings: " + rawLoadSettings); throw error; }
前回、mainプロセスでロード設定をURLのlocation.hash
につめてたけど、ここではそれのデコードをしてる。
各種キャッシュディレクトリの設定
setupCoffeeCache(cacheDir); ModuleCache = require('../src/module-cache'); ModuleCache.register(loadSettings); ModuleCache.add(loadSettings.resourcePath); // Start the crash reporter before anything else. require('crash-reporter').start({ productName: 'Atom', companyName: 'GitHub', // By explicitly passing the app version here, we could save the call // of "require('remote').require('app').getVersion()". extra: {_version: loadSettings.appVersion} }); setupVmCompatibility(); setupCsonCache(cacheDir); setupSourceMapCache(cacheDir); setupBabel(cacheDir); setupTypeScript(cacheDir);
mainプロセスでもCoffeeScriptのキャッシュをしていたが、rendererプロセスの方では他にもキャッシュしてる。ざっとあげると、
- CoffeScript
- node_modules
- cson
- source map
- babel
- TypeScript
大規模アプリだから、このくらいしないと辛いのかな。注目すべきは、renderer側ならbabelとTypeScriptにも対応しているという点。
src/window-bootstrap
の実行
require(loadSettings.bootstrapScript);
上でも書いたけどloadSettings
にはmainプロセスからもらった設定が入ってる。
ipc
でwindow-command
チャネルに'window:loaded'メッセージを送信
require('ipc').sendChannel('window-command', 'window:loaded');
まわりくどい感じだけど、ここでプロセス間通信をしてる。ipc
モジュールのイベントハンドラはAtomApplication
クラス(src/browser/atom-application.coffee
)で設定していたので、そちらをみる。
ipc.on 'window-command', (event, command, args...) -> win = BrowserWindow.fromWebContents(event.sender) win.emit(command, args...)
window-comman
チャネルにメッセージがきたら、メッセージを送ってきたウインドウを取得して、イベントを発火してる。今回はwindow:loaded
が発火する。このwindow:loaded
をウインドウ自身が監視してるんだけど、それは前回見た。
処理はざっとこんなものなので、src/window-bootstarp.coffee
をみる。
src/window-bootstarp.coffee
# Like sands through the hourglass, so are the days of our lives. require './window' # windowにユーティリティを生やす Atom = require './atom' window.atom = Atom.loadOrCreate('editor') # `Atom`のインスタンス化 atom.initialize() # エディタの初期化 atom.startEditorWindow() # エディタ開始 # Workaround for focus getting cleared upon window creation windowFocused = -> window.removeEventListener('focus', windowFocused) setTimeout (-> document.querySelector('atom-workspace').focus()), 0 window.addEventListener('focus', windowFocused)
エディタを起動しているようにみえる。Atom
クラスがアプリ本体かな。
src/atom.coffee
Atom
クラスがある。theoristというライブラリのModel
クラスを継承している。GitHubページには"A reactive model toolkit for CoffeeScript"と書いてあるだけで、ドキュメントはない。
src/window-bootstarp.coffee
から呼ばれてるメソッドを中心にみていく。
loadOrCreate(mode)
エディタの状態をロードしてAtom
クラスのインスタンスを生成して返す。エディタの状態ファイルは、デフォルトだと~/.atom/storage/editor-*
となりそう。*
の部分は起動パスで決まるSHA1ハッシュだった。
このメソッドに渡す引数mode
はspec
モードとeditor
モードがあった。普通にアプリ起動する場合はeditor
モードを渡す。
constructor
# Call .loadOrCreate instead constructor: (@state) -> @emitter = new Emitter {@mode} = @state DeserializerManager = require './deserializer-manager' @deserializers = new DeserializerManager() @deserializeTimings = {}
Emitter
はevent-kitというライブラリのもので、多分EventEmitter
みたいなものだと思う。
DeserializerManager
(src/deserializer-manager.coffee
)はなんだろう?必要になったら追う。
initialize()
window.onerrorのハンドラ設定
いろいろエラー処理がある。
nodeのグローバルパスにパスを追加
# Add 'exports' to module search path. exportsPath = path.join(resourcePath, 'exports') require('module').globalPaths.push(exportsPath) # Still set NODE_PATH since tasks may need it. process.env.NODE_PATH = exportsPath
exports
ディレクトリをグローバルに追加してる。macだと/Applications/Atom.app/Contents/Resources/app/exports/
になる。
exports
にあるのは、atom.coffee
だけみたいだけど、なんのためのディレクトリなんだろう?exports/atom.coffee
はmodule.exports
でいろんなクラスがエクスポートされてる。reactなんかもあった。
各種クラスのインスタンス化
@config = new Config({configDirPath, resourcePath}) @keymaps = new KeymapManager({configDirPath, resourcePath}) @keymap = @keymaps # Deprecated @keymaps.subscribeToFileReadFailure() @tooltips = new TooltipManager @notifications = new NotificationManager @commands = new CommandRegistry @views = new ViewRegistry @packages = new PackageManager({devMode, configDirPath, resourcePath, safeMode}) @styles = new StyleManager document.head.appendChild(new StylesElement) @themes = new ThemeManager({packageManager: @packages, configDirPath, resourcePath, safeMode}) @contextMenu = new ContextMenuManager({resourcePath, devMode}) @menu = new MenuManager({resourcePath}) @clipboard = new Clipboard() @grammars = @deserializers.deserialize(@state.grammars ? @state.syntax) ? new GrammarRegistry() Object.defineProperty this, 'syntax', get: -> deprecate "The atom.syntax global is deprecated. Use atom.grammars instead." @grammars @subscribe @packages.onDidActivateInitialPackages => @watchThemes() Project = require './project' TextBuffer = require 'text-buffer' @deserializers.add(TextBuffer) TokenizedBuffer = require './tokenized-buffer' DisplayBuffer = require './display-buffer' TextEditor = require './text-editor' @windowEventHandler = new WindowEventHandler
クラスが多すぎるので、個々の実装は必要になったらみる。各インスタンスはそれぞれプロパティで保持しているので、Developer Toolsのコンソールでatom
を参照すればアクセスできる。
startEditorWindow()
ようやくエディタが起動しそう。
# Call this method when establishing a real application window. startEditorWindow: -> {resourcePath, safeMode} = @getLoadSettings() # エディタからatom、apmコマンドを使えるようにしてる? CommandInstaller = require './command-installer' CommandInstaller.installAtomCommand resourcePath, false, (error) -> console.warn error.message if error? CommandInstaller.installApmCommand resourcePath, false, (error) -> console.warn error.message if error? dimensions = @restoreWindowDimensions() # ウインドウの位置、幅、高さを取得 @loadConfig() # configのロード @keymaps.loadBundledKeymaps() # バンドルされてるキーマップのロード @themes.loadBaseStylesheets() # ベースのスタイルシートをロード @packages.loadPackages() # パッケージのロード @deserializeEditorWindow() # エディタウインドウをデシリアライズ @watchProjectPath() @packages.activate() # パッケージの有効化 @keymaps.loadUserKeymap() # ユーザー定義のキーマップのロード @requireUserInitScript() unless safeMode # ユーザー定義の起動スクリプトを実行 @menu.update() # メニューの更新 @subscribe @config.onDidChange 'core.autoHideMenuBar', ({newValue}) => @setAutoHideMenuBar(newValue) @setAutoHideMenuBar(true) if @config.get('core.autoHideMenuBar') # メニューバーの可視を設定 @openInitialEmptyEditorIfNecessary() # 必要なら空のエディタを開く maximize = dimensions?.maximized and process.platform isnt 'darwin' @displayWindow({maximize}) # ウインドウの表示
気になったところを追う。
configのロード
loadConfig: -> @config.setSchema null, {type: 'object', properties: _.clone(require('./config-schema'))} @config.load()
src/config-schema.coffee
に設定のスキーマがある。
エディタウインドウをデシリアライズ
deserializeEditorWindow: -> @deserializePackageStates() @deserializeProject() @deserializeWorkspaceView()
順にみる。まずはパッケージのデシリアライズから
deserializePackageStates: -> @packages.packageStates = @state.packageStates ? {} delete @state.packageStates
stateにあったらそこからパッケージの状態を復元してる。次はproject
deserializeProject: -> Project = require './project' startTime = Date.now() @project ?= @deserializers.deserialize(@state.project) ? new Project() @deserializeTimings.project = Date.now() - startTime
Project
クラスのインスタンスを生成してる。インスタンスは@state
からデシリアライズできなかったら、new
で生成してる。
deserializeWorkspaceView: -> Workspace = require './workspace' WorkspaceView = require './workspace-view' startTime = Date.now() @workspace = Workspace.deserialize(@state.workspace) ? new Workspace workspaceElement = @views.getView(@workspace) @__workspaceView = workspaceElement.__spacePenView @deserializeTimings.workspace = Date.now() - startTime @keymaps.defaultTarget = workspaceElement document.querySelector(@workspaceViewParentSelector).appendChild(workspaceElement) # workspaceのDOM要素追加
Project
クラスと同様にWorkspace
クラスのインスタンスを生成してる。ようやくconstructor
で出てきたDeserializerManager
クラスが何をやっているかわかってきた。
あと、ようやくDOMのAPIが出てきた。@workspaceViewParentSelector
はbodyだったので、body直下にworkspaceのDOM要素を追加している。workspaceの要素はViewRegistry
クラスのインスタンスである@views
を介して取得している。ViewRegistry
クラスは重要そうだったので、後で追ってみる。
workspaceのDOM要素をDeveloper Toolsで確認してみたら、<atom-workspace></atom-work-space>
となっていた。遂にWeb ComponentsのCustom Elementsが出てきた。ルートの要素がCustom ElementsということはがっつりWeb Componentsを導入しているみたいですね。
ユーザー定義の起動スクリプトを実行
getUserInitScriptPath: -> initScriptPath = fs.resolve(@getConfigDirPath(), 'init', ['js', 'coffee']) initScriptPath ? path.join(@getConfigDirPath(), 'init.coffee') requireUserInitScript: -> if userInitScriptPath = @getUserInitScriptPath() try require(userInitScriptPath) if fs.isFileSync(userInitScriptPath) catch error atom.notifications.addError "Failed to load `#{userInitScriptPath}`", detail: error.message dismissable: true
~/.atom/init.coffee
がユーザースクリプトみたい。デフォルトではコメントしか書いてない。
ウインドウの表示
# Schedule the window to be shown and focused on the next tick. # # This is done in a next tick to prevent a white flicker from occurring # if called synchronously. displayWindow: ({maximize}={}) -> setImmediate => @show() @focus() @setFullScreen(true) if @workspace.fullScreen @maximize() if maximize
とりあえず@show()
をみる。
# Public: Show the current window. show: -> ipc.send('call-window-method', 'show')
mainプロセスにメッセージを送ってる。main側のコード(src/browser/atom-application.coffee
)をみる。
ipc.on 'call-window-method', (event, method, args...) -> win = BrowserWindow.fromWebContents(event.sender) win[method](args...)
BrowserWindow
のインスタンスを取得して、BrowserWindow.show()
を実行してる。ここで、実際に画面にワークスペースが表示されるはず。
この辺でrendererプロセス側の初期化処理は終わりな気がする。
上ででてきた重要そうなクラスをみる。
src/deserializer-manager.cofee
DeserializerManager
クラスがある。ファイルにコメントがあったのでみてみる。
# Extended: Manages the deserializers used for serialized state # # An instance of this class is always available as the `atom.deserializers` # global. # # ## Examples # # ```coffee # class MyPackageView extends View # atom.deserializers.add(this) # # @deserialize: (state) -> # new MyPackageView(state) # # constructor: (@state) -> # # serialize: -> # @state # `
シリアライズされたstateに対して使うデシリアライザーを管理するとのこと。globalのatom.deserializers
として使えるようにする想定。例にあるようにあるデシリアライザークラスを管理したい場合は、そのクラスフィールドでatom.deserializers.add(this)
とする必要があるみたい。
add
メソッドをみる。
# Public: Register the given class(es) as deserializers. # # * `deserializers` One or more deserializers to register. A deserializer can # be any object with a `.name` property and a `.deserialize()` method. A # common approach is to register a *constructor* as the deserializer for its # instances by adding a `.deserialize()` class method. add: (deserializers...) -> @deserializers[deserializer.name] = deserializer for deserializer in deserializers new Disposable => delete @deserializers[deserializer.name] for deserializer in deserializers return
.name
プロパティと.deserialize()
メソッドをもったオブジェクトがデシリアライザーになりうる。管理から外したい場合は、このメソッドの返り値であるDisposable
においてdispose()
を呼べばいいらしい。
実際に使われてるところがみたいので、Workspace
クラス(src/workspace.coffee
)を追ってみる。ざっとみたところ.name
プロパティがなかったけど、これは継承元であるtheoristのModel
クラスがもっていた。.deserialize()
メソッドの方はserializableというライブラリをミックスインすることにより解決していた。以下はWorkspace
クラスの冒頭。
module.exports = class Workspace extends Model atom.deserializers.add(this) # デシリアライザーをatomの管理下に加える Serializable.includeInto(this) # Serializableをミックスイン
src/atom.coffee
で実際にWorkspace
がデシリアライズされるとこ。
# deserializeWorkspaceViewメソッドの中 @workspace = Workspace.deserialize(@state.workspace) ? new Workspace
シリアライズされるとこ。
# unloadEditorWindowメソッドの中 @state.workspace = @workspace.serialize()
Workspace
のようにアプリケーションでstateを管理するクラスはほぼデシリアライザーでありシリアライザブルになっている感じがする。エディタくらいになると管理すべきstateが多いから、さくっとシリアライズして保存しておくのが楽なんだと思う。
あと、theoristもserializableもatomのライブラリなんだけど、細かくライブラリに切り出してるなという印象を受ける。atomにロックインしているわけではなく、単体でも使えそうだからすごい。
src/view-registry.coffee
ViewRegistry
クラスがある。コメントからみてみる。ここで気づいたんだけど、このコメントからAPI Referenceが生成されてた。
# Essential: `ViewRegistry` handles the association between model and view # types in Atom. We call this association a View Provider. As in, for a given # model, this class can provide a view via {::getView}, as long as the # model/view association was registered via {::addViewProvider} # # If you're adding your own kind of pane item, a good strategy for all but the # simplest items is to separate the model and the view. The model handles # application logic and is the primary point of API interaction. The view # just handles presentation. # # View providers inform the workspace how your model objects should be # presented in the DOM. A view provider must always return a DOM node, which # makes [HTML 5 custom elements](http://www.html5rocks.com/en/tutorials/webcomponents/customelements/) # an ideal tool for implementing views in Atom. # # You can access the `ViewRegistry` object via `atom.views`.
ViewRegistry
はmodelとviewの間の接続を担当する。その接続のことをview providerと呼ぶ。modelに対しては::getView
を通してviewを提供する。model/viewの接続は::addViewProvider
で登録する。
定番だけど、modelとviewの分離はいい戦略であると書いてある。modelはアプリケーションロジックを担当する。そして、API interactionのプライマリーポイントでもある。viewはプレゼンテーションを担当する。
view providerは、workspaceにmodelがどのようにDOMをプレゼンテーションすべきかを知らせる。view providerは常にDOMノードを返すべきである。DOMノードとして、HTML 5 custom elementsを使ってる。
ざっくりとだけどAtomのGUI部分の設計について説明しているようにみえる。ソースコードと照らしあわせてみたけど、実装は単純だった。ちゃんとviewインスタンスの管理もやってた。
まとめ
次は、projectとworkspaceの実装を追っていく。
Atomコードリーディング Part 2
エントリーポイント以降
前回はAtomのビルドからアプリケーションのエントリーポイントまでみた。今回はエントリーポイントからhtmlのロードまでみた。
src/browser/atom-application.coffee
EventEmitter
を継承したAtomApplication
クラスがある。Atomアプリケーションのシングルトンクラス。エントリーポイントであり、アプリの状態を保持する。
open()
src/browser/main.coffee
から呼ばれるAtomApplication
クラスのスタティックメソッド。
# Public: The entry point into the Atom application. @open: (options) -> options.socketPath ?= DefaultSocketPath createAtomApplication = -> new AtomApplication(options) # FIXME: Sometimes when socketPath doesn't exist, net.connect would strangely # take a few seconds to trigger 'error' event, it could be a bug of node # or atom-shell, before it's fixed we check the existence of socketPath to # speedup startup. if (process.platform isnt 'win32' and not fs.existsSync options.socketPath) or options.test createAtomApplication() return # ローカルドメインソケットに接続 client = net.connect {path: options.socketPath}, -> client.write JSON.stringify(options), -> client.end() app.terminate() # なぜ終了? client.on 'error', createAtomApplication
windowsでなくて、ローカルドメインソケット(macならatom-#{process.env.USER}.sock
)がなかったら、AtomApplication
自身のインスタンスを生成して終わり。
それ以外の場合は、AtomApplication
のインスタンス化はしないで、代わりにnet
モジュールでローカルドメインソケットにつないで、コールバックでデータ送信してる。データ送信終わったらapp.terminate()
が呼ばれるようになってるんだけど、なんだろこれ?
constructor
処理を順にみる。
自身をグローバルに追加
global.atomApplication = this
AtomApplication
はいろんなとこで参照するらしい。
3つのクラスのインスタンス化
以下をインスタンス化してた。
AutoUpdateManager
(src/browser/auto-update-manager.coffee
)ApplicationMenu
(src/browser/auto-update-manager.coffee
)AtomProtocolHandler
(src/browser/atom-protocol-handler.coffee
)
AutoUpdateManager
はAtomの自動更新を管理するクラス。ApplicationMenu
はグローバルメニューを管理するクラス。AtomProtocolHandler
はatomのURLスキーム(atom://
)を扱うクラス。必要になったら中身を追えばよさそう。
atomコマンドの複数回実行対応
@listenForArgumentsFromNewProcess()
メソッドを追って見る。
# Creates server to listen for additional atom application launches. # # You can run the atom command multiple times, but after the first launch # the other launches will just pass their information to this server and then # close immediately. listenForArgumentsFromNewProcess: -> @deleteSocketFile() server = net.createServer (connection) => connection.on 'data', (data) => @openWithOptions(JSON.parse(data)) server.listen @socketPath server.on 'error', (error) -> console.error 'Application server failed', error
コメントによるとatomコマンドは複数回実行できるようになってるらしい。2回目以降の起動は、初回の起動で生成したサーバーに情報送るだけして、クローズ(app.teminate()
?)されるらしい。open
メソッド中でローカルドメインソケットにつなごうとしてたのはこのためみたい。2回目以降はすでに起動しているサーバーにウィンドウを開いてもらっている。
chromiumのコマンドラインにスイッチを追加
app.commandLine.appendSwitch 'js-flags', '--harmony'
chromiumのコマンドラインにスイッチを追加は、app
モジュールのAPIでやってる。多分、rendererプロセスの方でES6使えるようにしてるんだと思う。
各種イベントハンドラの設定
@handleEvents()
大きく分けて3種類あった。
AtomApplication
インスタンスで監視するアプリ固有のイベント。イベント名はapplication:*
のような感じapp
モジュールのライフサイクル系のイベントipc
モジュールのイベント
ipc
モジュールはmainプロセスとrendererプロセス間の通信を行うためのモジュール。ここで、前回とばした2つのプロセスの違いを簡単にまとめておく(ほぼドキュメントの訳)。
mainプロセスとrendererプロセスの違い
mainプロセスはBrowserWindow
インスタンスを生成することにより、ウェブページを生成する。BrowserWindow
インスタンスは自身のrendererプロセスでウェブページを実行する。BrowserWindow
インスタンスが破棄されたら、対応するrendererプロセスも終了する。
あとmainプロセスはネイティブのAPIを呼べるみたい。rendererプロセスでは無理。でもipc
を使えばrendererプロセスを起点にmainプロセスを通してネイティブのAPIを呼べる。
新しいウィンドウを開く
@openWithOptions(options)
パスで開くか、URLスキームで開くかなどを判定してる。パスで開くとこはopenPaths
メソッド見ればよさそう。プロセスIDのを管理していて、同じパスで開いたらウィンドウが使いまわされる実装になってる。ちなみに-n or --new-window
オプションを使って起動すると同じパスでも新しいウィンドウで開ける。
とりあえず単純に新しいウィンドウを開く場合を追ってみる。
bootstrapScript ?= require.resolve('../window-bootstrap') resourcePath ?= @resourcePath openedWindow = new AtomWindow({locationsToOpen, bootstrapScript, resourcePath, devMode, safeMode, windowDimensions})
まずsrc/window-bootstrap.coffee
のパスをrequire.resolve()
で取得してる。(ここではじめてsrc/browser
以下以外のファイルがでてきた。)その後、取得したパスや各種設定情報を引数としてAtomWindow
のコンストラクタに渡してAtomWindow
クラス(src/browser/atom-window.coffee
)をインスタンス化をしてる。src/window-bootstrap.coffee
は名前からして、rendererプロセスの最初の方で実行するスクリプトなんだと思う。
AtomWindow
はコンストラクタでBrowserWindow
をインスタンス化してる。これが上で書いたmainプロセスにおけるBrowserWindow
インスタンスの生成にあたる。
src/browser/atom-window.coffee
EventEmitter
を継承したAtomWindow
クラスがある。BrowserWindow
クラスのラッパークラスみたいな立ち位置。
constructor
適当に処理を抜粋してみる。
BrowserWindow
インスタンスの生成
@browserWindow = new BrowserWindow options
インスタンスはbrowserWindow
プロパティで保持
AtomApplicationにウィンドウを追加
global.atomApplication.addWindow(this)
AtomApplication.addWindow(window)
でアプリケーションに自分自身をウィンドウとして追加してる。
AtomApplication.addWindow(window)
の中身。
# Public: Adds the {AtomWindow} to the global window list. addWindow: (window) -> @windows.push window @applicationMenu?.addWindow(window.browserWindow) window.once 'window:loaded', => @autoUpdateManager.emitUpdateAvailableEvent(window) unless window.isSpec focusHandler = => @lastFocusedWindow = window window.browserWindow.on 'focus', focusHandler window.browserWindow.once 'closed', => @lastFocusedWindow = null if window is @lastFocusedWindow window.browserWindow.removeListener 'focus', focusHandler
AtomWindow
インスタンスのwindow:loded
イベントのハンドラを設定してる。ハンドラの中でAutoUpdateManager
がでてきた。
AutoUpdateManager
に寄り道。
emitUpdateAvailableEvent: (windows...) -> return unless @releaseVersion? for atomWindow in windows atomWindow.sendMessage('update-available', {@releaseVersion}) return
Atomの更新が利用できる場合に、AtomWindow.sendMessage
でウインドウにupdate-available
メッセージを送ってる。
再度、AtomWindow
に戻る。
sendMessage: (message, detail) -> @browserWindow.webContents.send 'message', message, detail
BrowserWindow.webContents
を介して、rendererプロセスにメッセージを送ってる。renderer側のどこかでこのメッセージを処理してると思う。
各種イベントハンドラの設定
@handleEvents()
閉じたときとか反応しなくなったときなどのイベントを設定してた。
ロード設定をセットする
@setLoadSettings(loadSettings)
loadSettings
というローカル変数にはいろいろつめてた。
メソッドの中を追ってみる。
setLoadSettings: (loadSettingsObj) -> # Ignore the windowState when passing loadSettings via URL, since it could # be quite large. loadSettings = _.clone(loadSettingsObj) delete loadSettings['windowState'] @browserWindow.loadUrl url.format protocol: 'file' pathname: "#{@resourcePath}/static/index.html" slashes: true hash: encodeURIComponent(JSON.stringify(loadSettings))
さっそくBrowserWindow.loadUrl(url)
でhtmlをロードしてる。fileプロトコルでstatic/index.html
(macなら/Applications/Atom.app/Contents/Resources/app/static/index.html
)を指定してる。loadSettings
はJSONにしてURLエンコードしてURLのハッシュに入れるみたい。
コメントには、アプリの状態(windowState
)は巨大だから削ってると書いてある。確認のためAtomのDeveloper Tools(⌥⌘I)でURLハッシュをデコードして確認してみる。
decodeURIComponent(location.hash.substr(1)) "{"locationsToOpen":[{"pathToOpen":"/Users/sangotaro/work/atom"}],"bootstrapScript":"/Applications/Atom.app/Contents/Resources/app/src/window-bootstrap.js","resourcePath":"/Applications/Atom.app/Contents/Resources/app","devMode":false,"safeMode":false,"appVersion":"0.188.0","initialPaths":["/Users/sangotaro/work/atom"]}"
htmlのロードまできたので、mainプロセス側の初期化処理はだいたい終わりだと思う。初期化処理の最後にロード時間をコンソールに出力してた。
# src/browser/main.coffeeのstart関数の最後 console.log("App load time: #{Date.now() - global.shellStartTime}ms") unless args.test
mainプロセスでのコンソール出力はatom -f
で起動すると確認できる。mainプロセスを確認したいときに便利そう。
$ atom -f 2015-04-08 01:02:46.684 Atom[55711:507] App load time: 226ms