12.08.2018

Visualforce リモートオブジェクトのつらみと TypeScript でラッパーの作成

初めて Salesforce の開発に触れてから 4 年経つのですが、Sites や Community Cloud などの上でビジュアルの作り込みが求められる開発に携わることが多く、世界は Lightning で盛り上がる中 Visualforce から手離れできないでいる現状です。

Visualforce と言うと Apex 書いて変数バインドさせてーだったのですが、SPA でインタラクティブな物が求められるようになったので、非同期処理を書けるJavaScript Remotingを使うようになりました。

ただ、Remoting もレコードの取得や作成・削除それぞれの為に Apex にメソッドを生やさないといけないのが結構面倒で、JSforce などで API を使ったデータのやりとりも考えたのですが、 API コール数の制限がシビアだったり。

そこで出会ったのが Visualforce リモートオブジェクトって奴です。
Visualforce ページにアクセスしたいオブジェクトとフィールドを定義しておけば、Apex を書かずに JavaScript でデータを扱える優れものです。
…と言いたかったところなのですが、触ってみると現実問題かなり扱うのに悪戦苦闘しました。

細かい使い方は開発者ガイドを見てもらいつつ、約半年間ガッツリ使ってみて出たつらみをまとめます。

つらみ一覧

トランザクションがない

基本的にはcreate() update() delete()全て即時実行なので、様々なオブジェクトが絡んだ更新処理などの途中などで処理に失敗した場合、すでに登録済みのレコードはどうするとか考える必要があります。

リモートオブジェクト使用のベストプラクティス | Visualforce 開発者ガイド | Salesforce Developers

リレーションの取得やGROUP BYの実行はできない

リモートオブジェクトは対象オブジェクトに対する CRUD 処理のみしかできません。
SOQL のように子オブジェクトを取得するには、親オブジェクトを取得後にその Id を用いて子オブジェクトを取得しないといけないのでリクエストが複数回必要になります。
また、GROUP BYFORMATなどの式も使えないので、その場合は JavaScript Remoting に頼ることになります。
実際の業務でもデータ検索・取得部分だけは Remoting で実装したりしました。

retrieve() 1 回で取得できる最大レコード件数は 100 件まで

デフォルトは 20 件になっていて、limitを指定すれば最大 100 件まで取得できます。

offset は 2000 まで

limit100 固定で、offsetを 100 ずつズラしていけば、最大 2000 件のレコードを取ることができます。
それ以上は、NUMBER_OUTSIDE_VALID_RANGEのエラーになってしまいます。

カスタムメタデータは offset を指定できない

リモートオブジェクトからでも、レコードタイプやカスタムメタデータは取得できるのですが、カスタムメタデータは登録されているレコード数以上のoffsetを指定すると、An unexpected error occurredのエラーになります(SOQL でも同じ)。
事前にレコード数を知らない限りは、リモートオブジェクトの limit上限 100 件までがまとめて取得できる限界になると思います。
マスタっぽい扱い方でもしない限り、そもそも必要な分の設定のみを取ると思うのであまり関係ないかも。

retrieve() は null で検索できない

{ ne: null }などを検索条件に指定しても、何も反映されず全件返ってくるかエラーになります。
テキスト型なら{ ne: '' }、数値型や日付系なら{ gt: 0 } { gt: new Date('2000') }などの様に大げさな範囲指定をして値のないレコードを除外することができます。

SFDC:Remote Objects で日付型の NULL 判定 - tyoshikawa1106 のブログ

and or 条件配下の項目指定は 2 つのみ

andorのプロパティの配下の項目指定は、2 つ以下でも 2 つ以上でもエラーになってしまうようです。

また、日付などの項目の範囲指定を条件にする際はand配下に項目を並べず外出しする必要があります。 恐らく、サーバー側で処理する際に下記の JavaScript の条件オブジェクトを JSON.stringify()にして文字列に変換していて、重複プロパティはどちらかが消えてしまうということからそうしているのかな。
しかも、先ほどのandorのプロパティ配下の項目指定は 2 つにする必要があるので、例えば公式のサンプルの例だとId != nullを無理繰り入れているという感じですね。

