サンゴラボ

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

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の実装を追っていく。