サンゴラボ

4年目ソシャゲエンジニア

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の仕様もちょこちょこみてた。

用語

用語の理解があいまいな気がするけど、この記事内では統一しておきたい…

リソースサーバ

GoogleFacebookなどが提供しているAPIサーバ

認可サーバ(Authorization Server)

GoogleFacebookなどが提供している認可用のサーバ

リソースオーナー(Resource Owner)

GoogleFacebookにリソースおいてるユーザ。API認可を必要とするアプリのユーザ。

クライアント(Client)

OAuthクライアント。Authorization Code Flowならサーバで動くアプリ、Implicit Flowならブラウザで動くアプリという認識。

ユーザエージェント (User-Agent)

多分Authorization Code FlowとImplicit Flowならブラウザってことになるのかな。

OAuthについて3行

  • OAuthはAPI認可の技術である。
  • OAuthを使う目的は、OAuthアクセストークンを取得して、ユーザやアプリのためにAPIリクエストを行うこと。
  • アクセストークンをいかにして取得するかが重要。

OAuth 2.0 Authorization Code Flow

Authorization Code Flowだけではないが、OAuthエンドポイントとAPI間はHTTPS通信を行う。

以下でフローのステップを説明している。この説明は、リソースオーナーがAPIプロバイダにサインインしていることを前提としてる。

1. リソースオーナーに実行内容を知らせて、認可を要求

  1. OAuth認可ページを表示(またはウインドウを開く)
  2. リソースオーナーが認可
  3. (ユーザエージェントが)OAuth認可エンドポイント(認可サーバ)にリクエスト

リクエストには以下のようなクエリパラメータが必要

  • client_id: クライアントID
  • redirect_uri: 認可後、リダイレクトされるクライアントのURL
  • scope: アクセス要求するデータの種類
  • response_type: レスポンスの種類。codeを指定。
  • state: ローカルステート。CSRF対策用の一意の値。

2. 認可コードをアクセストークンに交換

  1. 認可サーバがユーザエージェントをredirect_uriにリダイレクト
  2. クライアントが認可サーバにアクセストークン取得をPOSTリクエスト
  3. レスポンスでアクセストークンが返ってくる

認可サーバによってリダイレクトされる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呼び出し

  1. クライアントがリソースサーバのAPIにリクエスト
  2. レスポンスでクライアントがリソースを取得する

リクエスト時に、HTTPのAuthorizationヘッダでアクセストークンを送付する。

OAuth 2.0 Implicit Flow

こっちはメインのウインドウで画面遷移をせずに認可を得られるのが特徴だと思った。

以下でフローのステップを説明している。

1. リソースオーナーに実行内容を知らせて、認可を要求

  1. OAuth認可用ウインドウを開く(iframeでもいいかも)
  2. リソースオーナーが認可
  3. (ユーザエージェントが)OAuth認可エンドポイント(認可サーバ)にリクエスト

認可がクライアントで簡潔するので、子ウインドウやiframeを使うのが特徴。

リクエスト時のクエリパラメータは以下である。Authorization Code Flowとはresponse_typeの指定が異なる。

  • client_id: クライアントID
  • redirect_uri: 認可後、リダイレクトされるクライアントのURL
  • scope: アクセス要求するデータの種類
  • response_type: レスポンスの種類。tokenを指定。
  • state: ローカルステート。CSRF対策用の一意の値。

2. URLからアクセストークンを解析

  1. 認可サーバがユーザエージェント(認可用ウインドウ)をredirect_uriにリダイレクト
  2. クライアントがハッシュフラグメントのアクセストークンを解析

Implicit Flowでは認可サーバによるリダイレクトのURLに、ハッシュフラグメントとしてaccess_token、token_type、expires_in、stateなどが付加されている。

URLから解析したアクセストークンは親ウインドウに渡される。親ウインドウへの送付には、グローバル関数やwindow.postMessageを使う。親ウインドウでCSRF対策を行うべき。

3. API呼び出し

  1. クライアントがリソースサーバのAPIにリクエスト
  2. レスポンスでクライアントがリソースを取得する

リクエスト時に、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 ClientImplicit Client、その2つをあわせたHybrid Flowがある

OpenID Connect Implicit Client

OAuth 2.0 Implicit Flowを利用した認証。Implicit Flowを利用しているだけあって、それと似たようなステップになってる。

1. ユーザ認可の取得

  1. 認証用ウインドウを開く(iframeでもいいかも)
  2. (ユーザエージェントが)OAuth認可エンドポイント(認可サーバ)に認証リクエスト
  3. 認可サーバーがエンドユーザを認証
  4. エンドユーザが承認/認可

認証リクエストは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トークンとアクセストークンを解析

  1. 認可サーバがユーザエージェント(認証用ウインドウ)をredirect_uriにリダイレクト
  2. クライアントがハッシュフラグメントのアクセストークンを解析

こちらもOAuth 2.0 Implicit Flow同様に、リクエストの結果がURLのハッシュ部分に返される。以下のような内容になる。

  • access_token
  • token_type
  • id_token
  • state
  • expires_in

3. IDトークンの検証

  1. 解析したIDトークンをCheck ID Endpointに送って検証
  2. 検証をパスしたら認証とする

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プロセスからもらった設定が入ってる。

ipcwindow-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ハッシュだった。

このメソッドに渡す引数modespecモードとeditorモードがあった。普通にアプリ起動する場合はeditorモードを渡す。

constructor

# Call .loadOrCreate instead
constructor: (@state) ->
  @emitter = new Emitter
  {@mode} = @state
  DeserializerManager = require './deserializer-manager'
  @deserializers = new DeserializerManager()
  @deserializeTimings = {}

Emitterevent-kitというライブラリのもので、多分EventEmitterみたいなものだと思う。

DeserializerManagersrc/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.coffeemodule.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プロパティがなかったけど、これは継承元であるtheoristModelクラスがもっていた。.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を使ってる。

ざっくりとだけどAtomGUI部分の設計について説明しているようにみえる。ソースコードと照らしあわせてみたけど、実装は単純だった。ちゃんと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)

AutoUpdateManagerAtomの自動更新を管理するクラス。ApplicationMenuはグローバルメニューを管理するクラス。AtomProtocolHandleratomの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)を指定してる。loadSettingsJSONにして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