TECH PLAY

プログラミング

イベント

マガジン

技術ブログ

こんにちは @kyo です! 2026年2月21日に開催された Go Conference mini in Sendai 2026 にて、「GoとWasmでつくる軽量ブラウザUI」というタイトルで登壇させていただきました。この記事では、発表中にいただいたフィードバックについて深掘りをして得られた知見をご共有できたらと思います。 フィードバック: 「 (*js.Value).Call は遅いので、 bind したうえで Invoke するといいですよ」 from Hajime Hoshi さん、Go製ゲームエンジン Ebitengine の作者 発表スライド speakerdeck.com 背景 Go の syscall/js パッケージでは、JS のメソッドを呼び出す方法が2つあります。 方法 Go コード 特徴 Call document.Call("getElementById", "myDiv") シンプルだが毎回オーバーヘッドあり bind + Invoke getElementById.Invoke("myDiv") 初期化が必要だが高速 Call が遅い理由 前提知識: Go Wasm の仕組み Go で書いた Wasm コードがブラウザの JS を呼び出すとき、直接呼べるわけではありません。 間に Wasm メモリ と wasm_exec.js (Go 公式提供の橋渡しスクリプト)を挟んでやりとりします。 Wasm メモリ(Linear Memory)とは? Wasm メモリは WebAssembly の仕様で定義された WebAssembly.Memory オブジェクトで、 実体は Go(Wasm)と JavaScript の 両方からアクセスできる巨大なバイト配列 ( ArrayBuffer )です。 「リニアメモリ(Linear Memory)」とも呼ばれます。 developer.mozilla.org wasmbyexample.dev 普通、Go と JS はお互いの変数を直接見ることができませんが、 この共有のバイト配列を「伝言板」のように使うことで、データをやりとりできます。 例: document.Call("getElementById", "myDiv") の場合 Go 側が "getElementById" という文字列をバイト列に変換して Wasm メモリに書き込む JS 側( wasm_exec.js )が Wasm メモリからそのバイト列を読み出す TextDecoder で JS の文字列に変換する(= loadString() ) その文字列を使って document["getElementById"] を探す(= Reflect.get() ) 見つけた関数を実行する Invoke が速い理由は、このステップ 1〜4 を丸ごとスキップできるからです。 事前に関数への参照を取得しておけば、Wasm メモリを経由した文字列のやりとりが不要になります。 Call の処理の流れ Go 側で document.Call("getElementById", "myDiv") を呼ぶと、 wasm_exec.js の以下のコードが実行されます: // wasm_exec.js "syscall/js.valueCall" : ( sp ) => { sp >>>= 0 ; try { const v = loadValue ( sp + 8 ) ; // ① オブジェクトを取得(例: document) const m = Reflect . get ( v , loadString ( sp + 16 )) ; // ② ここが遅い(後述) const args = loadSliceOfValues ( sp + 32 ) ; // ③ 引数を取得(例: "myDiv") const result = Reflect . apply ( m , v , args ) ; // ④ 関数を実行 sp = this. _inst . exports . getsp () >>> 0 ; storeValue ( sp + 56 , result ) ; // ⑤ 結果をメモリに書き戻す this. mem . setUint8 ( sp + 64 , 1 ) ; // ⑥ 成功フラグ } catch ( err ) { // エラー処理... } } , ② が遅い理由には二つの原因があります const m = Reflect . get ( v , loadString ( sp + 16 )) ; // ^^^^^^^^^^^^^^^^^^ ← (A) 文字列デコード // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^← (B) プロパティ検索 (A) loadString() — 文字列デコード const loadString = ( addr ) => { const saddr = getInt64 ( addr + 0 ) ; // Wasmメモリ上の文字列の開始位置 const len = getInt64 ( addr + 8 ) ; // 文字列の長さ(バイト数) return decoder . decode ( // TextDecoder で バイト列 → JS文字列に変換 new DataView ( this. _inst . exports . mem . buffer , saddr , len ) , ) ; } ; Go が Wasm メモリに書き込んだバイト列を、 TextDecoder を使って JavaScript の文字列 "getElementById" に変換しています。 この処理では毎回 new DataView の生成と decoder.decode() が走っています。 (B) Reflect.get() — プロパティ検索 補足: プロパティとプロパティ検索とは? JavaScript のオブジェクトは、名前(キー)と値のペアの集まり です。 この「名前と値のペア」1つ1つを プロパティ と呼びます。 // document オブジェクトのイメージ(実際はもっと多い) document = { "getElementById" : function ( ... ) { ... } , // ← プロパティ "createElement" : function ( ... ) { ... } , // ← プロパティ "querySelector" : function ( ... ) { ... } , // ← プロパティ "title" : "My Page" , // ← プロパティ // ... 他にも数百のプロパティがある } ; プロパティ検索 とは、この中から名前を指定して値を探す処理です。 Go でいえば `map[string]any` から `map["getElementById"]` でキーを探すのに近いイメージです。 // プロパティ検索の例(どれも同じ意味) document .getElementById ; // ドット記法 document [ "getElementById" ] ; // ブラケット記法 Reflect . get ( document , "getElementById" ) ; // Reflect API(wasm_exec.js が使う方法) Reflect . get ( v , "getElementById" ) ; // これは実質的に v["getElementById"] と同じ // = document オブジェクトから "getElementById" という名前の関数を探す JavaScript のオブジェクトからプロパティ名で関数を検索します。 ここの処理でも毎回この探索処理が走ります。 Invoke の処理の流れ 一方、 getElementById.Invoke("myDiv") を呼ぶと // wasm_exec.js "syscall/js.valueInvoke" : ( sp ) => { sp >>>= 0 ; try { const v = loadValue ( sp + 8 ) ; // ① 関数そのものを取得(文字列ではない) const args = loadSliceOfValues ( sp + 16 ) ; // ② 引数を取得 const result = Reflect . apply ( v , undefined , args ) ; // ③ 関数を直接実行 sp = this. _inst . exports . getsp () >>> 0 ; storeValue ( sp + 40 , result ) ; // ④ 結果をメモリに書き戻す this. mem . setUint8 ( sp + 48 , 1 ) ; // ⑤ 成功フラグ } catch ( err ) { // エラー処理... } } , Call との違い loadString() がない → 文字列デコードが不要 Reflect.get() がない → プロパティ検索が不要 v はすでに関数への参照なので、 Reflect.apply() で直接呼ぶだけ 処理の違いまとめ Call の処理: Go → [メソッド名をメモリに書く] → JS: loadString() → Reflect.get() → Reflect.apply() ~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~ ~~~~~~~~~~~~~ 毎回発生するオーバーヘッド 文字列デコード プロパティ検索 Invoke の処理: Go → JS: Reflect.apply() 図解 1. Call パターン(毎回のオーバーヘッド) 2. bind + Invoke パターン(初回のみオーバーヘッド) 3. 処理ステップの比較 4. bind が必要な理由 JS ではメソッドをオブジェクトから切り離すと this コンテキストが失われます。 bind で this を固定しないと Invoke 時にエラーになります。 コード例 遅いパターン(毎回 Call ) document := js.Global().Get( "document" ) for i := 0 ; i < 1000 ; i++ { // 毎回: 文字列書き込み → デコード → プロパティ検索 → 実行 element := document.Call( "getElementById" , "myElement" ) element.Call( "setAttribute" , "data-index" , i) } 速いパターン( bind + Invoke ) document := js.Global().Get( "document" ) // 初期化: bind で this を固定 getElementById := document.Get( "getElementById" ).Call( "bind" , document) for i := 0 ; i < 1000 ; i++ { // 毎回: 関数実行のみ(文字列処理・プロパティ検索なし) element := getElementById.Invoke( "myElement" ) // ... } 実用的なパターン: よく使うメソッドをまとめて事前バインド var ( document = js.Global().Get( "document" ) getElementById = document.Get( "getElementById" ).Call( "bind" , document) createElement = document.Get( "createElement" ).Call( "bind" , document) querySelector = document.Get( "querySelector" ).Call( "bind" , document) consoleLog = js.Global().Get( "console" ).Get( "log" ).Call( "bind" , js.Global().Get( "console" )) ) func getElement(id string ) js.Value { return getElementById.Invoke(id) } func newElement(tag string ) js.Value { return createElement.Invoke(tag) } オーバーヘッド比較表 処理 Call bind + Invoke 文字列の Wasm メモリ書き込み 毎回 初回のみ TextDecoder によるデコード 毎回 初回のみ Reflect.get (プロパティ検索) 毎回 初回のみ Reflect.apply (関数呼び出し) 毎回 毎回 makeArgSlices + storeArgs 毎回 毎回 ベンチマーク結果(10,000回呼び出し) 各メソッドを計測した実測結果 DOM操作(実際のJS API) JS API自体の実行コストが含まれるため、相対的な差は小さい。 // Call パターン document.Call( "getElementById" , "myElement" ) // bind+Invoke パターン getElementById := document.Get( "getElementById" ).Call( "bind" , document) getElementById.Invoke( "myElement" ) 対象メソッド Call (ms) bind+Invoke (ms) 差分 速度比 document.getElementById 48.7 46.6 +2.1 ms 1.05倍 console.log 68.3 59.3 +9.0 ms 1.15倍 element.setAttribute 26.8 25.8 +1.0 ms 1.04倍 DOM操作自体のコストが大きいため、Call と bind+Invoke の差は 3〜15% 程度に留まる。 純粋なオーバーヘッド検証 JS側の処理コストを排除し、 Call 固有のオーバーヘッドを可視化。 空の関数(何もしない関数) JS側に何もしない関数を用意し、呼び出しオーバーヘッドだけを測定。 // Call パターン: 毎回「文字列デコード → プロパティ検索 → 関数実行」 noopObj.Call( "noop" ) // bind+Invoke パターン: 事前バインド済みなので「関数実行」のみ noop := noopObj.Get( "noop" ).Call( "bind" , noopObj) noop.Invoke() 対象 Call (ms) bind+Invoke (ms) 差分 速度比 noop 1.90 0.40 +1.50 ms 4.76倍 JS側の処理コストがないため、 Call 固有のオーバーヘッド(文字列デコード + プロパティ検索)が約4〜5倍の差としてはっきり現れる。 メソッド名の長さによる影響 Call は毎回メソッド名を文字列デコードするため、名前が長いほどコストが増えるか検証。 // 短いメソッド名(1文字) obj.Call( "a" ) // 長いメソッド名(30文字) obj.Call( "abcdefghijklmnopqrstuvwxyz1234" ) // bind+Invoke はどちらも同じ(事前バインド済み) fn := obj.Get( "a" ).Call( "bind" , obj) fn.Invoke() 対象 Call (ms) bind+Invoke (ms) 差分 速度比 メソッド名 "a"(1文字) 1.80 0.50 +1.30 ms 3.61倍 メソッド名 "abcdefghij...1234"(30文字) 2.10 0.60 +1.50 ms 3.50倍 メソッド名の長さはほぼ影響しない。 TextDecoder のコストは小さく、Go↔JS間の境界越え自体( valueCall のスタック操作 + Reflect.get )の方がはるかに大きい。(メソッドをたくさん増やしたらもっと差が出るかも) いつ使い分けるか シナリオ 推奨 理由 高頻度呼び出し(60fps 描画、大量 DOM 操作) bind + Invoke オーバーヘッド削減の効果が大きい 低頻度呼び出し(ボタンクリック等) Call でOK 可読性を優先、パフォーマンス差は体感できない 同じメソッドをループで繰り返し呼ぶ bind + Invoke 最もメリットが出るケース まとめ Call は毎回「文字列の Wasm メモリ書き込み → TextDecoder によるデコード → Reflect.get によるプロパティ検索」という3つのオーバーヘッドが発生する bind + Invoke は事前に関数参照を取得・固定しておくことで、これらのオーバーヘッドをすべてスキップし、 Reflect.apply で直接関数を実行できる 純粋なオーバーヘッド比較では約4〜5倍の差があり、高頻度呼び出し(描画ループや大量DOM操作)では効果が大きい 一方、DOM操作自体のコストが大きい場面では差は数%程度に留まるため、低頻度の呼び出しでは Call のシンプルさを優先して良さそう よく使うメソッドを var でまとめて事前バインドしておくのが実用的なパターン 最後に Go Conference mini in Sendai で Hajime Hoshi さんからいただいた「 Call は遅いので bind + Invoke がいいですよ」というフィードバックは、最初は「そういうテクニックがあるんだな」程度の理解でした。しかし実際に wasm_exec.js のソースコードを読んでみると、 Call が遅い理由は単なる「関数呼び出しの方法の違い」ではなく、Go と JavaScript という2つの異なるランタイムが Wasm メモリという共有バイト配列を介してやりとりする仕組みそのものに起因していることがわかりました。 普段 Go を書いているだけでは意識しない「文字列がバイト列として Wasm メモリに書き込まれ、JS 側で TextDecoder によってデコードされる」という一連の流れを知ったことで、Go Wasm が裏側でどれだけの処理をしているのかを実感できました。と同時に、 wasm_exec.js がたった1つのファイルで Go と JS の橋渡しをすべて担っていることに、改めてすごさを感じました。 カンファレンスでのたった一言のフィードバックが、ここまで深い学びにつながるとは思っていませんでした。発表して、フィードバックをもらって、それを深掘りする——このサイクルの価値を改めて実感しています。 参考 golang/go#32591 — syscall/js: performance considerations golang/go#39740 — syscall/js: increase performance of Call, Invoke, and New golang/go#44006 — syscall/js: remove Wrapper type to avoid extreme allocations Go syscall/js ソースコード Go wasm_exec.js ソースコード
はじめに my route開発部のAndroidエンジニア、Romie( @Romie_1112 )です。 my routeのAndroidチームではUIの実装をxmlからJetpack Compose(以下Compose)へと粛々と切り替えております。 現在は地域別の特集コンテンツを並べた画面をCompose化しています。 希望の順番で並べ替えることもできます。 以下の順番で初回表示を行います。 1. 画面遷移する 2. 希望の順番を初期値:おすすめ順に設定する 3. リクエストの時に希望の順番をAPIに渡す 4. データを取得する 5. 取得したデータの一覧を表示する 実装する中で 4. データを取得する 処理について迷ったので、今回はそのお話をしたいと思います。 初期化の実装方法 これまでの実装は、希望の順番を渡してAPIを叩いた結果を LiveData で通知し、 observe で監視して値を取得してから画面を表示していました。 そのため、値を取得する前の初期化処理は実装されていませんでした。 しかし今回Compose化に伴いUiStateの値が変わればリアクティブプログラミングで即Fragmentに反映する StateFlow に変えることにし、 LaunchedEffect(Unit) 内で初期化するよう実装しました。 ここで初期化の実装にあたり、私は次に挙げる2つの方法で迷いました。 1. initブロックで初期化する場合 intiブロックで初期化する場合、以下のような実装になります。 data class FeatureSummaryListUiState( val featureSummaryList: List<一覧のアイテム> = emptyList(), ) private val _sortType = MutableStateFlow(おすすめ順) private val _uiState = MutableStateFlow(FeatureSummaryListUiState()) val uiState = _uiState.asStateFlow() init { viewModelScope.launch { _sortType.collectLatest { sortType -> val summary = (APIを叩いてデータを取得) _uiState.update { it.copy( featureSummaryList = (設定したい初期値), ) } } } } setContent { MyRouteTheme { val uiState = viewModel.uiState.collectAsStateWithLifecycle().value FeatureSummaryListScreen( uiState = uiState, ) } } initブロックについての記載を 公式リファレンス [^1]から見てみましょう。 The primary constructor initializes the class and sets its properties. In most cases, you can handle this with simple code. If you need to perform more complex operations during instance creation, place that logic in initializer blocks inside the class body. These blocks run when the primary constructor executes. Declare initializer blocks with the init keyword followed by curly braces {}. Write within the curly braces any code that you want to run during initialization: initブロックは引用にもあります通り、インスタンスが形成された時に実行されるものになります。 インスタンスが形成された時に一度だけ呼ばれますので、初期化の処理を書くのにぴったりです。 ただし、initブロックはインスタンス形成時に呼ばれるという性質上、単体テストで初期化がちゃんとできているか見ることが厳しく、また単体テストの記載に慣れていないとinitブロックを考慮したテストを書くのが大変です。 2. LaunchedEffect(Unit)内で初期化する場合 では、FragmentからViewModel内の初期化処理をコールした場合はどうでしょうか。 最初に一度だけ呼ぶ処理だとコードを読む人に明示するため LaunchedEffect(Unit) の中に書くことをお勧めします。 data class FeatureSummaryListUiState( val featureSummaryList: List<一覧のアイテム> = emptyList(), ) private val _sortType = MutableStateFlow(おすすめ順) private val _uiState = MutableStateFlow(FeatureSummaryListUiState()) val uiState = _uiState.asStateFlow() fun initFeatureSummaryListUiState() { // initがfunになっています viewModelScope.launch { _sortType.collectLatest { sortType -> val summary = (APIを叩いてデータを取得) _uiState.update { it.copy( featureSummaryList = (設定したい初期値), ) } } } } setContent { MyRouteTheme { val uiState = viewModel.uiState.collectAsStateWithLifecycle().value LaunchedEffect(Unit) { viewModel.initFeatureSummaryListUiState() // ここが違う! } FeatureSummaryListScreen( uiState = uiState, ) } } Composeにおける副作用 [^2]に副作用( LaunchedEffect )の説明がございます。 副作用とは、コンポーズ可能な関数の範囲外で発生するアプリの状態の変化を指します。 コンポーザブルのライフサイクルとプロパティ(予測できない再コンポジション、異なる順序でのコンポーザブルの再コンポジション、破棄可能な再コンポジションなど)により、コンポーザブルは副作用がないようにするのが理想的です。 ただし、スナックバーを表示するなどの1回限りのイベントをトリガーする場合や、特定の状態で別の画面に移動する場合などに、副作用が必要になることがあります。 これらのアクションは、コンポーザブルのライフサイクルを認識している制御された環境から呼び出す必要があります。 そして、 こちらの章 [^3]により具体的な記載がございます。 コールサイトのライフサイクルと一致する作用を作成するには、Unitやtrueのような決して変化しない定数をパラメータとして渡します。 この実装には次の一般的なメリットがあります。 再利用性:どの箇所からでも呼び出せる テスト容易性:独立した関数で実装しているため単体テストがやりやすい また、プロジェクト内のメリットとして以下が挙げられます。 既存のコードとの整合性:他の箇所を確認したところCompose化した画面の初期化は LaunchedEffect(Unit) 内で行っていることが多く整合性が取りやすい ただし、デメリットもあります。 UDFの法則に反する:ViewModel→Fragmentという単方向でデータが流れる[^4]べきなのにFragment→ViewModelとなってしまう[^5] 依存度が高まり疎結合が崩れる:FragmentでViewModelの処理が呼ばれると依存度が高まりMVVMの目的の1つである疎結合が崩れる 呼び忘れる恐れがある: LaunchedEffect(Unit) をはじめどこからでも呼び出せる代わりに呼び忘れる恐れがある 補足:発展編 今回の内容についてより高度な議論をJaewoong Eum氏が こちらの記事 [^6]にて行っております。 Androidコミュニティに対してアンケートを取得した上で、Ian Lake氏のツイートを引用してinitブロックも LaunchedEffect(Unit) 内での初期化もアンチパターンであり SharingStarted.WhileSubscribed(5_000) を活用した初期値の設定を紹介しています。 ただ、私は以下の懸念について検討した上で今回は SharingStarted.WhileSubscribed(5_000) を使用しませんでした。 一般的な点では 可読性の低下:複数のプロパティを持つUiStateを SharingStarted.WhileSubscribed(5_000) で管理すると実装が複雑になり却って可読性が下がる プロジェクト内の点では 既存のコードとの整合性の低下: LaunchedEffect(Unit) 内で初期化している画面が多いことから既存のコードとの整合性が取りづらくなる です。 Jaewoong Eum氏の記事は今回ご紹介したものも含めて非常に勉強になりますので、全て英語ですが興味のある方は是非読んでみてください。 まとめ 今回は LaunchedEffect(Unit) 内で初期化したのですが、initブロックで初期化する場合と LaunchedEffect(Unit) 内で初期化する場合、2つのメリットとデメリットを比較した上で、以下の点を重視しました。 テスト容易性:独立した関数で実装しているため単体テストがやりやすい 既存のコードとの整合性:他の箇所を確認したところCompose化した画面の初期化は LaunchedEffect(Unit) 内で行っていることが多く整合性が取りやすい また、希望の順番を変えて並べ替えを行った時以下の順番で再表示を行います。 1. 並べ替えボタンを押下する 2. 希望の順番を任意の並べ替えに設定する 3. リクエストの時に希望の順番をAPIに渡す 4. データを取得する 5. 取得したデータの一覧を表示する ここから 4. データを取得する 処理を1つの関数で実装し、初回表示時も希望の順番を変えて並べ替えを行った時も希望の順番をAPIに渡して関数を呼び出す形にした方がいいと考えました。 よって再利用性も重視しました。 再利用性:どの箇所からでも呼び出せる 理想を追求するといろんな方法が出てきますが、アンチパターンとされているものがあっても正解は1つではないですし、チーム内でレビューすること・後々の拡張性やテスト容易性を考慮しその都度1番良い実装を選択できると良いですね。 一番大切なのは、自分なりに理由や根拠を明確にして実装することです。 読んでいただきありがとうございました。それでは次の記事で。 [^1]: 出典元: Classes: Constructors and initializer blocks: Initializer blocks より一部抜粋 [^2]: 出典元: Composeにおける副作用 より一部抜粋 [^3]: 出典元: rememberUpdatedState: 値が変化しても再起動すべきでない作用の値を参照する より一部抜粋 [^4]: ViewModel内の値をFragmentが参照できない(ViewModelで何が起きているかFragmentが知らない)状態 [^5]: FragmentがViewModel内で更新されている featureSummaryList を参照できる状態 [^6]: 出典元: Loading Initial Data in LaunchedEffect vs. ViewModel より一部抜粋
MNTSQ プラットフォーム部の藤原です。 本記事では、PythonとLibreOfficeを組み合わせたオフィスファイルのpdf変換について解説します。 LibreOffice はオープンソースのオフィススイートです。 Microsoft Officeで作成した各種ファイル(docxや、xslx、pptx)を読み込み、編集できます。 LibreOfficeにはこれらファイルをpdfでエクスポートする機能も存在しています。 GUIからの実行はもちろんCLIでも実行可能です。 soffice --headless --convert-to pdf ファイル名.docx LibreOfficeを導入済みの場合はこのようなコマンド 1 を実行することで、docxなどをpdfに変換できます。 さて、 soffice コマンドでdocxファイルなどをpdfなどで変換可能なことはここで示せました。 ウェブアプリケーションなどでオフィスファイルをpdf変換する場合には、内部で soffice コマンドを呼び出す形で変換できそうです。 ただし、この方式では処理のたびにプロセスを立ち上げることになります。 大量のファイルを効率的に変換しようと考えると都度プロセスを立ち上げることは非効率です。 そのための対策として、LibreOfficeのプロセスを立ち上げた状態で外部プログラムからLibreOfficeの機能を利用するための仕組みが提供されています。 それが、UNO(Unified Network Objects)です。 UNO(Unified Network Objects)について UNO(Unified Network Objects)とは、LibreOfficeの内部機能に外部プログラムからアクセスするためのコンポーネントです。 UNOは言語非依存でさまざまなプログラミング言語から利用可能です。 UNOへのアクセス方法としてはインプロセス方式とソケット接続方式があります。インプロセス方式はLibreOfficeマクロからLibreOfficeの操作をするためのものです。ソケット接続方式は外部プロセス(=外部プログラム)からTCPを使って接続してLibreOfficeの各種操作をするための仕組みです。 次のようにすることでTCP接続を介してUNOを利用できます。 soffice --headless \ --accept="socket,host=localhost,port=2002;urp;"\ --norestore UNOを直接操作するためのPythonパッケージとしては、 PyUNO が存在しますが、オフィスファイルをpdfに変換する目的としては、too muchと言えそうです。 本記事ではUNOそのものの説明については、このようにすると指定したTCPポートでLISTENさせることが可能である旨に留めておきます。 以降は、UNOを使ってLibreOfficeの機能を簡単に利用するための仕組みとしてunoserverを紹介します。 unoserverについて unoconv/unoserver は、XML-RPC経由でUNOの仕組みを利用できるようにするための、Pythonパッケージです。 コンテナで動かす場合の動作イメージとしては以下のようになっています。 unoserverの動作イメージ コンテナ内ではunoserverとheadlessなLibreOfficeを常時立ち上げて処理を待ち受けています 2 。 unoserverを簡便に利用するため、また、macOS等でも利用する際の参考としてコンテナイメージおよび、docker-compose.ymlを準備しました。 コードの全体像は Fufuhu/docker-unoserver にて公開しています。 以下のようにコマンドを実行してください。 docker run -d -p 2003:2003 fufuhu/unoserver コードからビルドして利用する場合は以下の通り実行してください。 git clone git@github.com:Fufuhu/docker-unoserver.git # dockerコマンドで利用する場合 docker build -t docker-unorsever . docker run -d -p 2003:2003 docker-unoserver # docker composeコマンドで利用する場合 docker compose up --build -d 動作確認としてPythonを使ってXML-RPCのリクエストを投げてみます。 python -c " import xmlrpc.client; import json; print(json.dumps(xmlrpc.client.ServerProxy('http://localhost:2003').info()))" \ | jq { "unoserver": "3.6", "api": "3", "import_filters": { "HTML": "HTML (StarWriter)", 〜〜中略〜〜 "impress_svg_Export": "impress_svg_Export", "impress_tif_Export": "impress_tif_Export", "impress_webp_Export": "impress_webp_Export", "impress_wmf_Export": "impress_wmf_Export" } } このようにして、汎用のXML-RPCクライアントを使ってunoserverを利用することが可能です。 ただし、unoserverの個別の機能を利用するには汎用のクライアントでは少々面倒です。 そこで、unoserverの提供するUnoClientを使った実装例を提示します。 UnoClientのサンプル UnoClientはunoserverパッケージに含まれているunoserver向けのXML-RPCクライアントやバイナリデータの送受信などをラッピングした高レベルクライアントです。 Fufuhu/docker-unoserver-client-sample にサンプルのコードを作成しました。 このリポジトリのコードを git clone して docker compose up --build -d でUNOサーバーとサンプルとなるウェブアプリケーションが立ち上がります 3 。 すこし本題に入るまでが長くなりますが、クライアントウェブアプリケーションのコードを眺めてみましょう。 main.py の抜粋を見てみましょう。 from fastapi import FastAPI, Request, UploadFile from fastapi.responses import HTMLResponse, Response from fastapi.templating import Jinja2Templates from unoserver.client import UnoClient app = FastAPI() templates = Jinja2Templates(directory=Path(__file__).parent / "templates" ) UNOSERVER_HOST = os.getenv( "UNOSERVER_HOST" , "localhost" ) UNOSERVER_PORT = os.getenv( "UNOSERVER_PORT" , "2003" ) FastAPIを使ったアプリケーションサーバーが立ち上がります。 UNOサーバーのホスト名と待受ポートを環境変数で指定できます。 また、テンプレートエンジンであるJinja2向けのテンプレートを格納しているディレクトリとして templates ディレクトリを指定しています。 docker-compose.ymlの該当部分を抜粋すると次のようになっています。 services : unoserver : image : fufuhu/unoserver ports : - "2003:2003" app : build : . ports : - "8000:8000" depends_on : - unoserver environment : - UNOSERVER_HOST=unoserver - UNOSERVER_PORT=2003 次に main.py のindex関数を見てみましょう。 / にアクセスすると、 index.html ファイルをレンダリングして返すようになっています。 @ app.get ( "/" , response_class=HTMLResponse) async def index (request: Request): return templates.TemplateResponse(request, "index.html" ) index.htmlのbodyタグ以下の抜粋としては以下のとおりです 4 。 < body > < h1 > PDF変換ツール </ h1 > < form method = "post" action = "/convert" enctype = "multipart/form-data" > < p > 変換したいファイルを選択してください </ p > < input type = "file" name = "file" required >< br > < button type = "submit" > 変換 </ button > </ form > </ body > ファイルを選択して変換ボタンをクリックすると /convert にファイルがPOSTされる形となっています。 ブラウザ上で http://localhost:8000 にアクセスすると次のような表示になります。 インデックス画面イメージ このフォーム内でファイルを指定して変換ボタンをクリックすると、 /convert にファイルがPOSTされる形となっています。 次に main.py の /convert に対応するコードを見てみましょう。 @ app.post ( "/convert" ) async def convert ( file : UploadFile): # アップロードされたファイルの内容をバイト列として読み込む indata = await file .read() # 元のファイル名から拡張子を除いた部分を取得し、ダウンロード用のPDFファイル名を生成する stem = Path( file .filename).stem if file .filename else "output" out_filename = f "{stem}.pdf" # unoserverに接続するクライアントを作成する(XMLRPC経由で通信) client = UnoClient(server=UNOSERVER_HOST, port=UNOSERVER_PORT) # ファイルのバイト列をunoserverに送信し、PDF形式に変換する # 変換結果はPDFのバイト列として返される result = client.convert(indata=indata, convert_to= "pdf" ) # 変換されたPDFをレスポンスとして返す # Content-Dispositionヘッダーにより、ブラウザがファイルを直接ダウンロードする return Response( content=result, media_type= "application/pdf" , headers={ "Content-Disposition" : f 'attachment; filename="{out_filename}"' }, ) 内容としてはコード中のコメントに記載の通りです。 リクエストからアップロードされたファイルのバイト列を取得して、UnoClientを使ってpdf変換を実現しています。 ポイントは、 UnoClient です。 UnoClient を利用することで、XML-RPCなどの処理を隠蔽して簡潔に記述できています。 実際の変換処理部分としては以下の2行のみで実現できます。 client = UnoClient(server=UNOSERVER_HOST, port=UNOSERVER_PORT) result = client.convert(indata=indata, convert_to= "pdf" ) ここまでで、Python(unoserver, UnoClient)とLibreOfficeを使ったオフィスファイルのpdf変換について示しました。 今回提示のサンプルでは、リクエストを受けてその場で変換処理を実行して変換後のpdfファイルを返しています。 巨大なファイルを処理する場合、UXなどを考慮すると好ましい実装としてはこの限りではない点には注意した方が良いでしょう 5 。 まとめ LibreOfficeのGUI/CLIを使ってオフィスファイル(docx, xslx, pptxなど)をpdfに変換できる 大量のファイルを変換する場合はLibreOfficeのプロセスを常駐させ、UNO(Unified Network Objects)を使って変換する方が効率的である UNOを容易に利用するための仕組みとして unoconv/unoserver が提供されている unoserverは言語非依存なXML-RPCを使ったファイルのpdf変換を提供している。ただし、Pythonの場合はunoserverパッケージの提供するUnoClientがあり、容易にpdf変換を実現できる 参考文献 https://ja.wikipedia.org/wiki/XML-RPC https://github.com/unoconv/unoserver https://docs.libreoffice.org/pyuno.html https://hub.docker.com/r/fufuhu/unoserver https://github.com/unoconv/unoserver https://github.com/Fufuhu/docker-unoserver https://github.com/Fufuhu/docker-unoserver-client-sample コマンド名が soffice となっているのはLibreOfficeの歴史的経緯に起因するものです。基本的にはlibreofficeコマンドでもaliasが貼られていることがほとんどであり、同等の動作が可能なはずです ↩ コンテナ内部でunoserverのプロセスと、LibreOfficeのプロセス両方を管理するための仕組みとしてtiniを導入しています。 ↩ Fufuhu/docker-unoserverから立ち上げたdockerコンテナやdocker composeとポート競合が発生するので、あらかじめ停止した上で実行してください。 ↩ CSS指定部分などは今回はメインではないので例示からは除外しています。 ↩ レスポンスで変換済みファイルを即時返却するのではなく、裏側でイベント駆動で処理したのちに変換処理完了通知とダウンロードリンクを送るなどが考えられます ↩

動画

書籍