const account_created_from_date = new Date('2017-01-01')
const account_created_to_date = new Date('2018-01-01')
const clauses = {
  where: {
    CreatedDate: { lte: account_created_to_date },
    and: {
      CreatedDate: { gte: account_created_from_date },
      Id: { ne: '' },
    },
  },
}

リモートオブジェクトのクエリ条件の形式およびオプション | Visualforce 開発者ガイド | Salesforce Developers

Salesforce World Tour Tokyo2014: MiniHack を(ちょこっと)やってみたよ - yonet77 的な雑記帳

【Salesforce】RemoteObjects の検索条件オブジェクト自動生成について | UNITRUST

Blob は取得・更新できない

Attachment をリモートオブジェクトで取得しようとした時、Body などの Blob 型項目があるとVisualforce Remoting Exception: No serializer found for class…でエラーになってしまうので、

public with sharing class ExamplePageClass {
  @RemoteAction
  public static map<string, object> retrieveAttachment(String object_name, String[] fields, Map<String,Object> criteria) {
    String[] fs = new String[0];
    for (String f : fields) {
      if (f.equals('Body')) continue;
      fs.add(f);
    }
    return RemoteObjectController.retrieve(object_name, fs, criteria);
  }

  @RemoteAction
  public static map<string, object> createAttachment(String object_name, Map<String, Object> fields) {
    return RemoteObjectController.create(
      object_name,
      new Map<String, Object>{
        'Body' => EncodingUtil.base64Decode((String) fields.get('Body')),
        'ContentType' => fields.get('ContentType'),
        'Name' => fields.get('Name'),
        'ParentId' => fields.get('ParentId')
      }
    );
  }

  @RemoteAction
  public static map<string, object> updateAttachment(String object_name, String[] record_ids, Map<String, Object> fields) {
    Attachment a = [SELECT Id, Body FROM Attachment WHERE Id = :record_ids.get(0)];

    return RemoteObjectController.updat(
      object_name,
      record_ids,
      new Map<String, Object>{
        'Body' => EncodingUtil.base64Decode(EncodingUtil.base64Encode(a.Body) + (String) fields.get('Body'))
      }
    );
  }
}
<apex:remoteObjectModel
  name="Attachment"
  retrieve="{!$RemoteAction.ExamplePageClass.retrieveAttachment}"
  create="{!$RemoteAction.ExamplePageClass.createAttachment}"
  update="{!$RemoteAction.ExamplePageClass.updateAttachment}"
>
  <apex:remoteObjectField name="Body" />
  <apex:remoteObjectField name="ContentType" />
  <apex:remoteObjectField name="Name" />
  <apex:remoteObjectField name="ParentId" />
</apex:remoteObjectModel>

上記のようにretrieve()の時は、Id があれば/servlet/servlet.FileDownload?file=Idでダウンロードできると思うので Body を返さず、create()時に base64 の String を受け取って変換するリモートオブジェクトの上書き処理を定義して、

const a = new window.SObjectModel.Attachment()
a.create({ ParentId: 'parent_salesforce_id', Name: 'Attachment name', ContentType: 'text/plain', Body: 'YQo=' }, (error, ids) => {
  …
})

あとは上記のように Attachment の操作ができると思います。
ただし、リモートオブジェクトは処理を上書きできることからも裏は Remoting で動いているみたいで、数 MB の大きなファイルを base64 にして Body にそのまま突っ込もうとすると input too long のエラーになってしまいます。
base64 を 1,000,000 文字単位で base64 を分割しながら Apex 上で結合して更新する工夫が必要です(この方法だと Apex の String で扱える長さ 6,000,000 文字、おおよそ 4.3MB のデータまで扱える)。

create(), update() 時に標準項目を含めるとエラーになる

書き込む権限がないので当たり前なのかもしれませんが、例えばretrieve()して標準項目を含めた取得したデータの一部項目を修正して、標準項目を取り除かずそのままupdate()しようとしてエラーになっちゃうパターンがあったりします。

項目権限がないとエラーで返ってくる

上記と同じです。
だた裏が Remoting で Apex だからかわからないですけど項目権限が参照アクセス権のみでも更新ができちゃうんですよね。
あと、Page 上のリモートオブジェクト定義が動的にできないので、リモートオブジェクトの設定を上書きしてユーザやプロファイル毎の項目権限をgetDescribe()してエラーにならないように工夫する必要があります。

リモートオブジェクト使用のベストプラクティス | Visualforce 開発者ガイド | Salesforce Developers

create() 時に値がないとエラーになる

例えば、積み上げ集計項目だけを持つオブジェクトにレコードを作成する際、下記の様に何も値をセットせず(そもそもセットする項目がないので)create()をしようとするとエラーになってしまいます。

const p = new window.SObjectModel.Parent__c()
p.create({}, error => {
  console.log('error', error) // error => Error: 無効な項目リストが指定されています。
})

なので、リモートオブジェクトの操作を上書きする必要があります。

public with sharing class ExamplePageClass {
  @RemoteAction
  public static Map<String, Object> createCustomObject(String object_name, Map<String, Object> fields) {
    CustomObject__c co = new CustomObject__c();
    insert co;
    return new Map<String, Object>{ 'id' => co.Id };
  }
}
<apex:page controller="ExamplePageClass">
  <apex:remoteObjectModel name="CustomObject__c" create="{!$RemoteAction.ExamplePageClass.createCustomObject}">
    ...
  </apex:remoteObjectModel>
</apex:page>

create(), update(), retrieve() 時の WHERE, いずれも Date 項目は GMT 扱いになる

最後に上に並べたつらみが全部許せるぐらい、実装で苦労する部分です。
Salesforce の DB は、日付系の項目は GMT に変換されて格納され、表示する時はユーザのタイムゾーンを考慮して表示されると思います。

retrieve で取得した日付項目はタイムゾーンが考慮されているのですが、

  • retrieve()の WHERE で検索しに行く時
  • create()でレコードを作成する時
  • update()でレコードを更新する時

上記の時は、日付項目を GMT で処理してしまっているのです。
retrieve()してデータを取得して修正して更新しようとした時に日付系の項目が入ってるとタイムゾーン分上書きしてしまいます(日本時間だと日付系項目が +9 時間される)。
また、retrieve()の WHERE 条件に日付系項目があった場合、GMT として検索しにいこうとするのでその分を足し引きしないといけません(日本時間なら あらかじめ -9 時間しておく)。

いろいろ検証したのですが、Salesforce ユーザのタイムゾーン設定を変更したり PC のタイムゾーン設定を変更したりしても変化がなく、恐らくインスタンスか組織単位のタイムゾーン設定が反映されている感じがします。
普通だったらユーザ、もしくはブラウザレベルのタイムゾーンで影響を受けそうな気がしますがこれはもうちょっと動作確認が必要そうです。

セールスフォースの豆知識: Visualforce Remote Objects を使ったデータ更新

ラッパーを作る

こうやって詰まるところを挙げていくと、かなり癖があって普通にはオススメしづらいなという感じです。
じゃあ Remoting でいいんじゃんと言えばそうなんですが、Apex 見ると頭が痛くなる病にかかっているのであまり書きたくなくて。

なので、現在は流行りのバンドルした JS を読み込む SPA 開発で主に VSCode & TypeScript で実装しているのですが、その開発環境で使えるラッパーを作ってみました。
kenichi-odo/typed-remote-objects: A type safe and immutable Visualforce remote objects.

Active Record っぽい使い方ができて(実際に Ruby は書いたことない)、つらみで上げた取得件数と日付の GMT バグの解消を入れています。
CRUD する処理以外の関数は基本的に自分自身を返すのでメソッドチェーンで書けるようになっている、はず。
取得件数については、limit offsetを駆使して裏でグリグリ取得して待ち状態に入るので、SOQL で一括取得に比べると当たり前ですが速くはないです。

準備

下記は、SLDS のフィードを使ったサンプルを実装する例で、Feed__cと言うオブジェクトがあったら、Page にはリモートオブジェクトが定義済みの前提で下記のようなFeed__c.tsを作ります。

import { init, Record } from 'typed-remote-objects'

import { getElapsedTimeLabel } from '../visualforces/feeds/assets/utilities'

type SObject = Partial<{
  content__c: string | null
  CreatedById: string | null
  CreatedDate: Date | null
  Id: string | null
  LastModifiedById: string | null
  LastModifiedDate: Date | null
  Name: string | null
  number_of_comments__c: number | null
  OwnerId: string | null
}>

type Extensions = {
  getElapsedTimeLabel(this: SObject): string
}

export default ({ time_zone_offset }: { time_zone_offset: number }) =>
  init<SObject, Extensions>({
    object_name: 'Feeds__c',
    time_zone_offset,
    extensions: {
      getElapsedTimeLabel() {
        return getElapsedTimeLabel({ date_time: this.CreatedDate! })
      },
    },
  })
export type Feeds__cRecord = Record<SObject, Extensions>

どうしても解決できなかったので面倒ですが、Remoting の Apex を書かない分ここでオブジェクトのフィールドの型定義をしています。
Partialで包んでいるのは全プロパティがundefinedになることを示していて、レコード作成・更新時に null として登録するのか undefined で対象外かどうかを明示するために定義しています。
Extensionsの部分はカスタムオブジェクトで言うところの数式みたいなものです。数式作るほどでもないけどオブジェクト内の項目同士で加工したいという時にゲッターを作って項目同士を組み合わせたり文字列加工できるように関数を渡せるようになっています。

登録

登録

更新、削除

更新・削除

検索

検索

TypeScript の恩恵を受けるので、型定義によって補完が充実することと型が合わない物を入れようとするとエラーを出してくれます。
エディタ上での静的解析でコーディングはグッと楽になりました。

デモ

kenichi-odo/slds-feeds-sample: Sample feeds using salesforce lightning design system (with Salesforce DX, Vue.js and TypeScript).

PCデモ

モバイルデモ

どうやって開発に組み込むのっていう人のためにラッパー入れて即席でデモ画面を作っておきました(画面自体見てもアレだと思うので気になる方はソースを見てください)。
リンクから README 見てもらえれば動かし方書いてあります。
データの取得方法は、件数考慮せずデモ用に適用に書いているのであくまで参考程度に。

まとめ

ラッパーについては、もともとはリモートオブジェクトの定義を読み取ってフィールドのメタデータを見て VSCode で補完できるような拡張機能も考えたりもしたのですが、日付バグだったり使いやすさだったりを改善するためにラッパーを作ってみた次第です。
包んであげてる分ダミーデータを用意して裏に通すとか、それこそ一人で開発している分は API コール数は気にならないと思うのでラッパーの裏で JSforce などで API でデータのやりとりとかすればローカルでの画面開発もできそうです。 結局、リレーションも相変わらず取れなかったりリモートオブジェクト以上のことはできません。

あと、チームでの開発の一部で導入しているものの自己満で作ったもので潜在的なバグもまだあるかもしれないです。
Visualforce 依存なのでラッパーそのもののテストもどうしようかなという感じで、たぶんテスト用のスクラッチ組織でも作って画面上での確認が必要な気がします。

とにかく、Visualforce でごてごてとした開発も一般にどれぐらいあるのかわかりませんが、もしもリモートオブジェクト使う機会があれば同じ躓きが無くなるようつらみ一覧が参考になればなと思っています。
ちなみに Lightning での開発だとこのリモートオブジェクトにあたるのがLightning データサービスっぽいので、今後 Lightning での開発の機会があれば触ってみたいですね。

最後に、今回は普段開発で使用しているということもあり Vue での例を挙げましたが、今は React だったり Angular だったりこれから来る Web Components、もちろん Lightning も含めていろんな画面開発のアプローチが出てくると思います。
それに合わせて、Salesforce 開発でのデータ取得の部分も Remoting やリモートオブジェクトを超えるカッコいいものがリリースされて、より良い開発ができることを期待したいです。

以上、Salesforce Platform Advent Calendar 2018 - Qiitaの 8 日目の記事でした。