Translate

2011年5月21日土曜日

Lync 2010クライアントの前提って何なんだろう..

使えるかどうかの検証で
Linc Server Standard Editionを立ち上げてみた。

あくまで健勝なので
クアッドコアの
Windows 7 Home Edition
にVMware playerを入れて
MSのTechNetで手に入れた
Windows2008 Server:Active Directory
Windows2008 ServerR2:Lync Server Standard Edition
Windows XP SP3:Lync 2010(Client)

と別のWindows 7 上に1つ別のXP SP3
を建ててもう一つのクライアントにする。

でつなげようとしたのだけど
どうもActive Directiryで構築したドメインでログインしないと
Lync 2010がつながらない..
しかも最新のホットフィックスを入れていないと
証明書の検証云々のメッセージが出てつながらない。

クライアントの前提が
よくわからないんだよなあ..

どこかに書いてあるのかなあ..

もしご存じの方はコメントに是非
情報を入力してください

2011年5月18日水曜日

Xperia Play に HT-03A に挿していたSIMカードを突っ込んでみる

最近
Xperia arcだとか
Xperia acroだとか
発表していますが、
私が欲しかったのは
Xperia Play!

PlayStationロゴのゲームの動く
Android携帯!

3月に海外では販売されたので
わくわくして待っていたけど
どうも日本では出ないらしいなる記事を
見かけて..

..速攻並行輸入しているサイトで
ぽちってしまった..

SIMフリーってかいてあるので、
docomo HT-03Aに挿していた
SIMカード差しちゃえばうごくだろうと..


で、
到着して早速動かしてみる。

日本語をセットしたら..
「..コイツ、動くぞ」

普通に動くじゃん..日本語で。
とっととうっちまえばいいのに..

画面通りにセットアップしていったけど..
WiFiのない環境で
インターネットに繋がらない..

しょうがないのでGoogleってみると
Xperia PLAYに搭載されている“テザリング”機能を試してみる。
なるサイトをみつけて
このとおりにやってみた。

..うごかない..
mopera.flat.foma.ne.jp
がつかえないようだ。

で、
HT-03A上にあったAPN情報(mpr2.bizho.net)を
そっくりいれてみたが
..やっぱりだめだった。

色々検索して
open.moprera.net
をAPNに登録したら
みえるようになった。


..けど、パケ死するんじゃなかろうか..
..とりあえず今月はWiFiで使って
今月の請求書見てからこのAPN使うかどうかきめようかな..
#後日談:請求金額、2倍になりました..
#このAPNだと定額からはずれることだけはわかったよ。
#まあ10倍とかにならんでよかった..

------

しばらく使ってみて気づいたのは、
SIMカードやSDカードの挿しが甘い
ことがたまに発生する。
SIMが甘いと突然リブートするようになるし、
SDカードが甘いとアンマウントメッセージが
画面上部に表示される。

HT-03AはSIMをしっかりささないと
電池がはまらないのでよかったけど
Playは筐体自体が大きめで多少余裕がある
ことから
持ち運びで結構激しい動きをしてしまうと
勝手にリブートしてしまう..

よく差し替えて使用する人は
気をつけてください。

..だんだん筐体のふとさが
うざくなってきた..

Lync Serverインストール中にSqlExpressRtc導入中にエラー2068578304がでる

Lync Serverインストール
「最初のStandard Editionサーバーの準備」
でタイトルにあるような
SqlExpressRtcの前提条件チェック時に赤文字で
エラーコード2068578304
をだした人は、
おそらくActive Directorのドメインコントローラ上に
Lync Serverをインストール使用としている人だと思います。

残念ながら
Lync ServerはAD DC上にインストール出来ません。

厳密には
Lync Serverの構成要素である
SQL Server Expressが
Active Directory ドメインコントローラ上に
インストールできないらしい..


ということで別途ドメインコントローラを
用意してください。

Microsoft Lync Server 2010
Microsoft Lync Server 2010(RC)
ステップバイステップガイド

っていうのを
公開してくれているのはありがたいけど、
このドキュメントの前提条件とかに書いていて欲しかった...
#SQL Server入れる人なら
##知ってて当然なことなのだろう..


..読んでたときに
なぜDCを別にした例で書かれているのか..
..気にはなってたんだよなあ..



DCを別立てにしてインストールしたら
上記のエラーはかわせました。

リファレンス:<apex:page>

Visualforceページを表すタグ。
すべてのVisualforceページはこの1組のタグで囲まなくてはならない。


属性名:action
属性値の型:ApexPages.Action
説明:
該当ページがサーバにリクエストされると、属性値に指定したアクションメソッドが呼び出されます。
たとえば、action={"!doAction"}の場合、コントローラのメソッドdoAction()が呼び出されます。
属性が指定されていない場合は、普通にページがロードされます。
アクションメソッド(アクションメソッドは通常次の遷移先のPageReferenceを返却する)null値を返した場合、現在の画面を再描画します。
アクションメソッドはページ描画されるまえに呼び出され、別のページへリダイレクトされます。
アクションメソッドを初期化のために使用すべきではありません。
※オプション、APIバージョン10.0以降、アクセス範囲global


属性名:apiVersion
属性値の型:double
説明:
該当ページを描画・実行する際に使用されるForce.com WebサービスAPIのバージョンを指定します。
※オプション、APIバージョン10.0以降、アクセス範囲global


属性値:cache
属性値の型:Boolean
説明:
該当ページをキャッシュするかどうか真偽値で指定します。
真値の場合、ブラウザは該当ページをキャッシュします。
指定しない場合は、デフォルトである偽値が指定されたものとして動作します。
しかしForce.com サイトでは、属性cacheは指定しない場合、デフォルトとして真値を指定したものと判断され、該当ページをキャッシュします。
詳細はSalesforce組織のオンラインヘルプの"Caching Force.com Sites Pages"を参照して下さい。
※オプション、APIバージョン10.0以降、アクセス範囲global


属性名:contentType
属性値の型:String
説明:
描画されたページフォーマットとして指定したMIMEタイプが使用されます。
属性値として"text/html""image/png""image/gif""video/mpeg""text/css""audio/html"などが指定可能です。
MIMEタイプについてはW3C仕様を参照してください。
#の後にファイル名を定義することもできます。(例:"application/vnd.ms-excel#contacts.xls")
注意:ファイル名指定のある場合、属性cacheが真でなくては開くことのできないブラウザがあります。
※オプション、APIバージョン10.0以降、アクセス範囲global

属性名:controller
属性値の型:String
説明:
該当ページが使用するカスタムコントローラクラス名を指定します。
属性standardControllerと一緒に指定することはできません。
※オプション、APIバージョン10.0以降、アクセス範囲global

属性名:expires
属性値の型:Integer
説明:
キャッシュ期間を秒で指定します。
cache属性が真値でこの属性が指定されていない場合、デフォルト値として0秒が採用されます。
Force.com Siteの場合、cache値が偽値ではない場合、デフォルト値として600秒が採用されます。
詳細はSalesforce組織上のオンラインヘルプ"Caching Force.com Sites Pages"を参照の事。
※オプション、APIバージョン14.0以降

属性名:extensions
属性値の型:String
説明:
該当ページに追加する拡張コントローラクラス名を指定する(複数指定可能)。
※オプション、APIバージョン11.0以降、アクセス範囲global

属性名:id
属性値の型:String
説明:
ページ内の別のコントローラから参照されるときに使用するID名。
※オプション、APIバージョン10.0以降、アクセス範囲global

属性名:label
属性値の型:String
説明:
Salesforceセットアップツール(ブラウザ:氏名→設定)上で参照されるときに使用するラベル名。
※オプション、APIバージョン10.0以降、アクセス範囲global

属性名:language
属性値の型:String
説明:
Salesforce上のトランスレーション(トランスレーションワークベンチ)に結び付けるためのラベル名を指定します。
指定した値で表示しているページの言語をオーバライドします。
Salesforceにてサポートされている言語のキー値を属性値として定義可能です(たとえば"en"や"en-US"など)。
※オプション、APIバージョン10.0以降、アクセス範囲global

属性名:name
属性値の型:String
説明:
Force.com Webサービス内のページとして参照するための一意な名前。
※オプション、APIバージョン10.0以降、アクセス範囲global

属性名:pageStyle
属性値の型:String
説明:
属性pageStyleはSalesforce API 16.0の段階でdeprecated扱いとなりました。
※オプション、APIバージョン10.0以降、アクセス範囲global

属性名:recordSetName
属性値の型:String
説明:
属性pageStyleはSalesforce API 16.0の段階でdeprecated扱いとなりました。代わりに属性recordSetVarを使用してください。
※オプション、APIバージョン14.0以降

属性名:recordSetVar
属性値の型:String
説明:
このレコードセットは、ページ上への表示やレコード上でのアクション実行のための戻り値として式の中で使用することができます。
たとえば、標準コントローラを使用していてrecordSetVar値として"accounts"(商談)が定義されている場合は、次のように記述することで単純な商談レコードのpageBlockTableを作成することができます:
<apex:pageBlockTable value="{!accounts}" var="a"><apex:column value="{!a.name}"/></apex:pageBlockTable>
※オプション、APIバージョン14.0以降

属性名:renderAs
属性値の型:String
説明:
サポートされているコンテンツコンバータの名前を指定します。
現在サポートされているコンテンツコンバータはPDFのみです。
この属性値に"pdf"を指定することで、該当ページをPDFとして変換されます。
PDF変換が目的のVisualforceページは、ページを印刷用としてデザイン・最適化してください。
入力フィールドやボタン、その他のJava Scriptを許可しているコンポーネントなどのformタグ要素を含む構成である単純ではない標準コンポーネントでは使用すべきではありません。
これはformタグ要素を含めることを制限するわけではありません。
組織へ配置する前に変換ページを確認してください。
PDF変換結果が文字の表示に失敗し場合、CSS内のフォントを調整してください。
(例:<apex:page renderas="pdf"> <style> body { font-family: Arial Unicode MS; } </style>
注意:pageBlocksectionHeaderコンポーネントでの2バイトフォントを使ったPDF変換はサポートされていません。
※オプション、APIバージョン13.0以降、アクセス範囲global

属性名:rendered
属性値の型:Boolean
説明:
ページをレンダリングさせるかを真偽値で指定します。
指定しない場合は、デフォルト値として真値が指定されたものとして動作します。
※オプション、APIバージョン10.0以降、アクセス範囲global

属性名:setup
属性値の型:Boolean
説明:
標準のSalesforceセットアップページのスタイルを使用するかどうかを真偽値で指定します。
もし真値の場合、セットアップのスタイルを使用します。
属性を指定しない場合は、デフォルトである偽値が指定されたものとして動作します。
※オプション、APIバージョン10.0以降、アクセス範囲global

属性名:showHeader
属性値の型:Boolean
説明:
Salesforceタブヘッダがページ内に含まれるかどうか真偽値で指定します。
真値の場合、タブヘッダが表示されます。
指定がない場合、デフォルトの真値が指定されたものとして動作します。
※オプション、APIバージョン10.0以降、アクセス範囲global

属性名:sidebar
属性値の型:Boolean
説明:
標準Salesforceサイドバーを表示するか真偽値で指定します。
真値の場合、サイドバーが表示されます。
指定がない場合、デフォルトである真値が指定されたものとして動作します。
※オプション、APIバージョン10.0以降、アクセス範囲global

属性名:standardController
属性値の型:String
説明:
該当ページの振る舞いをコントロールするSalesforceオブジェクトの名前を指定します。
この属性はすでに属性controllerが指定されている場合、定義することができません。
※オプション、APIバージョン10.0以降、アクセス範囲global

属性名:standardStylesheets
属性値の型:Boolean
説明:
属性showHeaderが偽値である場合、標準Salesforceスタイルシートが生成されたページヘッダに追加されたかどうかを真偽値で指定します。
真の場合、標準スタイルシート群が生成されたページヘッダに追加されます。
指定がない場合、デフォルトで偽値が指定されたものとして動作します。
偽値を設定することで、要求されたSalesforce.com CSSは正しく表示されない場合があります。
また、リリースバージョンによりスタイルが変更される場合があります。
※オプション、APIバージョン11.0以降、アクセス範囲global

属性名:tabStyle
属性値の型:String
説明:
色、スタイル、このページの選択されたタブをコントロールするSalesforceオブジェクトもしくはVisualforceタブを指定します。
カスタムオブジェクトを使用している場合、オブジェクトのDeveloper Nameで指定しなければなりません。
例えば、MyCustomObjectの場合、"MyCustomObject__c"を指定します。
標準コントローラを使用している場合、コントローラに関連付けられたスタイルが使用されます。
カスタムコントロールを定義している場合、ホームタブのものがデフォルトで使用されます。
カスタムVisualforceタブを使用する場合、タブの名前(ラベルではない)に続けて"__tab"を指定してください。
例えば、名前が"Source"、ラベルが"Sources"の場合、tabStyle="Source__tab"と指定します。
※オプション、APIバージョン10.0以降、アクセス範囲global

属性名:title
属性値の型:String
説明:
ブラウザに表示されるページタイトル。
注意:ページを開発者モードで編集しているときは、ページタイトルは表示されません。
※オプション、APIバージョン10.0以降、アクセス範囲global

属性名:wizard
属性値の型:Boolean
該当ページにて標準Salesforceウィザードページのスタイルを使用するかどうかを真偽値で指定します。
真の場合、ウィザードスタイルが使用されます。
指定がない場合、デフォルトの偽値が指定されたものとして動作します。
※オプション、APIバージョン10.0以降、アクセス範囲global


コード例:


<!-- Page: -->
<apex:page renderAs="pdf">
    <style> body { font-family: Arial Unicode MS; } </style>
    <h1>Congratulations</h1>
    <p>This is your new PDF</p>
</apex:page>



------
上記はSalesforce組織の設定内にある
Visualforce Componetnts のリファレンスを翻訳したものです。
TOEICが残念な人間が翻訳していますので注意してください。
#なお、ほかのタグも気が向いたら翻訳するかもしれません


p.s.
どっかに日本語化されたものがあるかもしれません。
あくまで、参考として..

2011年5月14日土曜日

Salesforce.com認定デベロッパー試験を受ける

Salesforce認定デベロッパーは、
Force.com上でカスタムアプリを
作る開発者向きの試験に合格する事で
取得できます。


開発者用の資格には
上級デベロッパーというのもあるのですが、

ブラウザのみで宣言的開発を行う範囲
→デベロッパー
対応する研修:Force.com基礎コース

ApexやVisualforceページを使ったコーディング
→上級デベロッパー
対応する研修:Force.com開発者コース

という切り分けになっています。

ごりんごりんコードを書いたり
VisualforceとAPEXコードで
Springよろしく開発しちゃう人は
上級までの知識が必要になります。

話をもどして..


認定デベロッパー試験は
PCによる選択・多岐選択式の問題が合計60問、
合格点は41点以上で90分の長丁場になります。



私は、
対象の研修を受験してから、
家に帰ってテキストを全部勉強しなおして、
テキストの「確認」と
演習テキストの演習終わりにある"問題"を
一通りといて受けました。

難易度は、
まる2日ほどべったり勉強すれば受かるとおもいます。

まあよくあるベンダの認定資格並みといったところでしょうか。

だいぶ昔にとった
Siebel2000Consultantよりは簡単だった。
テストの難易度は一緒くらいで
あっちはパッケージソフトなので
インストールなどの
導入関連の技術がいっしょくたになっていたため
試験範囲が広かったからなあ..


そうそう
試験終わったときおじさんが「おちた~」と
唸っていました。
おそらく研修を受けただけでそれほど復習せずに
ふらっと受けてしまったんだとおもいます(たぶん、ですけど)。

おじさんにはもうしわけないですが、
研修いっただけの人で何も復習していない人は
きちんと落ちる難易度設定なんだとおもいます。


認定試験に関する情報収集でSalesforce社のサイトに
いったことのある人はごぞんじだとおもいますが、
Study Guideというのが公開されています。
この文書のなかには、
サンプル問題が数題と
細かいセクション別の出題比率がでています。

各セクションごとにボーダーラインがあるわけではないので、
比率の高い箇所から順番に
勉強したほうが効率が良いはずですから、
必ず勉強を始める前に読んでおいてください。


公式の研修を受講された方なら
ご存知の
"人材採用管理アプリケーション"
がけっこうの比率で出題のねたとして
利用されていました。

テキストのSObject間の関係を
見ておいてください。


先のStudy Guideにでていますが、
一番よく出るセクションが
「データモデル」で、
ほとんどが
参照項目と主従項目の違い、
多対多
に関する問題でした。

人材採用アプリでは、
「評価」-(主従)→「申込」とか、
「内定」-(参照)→「申込」とか、
「給与」→「サイト掲載職種」←求人サイト(多対多)
とかを題材にして、
積み上げ集計だとか
数式でリレーションを云々とか
親を消したら子はどうなるとか
子のアクセス権は親に従うか云々
がよくだされていました。

研修用組織へのログインが有効なうちに
このあたりを集中して確認しておいてください。


あとは
プロファイルと組織の共有、共有ルールの振る舞い
なんかも何問かでていました。



次に出題比率の高い
「ビジネスロジック」は
ワークフローや承認プロセスの問題が中心で、
時間トリガ中の更新とか(Study Guideサンプルにもありましたけど)、
並行承認とか
ワークフローアクションをえらべとか
このセクションは比較的取りやすい問題が多かった
記憶があります。


暗号化項目も数問でてました。
データをのぞけるのはだれかとか、
暗号化項目にできる型はなど。


申請中変更のできるユーザを指定できるのは
管理者のみか、管理者と承認者だけかなんてのも
出てましたね。
間違えたのでこの問題はよく覚えています。



「ユーザインタフェイス」は、
レコードタイプとプロファイルでのページレイアウト出しわけとか
が出てました。

あとはページレイアウト上でつけられる制限なんかも。



「データ管理」は
ユーザインタフェイスとの複合問題で、
ユニーク設定をページレイアウト上で設定できるかとか
APIから追加・更新した場合ページレイアウトの必須設定はチェックされるか
出てました。

あとは更新する場合必須の項目なども。

外部IDについても出てました。
これはデータモデルに分類されるのかもしれないですけど。

ああ作れるタブはどれかっていう選択問題もありましたね。



「レポートと分析」は
..勉強しづらいのですが、
問題にできる部分はそう多くないので
比較的取りやすいセクションです。

ダッシュボード上のコンポーネントの種類や
分析スナップショットで作らないといけないものなど。

ダッシュボード結果を電子メールで送信できるのは
Salesforceユーザ以外も可能か、
という問題が出ていたのは覚えてます。



「Force.comプラットフォームの概要」は..
どれがこのセクションの問題だったか..

これこれの機能はどのエディションから使えるか2つ選べ
とかいう問題が出てましたが、
2つ..といっただんかいで
これこれを知らないでも
EEとUEになるのは自明だったりする自明問題が
ここかな。



「アプリケーション設計」は
なにだすのかな..とおもっていましたが
カスタムオブジェクトは3つのもでるのどれにあたるのかとか
ページレイアウト、カスタムタブはどれかとか..
..まああまり勉強しなくても大丈夫のセクションでしょう。



長丁場であるため、
時間は比較的余裕があるとおもいます。
1問1分半なので、
セールス試験よりは
ゆっくり考えることができます。

私は
一旦回答しきって
見直し2回してタイムアップでした。

ただ時間をがっつりかけてしまうと後に響くので
3分以上はかけないなど自分ルールを
決めておくと良いかも。



SI会社のSEの場合は
上級試験範囲やもっと深い知識があったほうが
よいとおもいますが、
利用企業の人が自社内アプリ開発の基盤として
自社社員のみで開発する
とかならデベロッパー試験レベルの知識で
十分だと思います。

というかこのレベル以上の知識を
学習してもっと複雑なアプリを作る必要があるのであれば
Force.com以外のアーキテクチャでの開発が良いかもしれません。
#ケースによりますけど



Force.com基礎コース研修を受講した人は
理解度チェックとして度胸試しに
うけてみてはどうでしょうか。

ただ..
取得してもSalesforce.com社側から受けられる
メリットがあんまりないんですよね..

..って書いたら、誰も受けなくなっちゃうか..



Salesforce.com社にはもっと
資格取得者へ"アメ"を配ってほしいものですなw





p.s.
Salesforce.com認定試験って
なぜかピアソンとかでなく
富士通のeラーニングソフト上に構築された
システムでうごいてました。

試験が終わると
ダイアログがでて
60点中X点でした
とかいうダイアログだけでて終わり..

セールス試験は
一応Javaアプレットでレーダーチャートとか出たのに..

..こっちの方が値段髙いのに、淡白だなあ..

Salesforce.com認定セールス試験を受ける

Salesforce.com認定セールス試験を受けてきました。


セールス認定には一応メリットがあり、
取得者が1名いれば、
(もしくはコンサル認定1名以上いれば、)
Salesforce CRM/Force.comの協力社契約を
結ぶことができます。
#もしくは1ライセンスでも契約していればOK..だったとおもいます

協力社は
契約企業をSalesforce.com社に紹介することで
年間ライセンスの10%がいただける
というアライアンスです。

Salesforce.com社のパートナ営業がよくいう
はやりの製品を"いっちょかみ"することができます。


最近はやりのクラウドソリューションだけど
自社に載せるメニューがない会社は
この資格を取ることで
Salesforce CRM/Force.comをメニューに
のせることができます。

積極的にうりにいかずとも
メニューに載せられるわけで、
しかも顧客を運よく紹介できたら
受験料のもとは十分取り返せます。

#そんな"オトナ"な企業を探してみるのも
#面白いかもしれません;-p


まあ、いずれにせよ..
..デベロッパー試験よりはメリットがありますねw



私は..
ぶっちゃけ
値段も5000円と安いし、
営業向けの試験だから簡単かな..
とおもって何も考えずに
合格は結構余裕かな的なキモチで申し込みました。


Salesforce社のサイトに
ワークショップがあり
全部の動画を見ることが前提とあったのも
知ってたんですけど..
全く見ないで受けに行ってしまった..




一応合格しましたが..

..ぎりぎりでした..


デベロッパー試験同様
PCから受験するタイプのものなのですが、
時間25分で全50問と
時間が短いくせに設問が多い..


..答えに詰まって考える時間がほとんどない..

くそ、
25分間しかないのに..
50問も出しやがって..



家に帰ってから
ワークショップ動画を
ぱらぱらみてみたら..


..案の定ほとんどこの動画の内容から、でした。


これから受けようという人は
きちんと見て勉強しておいてください。




回答方式は、
多岐選択はほとんどなくて
ほとんどが一答式の
選択問題でした。

合格は50問中40問以上。

先に書いた通り
まず動画は事前に見ておくことをすすめますが、
気を付ける点をもうひとつ。

似たような問題が連投されるので
1問まちがえると数珠つなぎに間違える問題群
があること。




たとえば..
この"Value Buying Process"は
絵の中にある6つの四角の枠内が全部空欄になっていて
選択肢から選ばせる問題が3問ほどでたのですが、
1つ間違えたら数珠つなぎ的に間違えてしまいます。

こういうのは
勉強しないで受けにいった人間からすると
地雷地帯なわけです。



動画から出た問題のサンプルとして
もう1つ紹介しておきます。




"販売モデル"の絵も
問題に出ました。

赤枠で囲った部分が問題になってました。
これも
同じ絵で複数の役割で出題してましたね。


あと..動画見ていない人間が困るのは..
Salesforceの売り上げの国別比率とか、
現在の契約企業数とか..


逆に取りやすいのは一般的な問題。

CRMとは..とか、
顧客の訴求ポイントとか、
クラウドコンピューティングとかは
知らないでも回答できるのでありがたかったです。


気になったのは、
製品の内容についてあまり出てこない点。


..営業といえど
製品の内容についてもっと理解しておかないと
いけないんじゃないかな..と思うのですが..

ぎりぎり合格の私には
でかい口きける立場ではないですけど..



一緒に受けたのは4名、
顔色を見るとみな余裕で受かっている様子でした。
デベロッパー試験のように
「おちた~」
って唸るおじさんはいませんでした。

..っていうか、私がなりそうでした..






p.s.
ああいう認定試験の試験官、
楽そうでいいなあ..

後ろの席で本とか読めるし..
体力使わなさそうだし..


..老後は認定試験の試験官の仕事がいいなあ..

2011年5月11日水曜日

RESTサービス呼び出し、非同期、DOMを使ったApexコードサンプル

Force.comでは

SOAP APIのサーバとしてAPIを公開し他システムへ利用させる(コールイン)ことも、

外部のSOAP APIをApexクラスから呼び出すこと(コールアウト)もできる。



しかし

RESTサービスについてはコールアウト(外部RESTを呼び出す)のみ実装可能で、

残念ながらRESTサービスサーバにすることはできない。



おそらく、

ガバナ制約という足かせプラットフォーム環境で、

よくAjaxで活用されるRESTコールを受け付けるのは

どう考えても向いていないから

なのだろう。



以下のサンプルは、

Force.com開発者コースDEV541の演習8-3で使用したコードである。



このコードはRESTのコールアウトのサンプルでもあるが、

非同期Apexのサンプルでもある。



非同期Apexは、

Javaでいう別Theadのrun()のような非同期メソッドを含む

Apexのことである。



非同期Apexメソッドを呼び出すと、

その結果を待たずに次の行へ処理が進み、

非同期Apexメソッド上の処理は別途スレッドで実行される。

(スレッド、という名前なのかはわからないけど)



ここではApexトリガからRESTサービスを呼び出しているが、

処理時間がインターネットや相手側の負荷でよめないので

非同期メソッド上に処理を記述している。





トリガというアプリ屋泣かせの足かせに

非同期というさらに重石を載せるような

コードをかかなくてはならず、

結果紹介するコードはサンプルのくせに

実装にフラグを使った保守しにくいコードになっている。





さらにこのコードは受け取ったXMLをDOMを使って

要素内の情報を取り出しているサンプルにもなっている。

注意
XMLDom クラスは
Force.comにあらかじめ定義されているクラスではありません

[developerforce]XML Dom parser(英語)
のViewSourceボタンを押してソースを入手できます。
テストクラスも一緒に置いてあるので、
本番環境にもあげられます。






// Apex Code 演習8-3 非SOAP HTTPコールアウト(1)
//
//
trigger CandidateAddressValidation on Candidate__c (after insert, after update, before update) {

 // トリガ対象が1件のみの場合
 if (Trigger.new.size() == 1){ 
  // 新規追加の場合
  if (Trigger.isInsert) {
   // 新規の場合は非同期妥当性検査フラグの状態にかかわらず
   // 住所情報妥当性チェック(非同期処理)を行い結果を
   // レコード上に書き込み更新する非同期処理を実行する
   // なお、非同期実行は1回のApex呼び出しで最大10回まで
   AddressValidator.validateAddress(Trigger.new[0].id);

   // 上記メソッド内のロジックは非同期で処理されており
   // コールしたら結果を待たずに次の処理へ進む

  }

  // 更新の場合
  if (Trigger.isUpdate){
   // 更新前処理の場合
   if (Trigger.isBefore) {

    // 住所情報更新の場合
    if ((Trigger.new[0].Street_Address_1__c !=
     Trigger.old[0].Street_Address_1__c) ||
     (Trigger.new[0].Street_Address_2__c !=
     Trigger.old[0].Street_Address_2__c) ||
     (Trigger.new[0].City__c !=
     Trigger.old[0].City__c) ||
     (Trigger.new[0].State_Province__c !=
     Trigger.old[0].State_Province__c)) {

     // 非同期妥当性検査フラグを偽にする
     Trigger.new[0].AsyncValidationFlag__c
      = false;

     // 更新時妥当性検査処理は
     // after updateでのみ行うのでフラグを
     // 偽にすることでいちいち呼び出さない
     // ようにしている
    }
    // デバッグログ
    System.debug('\ncandidate=' +
     Trigger.new[0] + '\n');

   // 更新後処理の場合
   } else {
    // 非同期妥当性検査フラグが真の場合
    if (!Trigger.new[0].asyncvalidationflag__c){
     // 住所情報妥当性チェック
     // (非同期処理)を実行し結果を
     // 応募者レコード上に書き込む(更新)
     // 非同期実行は1回のApex呼び出しで
     // 最大10回まで
     AddressValidator.validateAddress(
      Trigger.new[0].id); 

     // 上記メソッド内のロジックは非同期で
     // 処理されておりコールしたら結果を待
     // たずに次の処理へ進む
    }
   }
  } // 更新の場合
 } // 1件のみの場合

 // 複数県の場合≒バッチ実行で複数件が来た場合は何もしない
 // おそらく、大量投入されたらコールアウトのガバナ制約を食いつぶすから
}




通常トリガクラスで

呼び出し元となるレコードの更新を行う場合

DML処理(insert/update/delete)はご法度である。



しかしREST結果取得に時間がかかりそうなので

非同期処理化してupdateステートメントを

使っている。





// Apex Code 演習8-3 非SOAP HTTPコールアウト(1)
//
// StrikeIronのサービスの1つである住所妥当性検査を使用して
// 応募者(Candidate)レコード上の住所情報の妥当性をチェックして
// 結果を同レコード上に格納する
//

public class AddressValidator {

 // StrikeIronサービスへログインするための情報
 static final StrikeIronInfo__c info =
  [select userid__c,
   password__c
   from strikeironinfo__c
   where active__c = true limit 1];

 // StrikeIronInfoはカスタムオブジェクトであり、
 // あらかじめStrikeIronサイトで登録したユーザID、
 // パスワード(平文)が1行だけ格納されている

 // 応募者の住所情報妥当性をチェックして結果を
 // 応募者レコード上に更新書き込みを行う
 //
 // ただしこの処理は米国住所のみ対応しており
 // 日本の住所妥当性は検査できない
 // 引数: id 応募者レコードID
 // 戻り値: なし(非同期メソッドはvoid必須)
 //
 // 非同期コールアウトメソッドとして指定しているので
 // このメソッドを呼び出した後は呼び出し元の次の処理へ
 // 進み、この中の処理は非同期に実行される
 @Future(callout=true) 
 public static void validateAddress(String id) {
  // カスタムオブジェクトError_Logがあらかじめ
  // 定義されておりレコードとして書き込むための
  // メッセージ用変数
  String msg = '';

  try {
   // 引数で渡されたレコードIDから
   // レコード情報を取得
   Candidate__c cand =
    [select
     // 非同期妥当性検査フラグ
     // StrikeIronを通したかどうかであり
     // 検査結果の合否ではない
     AsyncValidationFlag__c,
     // 住所1
     Street_Address_1__c,
     // 住所2
     Street_Address_2__c,
     // 市
     city__c,
     // 州
     state_Province__c,
     // 郵便番号
     zip_postal_code__c,
     // 国
     country__c,
     // 住所妥当性フラグ
     valid_us_address__c,
      // 行政区画
     county__c,
     // 議会地区(選挙区?)
     congress_district__c,
     // 緯度(99.9999)
     latitude__c,
     // 経度(999.9999)
     longitude__c,
     // 住所エラーメッセージ
     // StrikeIronサービスのエラーメッセージ
     // を格納するための項目
     address_error__c
     // 応募者オブジェクト
     from candidate__c
     // 引数で渡されたIDと一致する
     where id=:id];   
   // 応募者情報・StrikeIronログイン情報をメッセージに追加
   msg += 'candidate=' + cand + '\n\n';
   msg += 'info=' + info + '\n\n';


   // 送信情報(リクエスト)を生成
   HttpRequest req = new HttpRequest();

   // エンドポイント(接続するサービスのURL)文字列を
   // 作成する
   // GETパラメータもURLの後ろに付加させるので
   // 適宜EncodingUtilを使用してUTF-8エンコード化して
   // 文字列を完成させる

   // エンドポイント用変数
   String endpoint =
    'http://ws.strikeiron.com/' + 
    'StrikeIron/USAddressVerification4_0/' +
    'USAddressVerification/VerifyAddressUSA?';

   // GETパラメータ文字列:ユーザID
   String uId = 'LicenseInfo.RegisteredUser.UserID=' +
    // EncodingUtilクラスを使ってエンコード
    EncodingUtil.urlEncode(info.userid__c,'UTF-8');

   // GETパラメータ文字列:パスワード
   String pw = '&LicenseInfo.RegisteredUser.Password=' +
    info.password__c;

   // GETパラメータ文字列:住所情報1
   String street1 = '&VerifyAddressUSA.addressLine1=';
   street1 +=
    cand.Street_Address_1__c != null ?
    // 存在する場合はEncodingUtilでエンコード化
    EncodingUtil.urlEncode(
    cand.Street_Address_1__c,'UTF-8') :
    // nullの場合は空文字にする
    '';

   // GETパラメータ文字列:住所情報2
   String street2 = '&VerifyAddressUSA.addressLine2=';
   street2 +=
    cand.Street_Address_2__c != null ?
    // 存在する場合はEncodingUtilでエンコード化
    EncodingUtil.urlEncode(
    cand.Street_Address_2__c,'UTF-8') :
    // nullの場合は空文字にする
    '';

   // GETパラメータ文字列:市、州、郵便番号
   String citystatezip =
    '&VerifyAddressUSA.city_state_zip=';
   citystatezip +=
    cand.City__c != null ?
    // 存在する場合はEncodingUtilでエンコード化
    // して次のデータのための空白1個追加
    EncodingUtil.urlEncode(
    cand.City__c + ' ','UTF-8') :
    // nullの場合は空文字にする
    '';
   citystatezip +=
    cand.State_Province__c != null ?
     // 存在する場合はEncodingUtilでエンコード化
    // して次のデータのための空白1個追加
    EncodingUtil.urlEncode(
    cand.State_Province__c + ' ','UTF-8') :
    // nullの場合は空文字にする
    '';
   citystatezip +=
    cand.Zip_Postal_Code__c != null ?
     // 存在する場合はEncodingUtilでエンコード化
    // して次のデータのための空白1個追加
    EncodingUtil.urlEncode(
    cand.Zip_Postal_Code__c,'UTF-8') :
    // nullの場合は空文字にする
    '';

   // エンドポイント文字列作成
   endpoint += uId + pw + street1 + street2 +
    citystatezip +
    // 米国住所妥当性検査
    '&VerifyAddressUSA.casing=Proper';

   // メッセージにエンドポイント追加
   msg += 'endpoint=' + endpoint + '\n';

   // リクエストにエンドポイント登録
   req.setEndpoint(endpoint);
   // HTTP MethodをGETにする
   req.setMethod('GET');

   // HTTP通信インスタンス生成
   Http http = new Http();

   // リクエスト送信しレスポンスを受領
   HttpResponse res = http.send(req);

   // HTTP状態コードが200(通信成功)の場合
   if (res.getStatusCode() == 200) {

    // 応募者レコードを更新する

    // レスポンスから受信メッセージ本文の
    // DOMオブジェクトを生成
    XMLDom doc = new XMLDom(res.getBody());

    // メッセージ内のVerifyAddressUSAResultタグ部分
    // のElementインスタンスを取得
    XMLDom.Element result =
     doc.getElementByTagName(
      'VerifyAddressUSAResult');

    // 属性AddressStatus値がValid(妥当性検査合格)
    // の場合
    if (result.getValue('AddressStatus')=='Valid'){

     // 住所妥当性検査フラグを真にする
     cand.valid_us_address__c = true;
     // 国は米国固定なので決め打ち
     cand.Country__c = 'US';
     // 属性ZipPlus4値を郵便番号に格納
     cand.Zip_Postal_Code__c =
      result.getValue('ZipPlus4');
     // 属性County値を行政区画に格納
     cand.County__c =
      result.getValue('County');
     // 属性CongressDistrict値を議会地区に
     // 格納
     cand.Congress_District__c =
      result.getValue(
       'CongressDistrict');
     // 属性Latitude値を緯度に格納
     cand.Latitude__c =
      // 数値項目なのでDoubleクラスを
      // 使ってDouble化する
      Double.valueOf(
       result.getValue(
        'Latitude'));
     // 属性Longitude値を経度に格納
     cand.Longitude__c =
      Double.valueOf(
       result.getValue(
        'Longitude'));
     // 妥当性検査成功なので空文字にする
     cand.address_error__c = '';

    // Validでない=不合格の場合
    } else {

     // 住所情報は書き換えない
     // StrikeIronサービスで取得できる
     // 行政地区、議会地区、緯度、経度
     // は空文字やnull値に初期化しておく

     // 行政区画を空文字にする
     cand.County__c = '';
     // 議会地区を空文字にする
     cand.Congress_District__c = '';
     // 緯度をnullにする
     // 画面に項目は出るが値の部分には
     // なにも出ない
     cand.Latitude__c = null;
     // 経度をnullにする
     cand.Longitude__c = null;

     // StrikeIronエラーメッセージを格納
     cand.address_error__c =
      // エラー番号
      result.getValue(
      'AddressErrorNumber') + ' - ' +
      // エラーメッセージ
      result.getValue(
      'AddressErrorMessage');
     // 住所妥当性検査フラグを偽にする
     cand.valid_us_address__c = false;

    }

    // 結果がValidであるかどうかはともかく
    // StrikeIron妥当性検査を実施したので
    // 非同期妥当性検査フラグを真にする
    cand.asyncvalidationflag__c = true;

    // 更新後応募者レコード情報をメッセージに追加
    msg += 'async updated candidate=' +
     cand + '\n';

    // 応募者レコードを更新する
    update cand;

    // この更新処理についても
    // CandidateAddressValidationトリガを
    // 2度(before update/after update)実行
    // されてしまうが、
    // トリガ内で住所1、住所2、市、州の更新を
    // した場合のみafter updateで妥当性検査が
    // 実行される実装になっているため、
    // StrikeIronサービスは呼び出されない

    // だったらinsertとbefore updateのみの
    // トリガにすればいいのだけど、
    // Apexトリガには
    // 1. 元のレコードがロード、または初期化される
    // 2. 新しいレコードの値がロードされ、古い値を
    //    上書きされ、必須項目などの検証処理が実行
    //    される
    // 3. すべてのbeforeトリガが実行される
    // 4. カスタム検証ルールを含むシステム検証が
    //    行われる
    // 5. レコードはデータベースに保存されるが、
    //    コミットはされない
    // 6. すべてのafterトリガが実行される
    // 7. 割り当てルールが実行される
    // 8. 自動返信ルールが実行される
    // 9. ワークフロールールが実行される
    // 10. ワークフローフィールドが更新されたら、
    //     レコードがリロードされる
    // 11. レコードの更新後、beforeトリガおよび
    //     afterトリガがもう一度起動される
    // 12. エスカレーションルールが実行される
    // 13. すべてのDML操作がデータベースにコミット
    //     される
    // 14. 電子メールの送信など、コミット後の
    //     ロジックが実行される
    // ※引用元:
    //     Force.com開発者コードテキストDEV541 p174
    // この順番に処理されていく。
    // だから、宣言的開発で設定された必須
    // チェックや入力規則などを先に実行させたい
    // がために面倒でもafterで処理しなくてはならな
    // かったから、と読んだ・

   // HTTP状態コードが200ではない(通信失敗の)場合
   } else {
    // デバッグログにレスポンス情報を書き込む
    System.debug('Callout failed: ' + res);
    // Error_Logへメッセージやレスポンスの主要情報
    // を書き込む
    msg += 'STATUS:'+res.getStatus();
    msg += '\nSTATUS_CODE:'+res.getStatusCode();
    msg += '\nBODY: '+res.getBody();
    Error_Log__c log = new Error_Log__c();
    log.trace__c = msg;
    insert log;
   } // HTTP状態コードのif文

  // 例外発生時処理
  } catch(Exception e) {
   // デバッグログに例外情報書き込む
   System.debug('ERROR: '+ e);
   // Error_Logへ例外情報を書き込む
   Error_Log__c log = new Error_Log__c();
   log.trace__c = msg + '\n' + e.getCause() + '\n' +
    e.getMessage();
   insert log;
  } // try-catch句:終わり
 } // validateAddress():終わり
}




非同期処理であり、

普通のプログラミング言語でもデバッグが大変なため

上記のようなError_Logカスタムオブジェクトを別途用意して

エラーの詳細情報をくみ取れるようにしておくと便利である。



が、

ログ情報がレコードとしてがんがんたまっていくので

容量上限に気を付けないとシステムが止まってしまう。



おまけに自動削除Apexトリガなどは埋め込みにくいので

人間系管理が面倒になることも注意だ。











ぬふー

2011年5月6日金曜日

VisualforceページをPDF化サンプル

以下のコードは、

Visualforceページの<apex:page>タグの

属性renderAsを使うことで

HTMLではなくPDFとしてユーザに渡すことができるサンプルである。



出自はForce.com開発者コースのVisualforce演習にて

提供されたコードである。






<!-- Visualforce 演習2-1 Visualforce でのPDFの生成 -->
<!-- 申し込み応募用紙をPDFとして提供する -->
<!-- 申し込みの意思表示のために署名させるためにPDFとして提供しているようだ -->
<!-- 申込(Offer__c)の標準コントローラを使用 -->
<!-- pageタグにはrenderAs属性がありこれをpdfと指定するとPDF化して表示できる -->
<apex:page standardController="Offer__c" showHeader="false" renderAs="pdf">
<body>
<center>
<!-- HTMLのtableタグになる -->
<apex:panelGrid columns="1" width="100%">
<!-- 組織の「組織名」 -->
<apex:outputText value="{!$Organization.Name}" styleClass="companyName"/>
<!-- 組織の「町名・番地」 -->
<apex:outputText value="{!$Organization.Street}"/>
<!-- 組織の「市区群」「都道府県」「郵便番号」 -->
<apex:outputText value="{!$Organization.City}, {!$Organization.State} {!$Organization.PostalCode}"/>
<!-- 組織の「電話番号」
<apex:outputText value="{!$Organization.Phone}"/>
</apex:panelGrid>
</center>
<p>
<br/><br/>
<!-- 申込日の表示を英語表記にしている-->
<apex:outputText value="{!CASE( MONTH(Offer__c.Offer_Date__c) ,
1, "January",
2, "February",
3, "March",
4, "April",
5, "May",
6, "June",
7, "July",
8, "August",
9, "September",
10, "October",
11, "November",
12, "December",
"None")} {!DAY(Offer__c.Offer_Date__c)}, {!YEAR(Offer__c.Offer_Date__c)}" />
<br/><br/><br/>
</p>
<p>
<!-- HTMLのtableタグ -->
<apex:panelGrid columns="1" width="100%">
<!-- 応募者氏名 -->
<apex:outputText value="{!Offer__c.Job_Application__r.Candidate__r.First_Name__c} {!Offer__c.Job_Application__r.Candidate__r.Last_Name__c}" styleClass="candidateName"/>
<!-- 応募者住所1 -->
<apex:outputText value="{!Offer__c.Job_Application__r.Candidate__r.Street_Address_1__c}" />
<!-- 応募者住所1 -->
<apex:outputText value="{!Offer__c.Job_Application__r.Candidate__r.Street_Address_2__c}" rendered="{!NOT(ISNULL(Offer__c.Job_Application__r.Candidate__r.Street_Address_2__c))}" />
<!-- 応募者市区群+都道府県+郵便番号 -->
<apex:outputText value="{!Offer__c.Job_Application__r.Candidate__r.City__c}, {!Offer__c.Job_Application__r.Candidate__r.State_Province__c} {!Offer__c.Job_Application__r.Candidate__r.Zip_Postal_Code__c}" />
<!-- 応募者国名(定義されていた場合のみ) -->
<!-- rendered属性が真であれば有効になる -->
<apex:outputText value="{!Offer__c.Job_Application__r.Candidate__r.Country__c}" rendered="{!NOT(ISNULL(Offer__c.Job_Application__r.Candidate__r.Country__c))}" />
<br/>
<!-- Dear+名前 -->
<apex:outputText value="Dear {!Offer__c.Job_Application__r.Candidate__r.First_Name__c}," />
<!-- テキスト -->
<apex:outputText >
<p>
<!-- 募集職種への申し込み開始云々 -->
I am pleased to offer you a position with Universal Containers (the "Company") as a {!Offer__c.Job_Application__r.Position__r.Name} for a start date of {!MONTH(Offer__c.Job_Application__r.Position__r.Start_Date__c)}/{!DAY(Offer__c.Job_Application__r.Position__r.Start_Date__c)}/{!YEAR(Offer__c.Job_Application__r.Position__r.Start_Date__c)}, reporting to {!Offer__c.Job_Application__r.Position__r.Hiring_Manager__r.Name}.
</p>
<p>
<!-- 給与やボーナスの話 -->
If you decide to join us, you will receive an annual base salary of ${!ROUND(Offer__c.Actual_Salary__c,0)} less applicable withholding, which will be paid semi-monthly in accordance with the Company's normal payroll procedures. In addition, you will be eligible to receive a discretionary {!ROUND(Offer__c.Bonus_Percentage__c,0)}% bonus based on individual performance, Company performance, and corporate matrix.
</p>
<p>
<!-- ストックオプションの話 -->
Subject to approval of the Company's Board of Directors, you will be granted an option to acquire {!ROUND(Offer__c.Stock_Options__c,0)} shares of Common Stock, vesting 25% on the first anniversary of the grant date and 1/48th of the grant amount each month thereafter, so long as your employment with the Company continues. The grant price of the stock option and vesting date of the stock option will be set upon Board approval.
</p>
<p>
<!-- 締め切りの話 -->
As a Company employee, you are also eligible to receive certain employee benefits. The Company reserves the right to change the benefits and compensation programs at any time. This offer expires on {!MONTH(Offer__c.Offer_Expiration_Date__c)}/{!DAY(Offer__c.Offer_Expiration_Date__c)}/{!YEAR(Offer__c.Offer_Expiration_Date__c)}, and is contingent upon the results of your background investigation.
</p>
<p>
<!-- 応募者の権利の話 -->
If you choose to accept this offer, your employment with the Company will be voluntarily entered into and will be for no specified period. As a result, you will be free to resign at any time, for any reason or for no reason, as you deem appropriate. The Company will have a similar right and may conclude its employment relationship with you at any time, with or without cause. In addition, you will be required to gain approval from Universal Containers in the event that you are a member of or would like to participate on the board of any existing or new companies.
</p>
<p>
<!-- 身分証明の話 -->
For purposes of federal immigration law, you will be required to provide the Company documentary evidence of your identity and eligibility for employment in the United States. Such documentation must be provided to us within three (3) business days of your hire date, or our employment with you may be terminated.
</p>
<p>
<!-- 申込時合意事項の話 -->
This offer letter fully sets forth all of your compensation information and expectations, and you agree that in making your decision to join the Company you are not relying on any oral or other representations concerning any other compensation or consideration or the duration of your employment with the Company, including but not limited to any value associated with your stock options.
</p>
<p>
<!-- サインして提出云々 -->
To indicate your acceptance of the Company’s offer, please sign and date this letter in the space provided below and return it to me. A duplicate original is enclosed for your records. You will be required to sign an Employee Inventions and Proprietary Rights Assignment Agreement as a condition of your employment. This letter, along with any agreements relating to proprietary rights between you and the Company, set forth the terms of your employment with the Company and supersede any prior representations or agreements, whether written or oral. This letter may not be modified or amended except by a written agreement, signed by the Company and by you.
</p>
<p>
<!-- オリエンテーションの話 -->
If you accept this job offer, your hire date will be on the day that you attend new-hire orientation. Plan to work for the remainder of the business day after new-hire orientation ends.
</p>
<p>
<!-- 申込追加コメント -->
{!Offer__c.Additional_Instructions__c}
</p>
<p>
<!-- しめくくり -->
We look forward to working with you at Universal Containers. Welcome aboard!
</p>
<p>
<!-- 草々 -->
Best Regards,
</p>
<p>
<br/>
<br/>
<br/>
________________________________
<br/>
<!-- 募集職種担当者(所有者)名前 -->
{!Offer__c.Job_Application__r.Position__r.Owner.Name}
<br/>
<!-- 採用担当 -->
Hiring Coordinator, Human Resources
</p>
<p>
<br/>
<!-- 申し込み用紙 -->
<h4>Accept Job Offer</h4>
By signing and dating this letter below, I, {!Offer__c.Job_Application__r.Candidate__r.First_Name__c} {!Offer__c.Job_Application__r.Candidate__r.Last_Name__c}, accept the job offer of {!Offer__c.Job_Application__r.Position__r.Name} by Universal Containers.
<br/>
Signature:__________________________________ Date:_____________
</p>
</apex:outputText>
</apex:panelGrid>
</p>
</body>
</apex:page>





Force.com上のDBデータを使ってPDFが作成できるので

ユーザ間で紙文書としてのやり取りが必要な場合には

活用できる。



ただHTMLとして表現できないものはダメなので

そのへんは癖が結構あると考えた方がいいだろう。

Visualforceページのコードサンプル

以下のコードは、
Force.com開発者コースの演習で紹介された
Visualforceの簡単なサンプルページである。




<!-- Visualforce 演習1-1 Visualforce Hello Worldの作成 -->
<!-- 標準コントローラを使いたい場合、オブジェクト名を指定する -->
<apex:page standardController="Position__c">
<!-- HTMLも併用可能だが、かならずpageタグは使用しなくてはならない -->

<!-- {"!xxx}はJSPの@=と同じ -->
<!-- キーワード$Userを使えば操作中のユーザレコードにアクセスできる -->
<b>Hello {!$User.FirstName}!</b> <br/> <br/>

<!-- オブジェクトPosition__cをpという変数に置き換えている -->
<apex:variable var="p" value="{!Position__c}"/>

<!-- formタグ代替:1ページ1つだけしか定義できない -->
<apex:form>
<!-- ページブロック:標準画面の区画 -->
<apex:pageBlock title="Quick Edit" tabStyle="Zip_Code__c">
<!-- セクションは3列表示される -->
<apex:pageBlockSection columns="3">
<!-- name入力フィールド -->
<apex:inputField value="{!p.name}"/>
<!-- status入力フィールド -->
<apex:inputField value="{!p.Status__c}"/>
<!-- ボタン:押下されると標準コントローラの -->
<!-- save()が呼び出される -->
<apex:commandButton action="{!save}" value="Update"/>
</apex:pageBlockSection>
</apex:pageBlock>
</apex:form>
<!-- 詳細ページレイアウトを作成する:但し標準の関連リストは出さない -->
<apex:detail relatedList="false"/>
<!-- カスタム関連項目Job_Applicationの関連リストは表示する -->
<apex:relatedList list="Job_Applications__r" />
</apex:page>



JSP系の操作はすべて使えないようになっているので
HTML標準のタグかForce.comで用意されたapexタグで
実装しなくてはならない。

でも
どうみてもJSPなんだよなあ..

だったらもったいぶらないで
PaaSとしてJava書かせればいいものを..

匿名ブロックへ書くコードサンプル

匿名ブロックは、

Force.com IDE上に用意されている

コマンドライン実行領域。
#Apexコード上の匿名クラスとかではない
#私も最初勘違いしそうになったけど..


匿名ブロックを使えば

Apexコードをわざわざ書かなくても

プログラムを書くことができる。



以下、Force.com開発コースにある演習サンプルコードである。



// Apex Code 演習2-1 匿名ブロックを利用した簡単なApexコーディング
//
// 匿名ブロック:Force.com IDEパースペクティブ下部のAnonymous Blockを選択すると
// 以下のようなコードがダイレクトに書ける
// DOSプロンプトやshellコマンドのようなものと考えてよい
//
// Hello Worldと表示する
System.debug('Hello World!');
// 変数を定期する
Map<Integer, String> numbers = new Map<Integer, String>();
// for文で先のnumbersへ100個データを作成
for (Integer i=0; i<100; i++){
  numbers.put(i, String.valueof(i));
}
// キーが35の値がnumbersに存在する場合はデバッグログへ書き込む
if (numbers.containskey(35)){
   System.debug('The answer is: ' + numbers.get(35));
}
// リストを作成
List<Account> accts = new List<Account>();
// 20件のAccountレコードを作成
for (Integer i=0; i<20; i++){
  Account a = new Account(name='Account Name' + i);
  accts.add(a);
}
// for文でデバッグログに中身を書き出す
for(Account acct:accts){
    system.debug('The account name is: ' + acct.name);
}
// セットを作成
Set<Date> myDates = new Set<Date>();
// 100件データを作成
// 1件ごとに1日づつ後の日を登録していく
for(integer i = 0; i < 100; i++){
  myDates.add(Date.today().addDays(i));
}
// 日本語環境の場合はYYYY/MM/DD形式でDateを作成できる
Date birthday = Date.parse('2010/12/31');
// 先のmyDatesに上記の誕生日が含まれているかを調べる
if (myDates.contains(birthday)){
  System.debug('yes');
} else {
  System.debug('no');
}

ここではDB操作が書かれていないが、

当然処理可能。

ただし組織に反映されるので

ロールバックする場合は手動で行わないといけない。

ただ、SavePointはこの上でも使えるようだ

InboundEmailHandler内で自動返信メールまで送信するサンプル

電子メールtoケースを使用した場合

電子メールを受信の旨を自動返信する機能が

あらかじめ用意されていた。



InboundEmailHandlerを使用した場合

自動返信についてもApexコードで記述しなくてはならない。



以下のコードはForce.com開発者コースの演習で

使用されたサンプルで、

電子メールを受信処理後に送る処理を

行っている。







// Apex Code 演習9-2 アウトバウンドメールサービス(1)
// 到着した電子メールの本文を解釈して応募者(Candidate)レコードとして格納する
// 演習9-1の処理に加え、自動応答メール返信処理を追加
global class CandidateEmailHandler implements Messaging.InboundEmailHandler {

// 電子メール到着時に呼び出されるメソッド
// 引数: email 受信した電子メールデータ
// envelope 受信した電子メールのエンベロープ
global Messaging.InboundEmailResult handleInboundEmail(
Messaging.InboundEmail email, Messaging.InboundEnvelope envelope) {

// 戻り値インスタンス生成
Messaging.InboundEmailResult result =
new Messaging.InboundEmailresult();

// デバッグメッセージ用文字列変数
String myErr;

try{
// メール本文を格納する文字列変数
String theBody;

// 属性別に切り出された文字列を格納するリスト
List<String> fieldList = new List<String>();

// 受信メールがテキスト形式

if (email.plainTextBody != null){
// デバッグメッセージ変数に書き込み
myErr = 'plainTextBody=' + email.plainTextBody;
// メール本文を格納
theBody = email.plainTextBody;

// 受信メールがテキスト形式ではない(HTML形式)
} else {
myErr = 'htmlBody=' + email.htmlBody;
theBody = email.htmlBody;
}

// 本文を属性別に分解する
// 開始文字列、終了文字列にはさまれた文字列を切り出す
theBody = theBody.substring(
theBody.indexOf('[STARTBODY]')+11,
theBody.indexOf('[ENDBODY]'));
// セパレータ":"で分解しリスト化
fieldList = theBody.split(':',0);

// 分解元文字列と分解後をデバッグメッセージに追加
myErr += '\ntheBody=' + theBody;
myErr += '\nfieldList=' + fieldList;

// 属性名、属性値のMap化
Map<String,String> fieldMap = new Map<String,String>();

// 分解リストループ
for(String field : fieldList){
// "="で分割すると2つになる場合
if (field.split('=',0).size() == 2){
// 最初の要素をキー
// 2番目の要素を値として格納
fieldMap.put(
field.split('=',0)[0],
field.split('=',0)[1]);
}
}


// 応募者レコード
Candidate__c candidate;
try{
// 重複チェックのためにLastNameと電子メール
// アドレスが同じレコードを抽出
// 今回のアプリの仕様上LastNameとEmailで一意
// となっているはずなので存在しても1件のみ
// の想定であるためListではない
candidate =
[select id,
first_name__c,
last_name__c,
phone__c,
email__c,
ownerid
from candidate__c
where last_name__c =
:fieldMap.get(
'lastname')
and email__c =
:email.fromAddress];
// 既に存在する場合は、ほかの属性値更新のため
// このcandidate値を継続使用する
// 存在しない場合もQueryExceptionを発生させる
} catch (QueryException qe){
// candidateが存在しない場合は重複なしなので
// 新規作成する
if (candidate == null){
candidate = new Candidate__c();
}
// 処理継続させたいので再スローしない
}

// FirstName値上書き
candidate.first_name__c =
fieldMap.containsKey('firstname') ?
// 日本語が混じることも考えられるので
// EncodingUtilクラスを使ってエンコード
// する
EncodingUtil.urlDecode(
fieldMap.get('firstname'),'UTF-8') :
// nullの場合更新対象にならない
null;

// LastName値上書き
candidate.last_name__c =
fieldMap.containsKey('lastname') ?
EncodingUtil.urlDecode(
fieldMap.get('lastname'),'UTF-8') :
null;

// Phone値上書き
candidate.phone__c =
fieldMap.containsKey('phone') ?
EncodingUtil.urlDecode(
fieldMap.get('phone'),'UTF-8') :
null;
// Email値上書き
// 電子メールオブジェクトからなのでEncode不要
candidate.email__c = email.fromAddress;

try{
// 新規追加の場合
// (IDがまだ割り当てられていない)
if (candidate.id == null) {
// DBへ1件新規追加
insert candidate;
} else {
// 指定行を1件更新
update candidate;
}

// DB例外発生時処理
} catch (DMLException e){

// Error_Logレコードに例外情報を格納する
Error_Log__c log = new Error_Log__c();
log.trace__c = e.getTypeName() + '\n' +
e.getCause() + '\n' + e.getMessage() +
'\n\nfieldMap=' + fieldMap +
'\n\nmyErr=' + myErr;
insert log;

// 戻り値の成功フラグを偽にして返却
result.success = false;
return result;
}

// Comment欄データが存在する場合
// 応募者レコードのメモとして追加
// 「メモ&添付ファイル」関連レコードに格納される
if (fieldMap.containsKey('comment')){
// Note(メモ)レコード生成
Note cNote = new Note();
cNote.body = EncodingUtil.urlDecode(
fieldMap.get('comment'),'UTF-8');
cNote.parentId = candidate.id;
// タイトル文字列を格納
cNote.title = 'CandidateEmail:' + System.now();
try{
// Noteレコード格納
insert cNote;
// DB例外発生時処理
} catch (DMLException e){
// Error_Logレコードに書き込み
Error_Log__c log = new Error_Log__c();
log.trace__c = e.getTypeName() + '\n' +
e.getCause() + '\n' +
e.getMessage() +
'\n\nfieldMap=' + fieldMap +
'\n\nmyErr=' + myErr;
insert log;

// 戻り値の成功フラグを偽にして返却
result.success = false;
return result;
}
}

// バイナリorテキスト添付ファイル群を格納するリストを
// 新規生成
List<Attachment> attachments = new List<Attachment>();
// バイナリ添付ファイル群が存在する場合
if (email.binaryAttachments != null){
// バイナリ添付ファイルデータループ
for (Messaging.InboundEmail.BinaryAttachment
emailAttachment:
email.binaryAttachments){

// 新規添付ファイル格納オブジェクト生成
Attachment attachment =
new Attachment();
// 親IDとして応募者レコードを指定
attachment.parentId = candidate.id;
// データとしてそのまま格納
attachment.body = emailAttachment.body;
// ファイル名を指定
attachment.name =
emailAttachment.fileName;
// リストへ追加
attachments.add(attachment);
}
}

// テキスト添付ファイル群が存在する場合
if (email.textAttachments != null){
// テキスト添付ファイルデータループ
for (Messaging.InboundEmail.TextAttachment
emailAttachment:email.textAttachments){
// 新規添付ファイル格納オブジェクト生成
Attachment attachment =
new Attachment();
// 親IDとして応募者レコードを指定
attachment.parentId = candidate.id;
// データとしてBLOB型化して格納
attachment.body =
blob.valueOf(
emailAttachment.body);
// ファイル名を指定
attachment.name =
emailAttachment.fileName;
// リストへ追加
attachments.add(attachment);
}
}

// 格納対象となる添付ファイルが存在する場合
if (attachments.size() > 0){
try{
// 添付ファイル新規追加
insert attachments;
} catch (DMLException e){
// Error_Logレコードに例外情報を格納
Error_Log__c log = new Error_Log__c();
log.trace__c = e.getTypeName() + '\n' +
e.getCause() + '\n' +
e.getMessage() +
'\n\nfieldMap=' + fieldMap +
'\n\nmyErr=' + myErr;
insert log;
// 戻り値の成功フラグを偽にして返却
result.success = false;
return result;
}
}

// ここまで演習9-1の回答と同じ

// 演習9-2では送信元アドレスへ受信確認メールを自動
// で返信する処理を追加している

// 送信メール本文作成
String htmlBody =
buildOutboundEmailBody(
candidate.first_name__c);

// このコメントブロックはPDF(Visualforceページ)を
// 使って受信確認させる場合のサンプル
// buildOutboundEmailBody()メソッド呼び出している上
// の行と差し替えて使用する
// 前提)電子メールテンプレート
// 「CandidateEmailResponseTemplate」が登録済み
//
// 電子メールテンプレートのPageReferenceオブジェクト
// を取得
// PageReference templatePage =
// Page.CandidateEmailResponseTemplate;
// IDを応募者IDに差し替え
// templatePage.getParameters().put(
// 'id',candidate.id);
// リダイレクトを真に変更
// templatePage.setRedirect(true);
// 返信メール本文をPageReferenceから作成する
// ※このコードはSummer'08だと動作しない
// String htmlBody =
// templatePage.getContent().toString();

// 送信用メールメッセージの新規作成
Messaging.SingleEmailMessage emailOut =
new Messaging.SingleEmailMessage();
// サブジェクトの設定
emailOut.setSubject('Thank You for Your Email');

// 送信先アドレスの設定(受信メールのFromを指定)
String [] toAddresses =
new String[] {email.fromAddress};
emailOut.setToAddresses(toAddresses);

// メール本文の設定(HTML形式)
emailOut.setHtmlBody(htmlBody);

try{
// メールの送信
Messaging.SendEmailResult[] mailResults =
Messaging.sendEmail(
new Messaging.SingleEmailMessage[] {
emailOut});

// 送信結果をError_Logレコードに書き込み
List<Error_Log__c> logs =
new List<Error_Log__c>();
for (Messaging.SendEmailResult mailResult:
mailResults){
// 送信失敗の場合
if (!mailResult.isSuccess()){
// 例外情報を取り出す
Error_Log__c log =
new Error_Log__c();
log.trace__c =
'Error sending email to ' +
'candidate ' + candidate.id +
', ' + candidate.last_name__c +
'\n' + mailResult;
logs.add(log);
}
}
// 送信失敗の場合ログへ書き込む
if (logs.size() > 0) insert logs;

// メール送信以外の例外処理
} catch (Exception e){
// Error_Logへ例外情報書き込み
Error_Log__c log = new Error_Log__c();
log.trace__c = e.getTypeName() + '\n' +
e.getCause() + '\n' + e.getMessage() +
'\n\nfieldMap=' + fieldMap +
'\n\nmyErr=' + myErr;
insert log;
// 結果オブジェクトを成功フラグを立て返却
// メール送信以外は成功したという仕様かな?
result.success = true;
return result;
}

// 上記try-catch句以外で発生した例外処理
} catch (Exception e){
// 例外情報をError_Logへ書き込む
Error_Log__c log = new Error_Log__c();
log.trace__c = e.getTypeName() + '\n' + e.getCause() +
'\n' + e.getMessage() + '\n\nmyErr=' + myErr;
insert log;

// 結果オブジェクトの成功フラグに偽値を代入
result.success = false;
// 結果オブジェクトのメッセージに例外情報を書き込む
result.message = e.getTypeName() + '\n' +
e.getCause() + '\n' + e.getMessage() +
'\n\nmyErr=' + myErr;
// 結果オブジェクトの返却
return result;
}

// 結果オブジェクトを返却
// 正常終了の場合ここまで処理される
return result;

} // handleInboundEmail():終わり

// 送信元へ送る電子メール本文を作成する
// 引数: candidateName 応募者名
// 戻り値: HTML形式のメール本文文字列
private String buildOutboundEmailBody(String candidateName){

// 本来は電子メールテンプレート定義を使った処理がベター
// だがバグが治るまでは以下のようなべたな処理で対処する
String body = '<html><body><center><h1>';
body += UserInfo.getOrganizationName() + '</h1></center><p>';
body += 'Dear ' + candidateName + ',</p><p>';
body += 'Thank you for your interest in Universal Containers' +
body += '. Your email has been received and a recruiter will' +
body += ' evaluate it for potential opportunities within the' +
body += ' organization.';
body += 'We will keep your information on file, including an' +
body += 'y resume submitted, for one year. During that time' +
body += ' our recruiters will regularly search our pool of r' +
body += 'esumes for potential matches ';
body += 'within our open positions. You may be contacted by' +
body += ' one of our recruiters during the next year. There' +
body += ' is no need to respond to this email.';
body += '</p><p>Once again, we thank you for your submission' +
body += ' and interest.</p>';
body += 'Best Regards,</p><p>Recruiting Dept., Universal Con' +
body += 'tainers</p></p></body></html>';
return body;
}
}





処理の仕方は単純に

Messaging.sendMail()の使い方

を覚えればいいのだけど、

コメントにある通りバグフィックスされていないと

電子メールテンプレートをApexコードから活用することが難しい。

2011年5月4日水曜日

外部からの電子メール受信をトリガにApexクラスを起動するサンプル

Salesforce は CRM SaaS なので、

顧客からの問いあわせを割り振る機能は用意されている。

それが

電子メールtoケース or

オンデマンド電子メールtoケース

という機能だ。

ただこの機能は外部のメールサーバを利用する

というもの。



おそらく一般的にも

info@<会社のメールアドレス>

などへ問い合わせアドレスを顧客の目にさらす

場合があることを想定しているのだと思う。



ただ上記サービスは「ケース」オブジェクトへの連携

なので、

カスタムオブジェクトなどへの連携は宣言的開発では

出来なかった。



ただApexクラスに

Messaging.InboundEmailHandler インタフェイスを実装して

電子メールサービスに登録することで

自由度の聞くインバウンドメールをトリガとして起動する

プログラムを書くことが出来る。



以下、Force.com開発者コースの演習で使用した

電子メールサービスのサンプルコードである。





// Apex Code 演習9-1 インバウンドメールサービス(1)
// 到着した電子メールの本文を解釈して応募者(Candidate)レコードとして格納する
//
// 電子メールサービス「CandidateSubmission」のハンドラとして定義
// 「CandidateSubmission」宛メール到着→handlerInboundEmailメソッド呼び出し
// <登録すると割り当てられるメールアドレス名の例>
// candidatesubmission@
// x-xxxxxxxxxxxxxxxxxxxxxxxxx.xxxxxxxx.9.apex.salesforce.com
// x:Salesforceが自動で割り当てる
//
// 受信するメール本文は以下の文字列形式となっていることが前提
//
// "[STARTBODY]firstname=" + FirstName文字列 +
// ":lastname=" + LastName文字列 +
// ":phone=" + Phone値 + ":comment=" + Comment文字列 + "[ENDBODY]"
//
//
// 電子メールハンドラクラスとなるための条件
// ・globalクラス
// ・Messaging.InboundEmailHandlerを実装
// - handleInboundEmail()を実装
global class CandidateEmailHandler implements Messaging.InboundEmailHandler {
// 電子メール到着時に呼び出されるメソッド
// 引数: email 受信した電子メールデータ
// envelope 受信した電子メールのエンベロープ
global Messaging.InboundEmailResult handleInboundEmail(
Messaging.InboundEmail email, Messaging.InboundEnvelope envelope) {

// 戻り値インスタンス生成
Messaging.InboundEmailResult result =
new Messaging.InboundEmailresult();

// デバッグメッセージ用文字列変数
String myErr;


try{
// メール本文を格納する文字列変数
String theBody;

// 属性別に切り出された文字列を格納するリスト
List<String> fieldList = new List<String>();

// 受信メールがテキスト形式
if (email.plainTextBody != null){
// デバッグメッセージ変数に書き込み
myErr = 'plainTextBody=' + email.plainTextBody;
// メール本文を格納
theBody = email.plainTextBody;

// 受信メールがテキスト形式ではない(HTML形式)
} else {
myErr = 'htmlBody=' + email.htmlBody;
theBody = email.htmlBody;
}

// 本文を属性別に分解する
// 開始文字列、終了文字列にはさまれた文字列を切り出す
theBody = theBody.substring(
theBody.indexOf('[STARTBODY]') + 11,
theBody.indexOf('[ENDBODY]'));
// セパレータ":"で分解しリスト化
fieldList = theBody.split(':',0);

// 分解元文字列と分解後をデバッグメッセージに追加
myErr += '\ntheBody=' + theBody;
myErr += '\nfieldList=' + fieldList;

// 属性名、属性値のMap化
Map<String,String> fieldMap = new Map<String,String>();

// 分解リストループ
for(String field : fieldList){
// "="で分割すると2つになる場合
if (field.split('=',0).size() == 2){
// 最初の要素をキー
// 2番目の要素を値として格納
fieldMap.put(
field.split('=',0)[0],
field.split('=',0)[1]);
}
}


// 応募者レコード
Candidate__c candidate;
try{
// 重複チェックのためにLastNameと電子メール
// アドレスが同じレコードを抽出
// 今回のアプリの仕様上LastNameとEmailで一意
// となっているはずなので存在しても1件のみ
// の想定であるためListではない
candidate =
[select id,
first_name__c,
last_name__c,
phone__c,
email__c,
ownerid
from candidate__c
where last_name__c =
:fieldMap.get(
'lastname')
and email__c =
:email.fromAddress];
// 既に存在する場合は、ほかの属性値更新のため
// このcandidate値を継続使用する
// 存在しない場合もQueryExceptionを発生させる
} catch (QueryException qe){
// candidateが存在しない場合は重複なしなので
// 新規作成する
if (candidate == null){
candidate = new Candidate__c();
}
// 処理継続させたいので再スローしない
}

// FirstName値上書き
candidate.first_name__c =
fieldMap.containsKey('firstname') ?
// 日本語が混じることも考えられるので
// EncodingUtilクラスを使ってエンコード
// する
EncodingUtil.urlDecode(
fieldMap.get('firstname'),'UTF-8') :
// nullの場合更新対象にならない
null;

// LastName値上書き
candidate.last_name__c =
fieldMap.containsKey('lastname') ?
EncodingUtil.urlDecode(
fieldMap.get('lastname'),'UTF-8') :
null;

// Phone値上書き
candidate.phone__c =
fieldMap.containsKey('phone') ?
EncodingUtil.urlDecode(
fieldMap.get('phone'),'UTF-8') :
null;
// Email値上書き
// 電子メールオブジェクトからなのでEncode不要
candidate.email__c = email.fromAddress;

try{
// 新規追加の場合
// (IDがまだ割り当てられていない)
if (candidate.id == null) {
// DBへ1件新規追加
insert candidate;
// 更新の場合
} else {
// 指定行を1件更新
update candidate;
}

// DB例外発生時処理
} catch (DMLException e){

// Error_Logレコードに例外情報を格納する
Error_Log__c log = new Error_Log__c();
log.trace__c = e.getTypeName() + '\n' +
e.getCause() + '\n' + e.getMessage() +
'\n\nfieldMap=' + fieldMap +
'\n\nmyErr=' + myErr;
insert log;

// 戻り値の成功フラグを偽にして返却
result.success = false;
return result;
}

// Comment欄データが存在する場合
// 応募者レコードのメモとして追加
// 「メモ&添付ファイル」関連レコードに格納される
if (fieldMap.containsKey('comment')){
// Note(メモ)レコード生成
Note cNote = new Note();
// Comment値をエンコードして格納
cNote.body = EncodingUtil.urlDecode(
fieldMap.get('comment'),'UTF-8');
cNote.parentId = candidate.id;
// タイトル文字列を格納
cNote.title = 'CandidateEmail:' + System.now();
try{
// Noteレコード格納
insert cNote;

// DB例外発生時処理
} catch (DMLException e){
// Error_Logレコードに書き込み
Error_Log__c log = new Error_Log__c();
log.trace__c = e.getTypeName() + '\n'+
e.getCause() + '\n' +
e.getMessage() +
'\n\nfieldMap=' + fieldMap +
'\n\nmyErr=' + myErr;
insert log;

// 戻り値の成功フラグを偽にして返却
result.success = false;
return result;
}
}

// バイナリorテキスト添付ファイル群を格納するリストを
// 新規生成
List<Attachment> attachments = new List<Attachment>();
// バイナリ添付ファイル群が存在する場合
if (email.binaryAttachments != null){
// バイナリ添付ファイルデータループ
for (Messaging.InboundEmail.BinaryAttachment
emailAttachment:
email.binaryAttachments){

// 新規添付ファイル格納オブジェクト生成
Attachment attachment =
new Attachment();
// 親IDとして応募者レコードを指定
attachment.parentId = candidate.id;
// データとしてそのまま格納
attachment.body = emailAttachment.body;
// ファイル名を指定
attachment.name =
emailAttachment.fileName;
// リストへ追加
attachments.add(attachment);
}
}

// テキスト添付ファイル群が存在する場合
if (email.textAttachments != null){
// テキスト添付ファイルデータループ
for (Messaging.InboundEmail.TextAttachment
emailAttachment:email.textAttachments){
// 新規添付ファイル格納オブジェクト生成
Attachment attachment =
new Attachment();
// 親IDとして応募者レコードを指定
attachment.parentId = candidate.id;
// データとしてBLOB型化して格納
attachment.body =
blob.valueOf(
emailAttachment.body);
// ファイル名を指定
attachment.name =
emailAttachment.fileName;
// リストへ追加
attachments.add(attachment);
}
}

// 格納対象となる添付ファイルが存在する場合
if (attachments.size() > 0){
try{
// 添付ファイル新規追加
insert attachments;

// DB例外発生時処理
} catch (DMLException e){
// Error_Logレコードに例外情報を格納
Error_Log__c log = new Error_Log__c();
log.trace__c = e.getTypeName() + '\n' +
e.getCause() + '\n' +
e.getMessage() +
'\n\nfieldMap=' + fieldMap +
'\n\nmyErr=' + myErr;
insert log;
// 戻り値の成功フラグを偽にして返却
result.success = false;
return result;
}
}

// DB以外の例外が発生した場合
} catch (Exception e){
// Error_Logレコードに例外情報書き込み
Error_Log__c log = new Error_Log__c();
log.trace__c = e.getTypeName() + '\n' + e.getCause() +
'\n' + e.getMessage() + '\n\nmyErr=' + myErr;
insert log;
// 戻り値の成功フラグを偽にする
result.success = false;
// エラーメッセージも戻り値オブジェクトへ記述
result.message = e.getTypeName() + '\n' +
e.getCause() + '\n' + e.getMessage() +
'\n\nmyErr=' + myErr;
// 戻り値オブジェクト返却
return result;
}
// 正常終了の場合、生成したままの戻り値オブジェクトを返却
return result;
}
}





やはりメールアドレスを自由に設定できない点が

ちょっとイヤではあるが

外部メールサーバなしで出来る利点がある。

2011年5月3日火曜日

JavaScriptでSalesforce上のApexコード(SOAP API)を呼び出す

Force.com上のApexコードをSOAP APIとして公開するのは
とても簡単だ。

以下のコードは
Force.com開発者コースでもらった
サンプルコードですが、
見てもらって分かるとおり
すでにApexコードが実装されているのであれば
あとはWSDLをダウンロードできる状態にするために
クラス委定義にglobalキーワードを加え
公開するwebserviceキーワードを追加するだけだ。

こうすれば
設定画面のApexクラス一覧からWSDLがダウンロードできるようになる。




// Apex Code 演習8-1 カスタムSOAP Webサービス(1)
// EmployeeReferralタブのVisualforce画面から入力しsaveボタン押下
// →CandidateKeyWebService#submitEmployeeReferral()をSOAP API呼び出し
//   →CandidateレコードとJobApplicationレコードを格納する
//
// Webサービスとして呼び出すクラスはglobalでなくてはならない
global class CandidateKeyWebService {

 // Webサービス呼び出しメソッドは webServiceキーワードをつける
 // EmployeeReferralタブ上のページから呼び出され
 // 応募者(Candidate__c)レコードと申込(JobApplication__c)レコードを
 // 新規追加する
 // ただしすでに存在する応募者の場合は応募者レコードは追加しない
 //
 // 引数: posId 申込レコード上の募集職種ID(参照項目)
 //  c 応募者レコード
 // 戻り値: 真:成功、偽:失敗
 webService static Boolean submitEmployeeReferral(
 String posId, Candidate__c c){

  // 応募者レコード作成フラグ
  boolean cCreate = true;

  // 項目Emailが設定されている場合、重複チェックをかける
  if (c.Email__c != null){
   // LastNameとEmailを連結した文字列を作成
   String uKey =
    c.Last_Name__c.toLowerCase() +
    c.Email__c.toLowerCase();

   // unique_key(自動計算される項目)と突き合わせ、
   // 1件以上存在すれば重複
   if ([select count()
    from Candidate__c
    where unique_key__c = :uKey] >= 1){

    // 既に存在する→作成フラグを偽に変更
    cCreate=false;
    // 引数cを既存レコードのForce.com IDに変更
    // limit 1を付けると最初の1件をとることができる
    c = [select Id
     from Candidate__c
     where unique_key__c = :uKey limit 1];
   }
  }

  // エラーフラグ
  boolean err = false;

  // 応募者レコードを新規作成する必要がある場合
  if (cCreate){
   try{
    // 新規追加
    insert c;
    // DBエラーの場合
   } catch (System.DmlException e) {
    // エラーフラグを真にする
    err = true;
    // デバッグログへ例外情報を記述
    System.debug(
     'error bulk inserting new candidate record');
    for (Integer k = 0; k < e.getNumDml(); k++) {
     System.debug(e.getDmlMessage(k));
    }
   }
  }

  // 応募者レコード新規追加に成功、もしくは既存レコードが存在し
  // 新規追加操作をしていない場合
  //  応募者レコード新規格納失敗の場合に申込レコードのみを追加
  //  してしまうとDBの整合性が荒れてしまうので
  if (!err){
   // 新規申込レコードを作成
   Job_Application__c j = new Job_Application__c();

   j.Status__c = 'Open'; // Status値をOpenに設定
   j.Stage__c = 'New'; // Stage値をNewに設定
   j.Position__c = posId; // Position値を引数posIdに設定
   j.Candidate__c = c.Id; // Candidate値を応募者IDに設定

   try{
    // 申込レコードを新規追加する
    insert j;
   // DBエラーの場合
   } catch (System.DmlException e) {
    // デバッグログに例外情報を格納
    System.debug(
     'error bulk inserting new job application');
    for (Integer k = 0; k < e.getNumDml(); k++) {
     System.debug(e.getDmlMessage(k));
    }

    // 本来はエラーフラグを立て後続のハンドリング
    // 処理判定に使用する
   }
  }

  // エラーがない場合
  if (!err) {
   // 真を返却
   return true;

  // エラーが発生した場合
  } else {
   // 本来はエラーハンドリング処理をここに書く
   // たとえばErrorLogレコードに格納する、など

   // 偽を返却
   return false;
  }
 }
}




できあがったWSDLをAXIS2のwsdl2javaへかませば
Javaから呼び出せるし
VisualStudioにかませれば
そっくりAPI呼び出し準備が完了する。

ただこの研修では
SOAPクライアント側をSalesforceが用意する
JavaScriptライブラリを利用して使用している
サンプルを紹介している。

Visualforceページになっているが
呼び出ししている
SOAP API呼び出しは
まるまるJavaScriptなので
apexタグを知らなくても読めると思う。




<!-- Apex Code 演習8-1 カスタムSOAP Webサービス(1) -->
<!-- EmployeeReferralタブのVisualforce画面から入力しsaveボタン押下 -->
<!-- →CandidateKeyWebService#submitEmployeeReferral()をSOAP API呼び出し -->
<!-- →CandidateレコードとJobApplicationレコードを格納する -->

<!-- controllerは定義されていない→JavaScriptからSOAP APIを呼び出している -->
<apex:page >
//Note that if the org password is NOT 'password1' that you will need to change it twice below!

<!-- Visualforceは終了タグを明記しないとエラーになる -->
<script type="text/javascript" src="/js/functions.js"></script>
<script src="/soap/ajax/17.0/connection.js"></script>
<script src="/soap/ajax/17.0/apex.js" type="text/javascript"></script>

<script type="text/javascript">
 <!-- 入力チェックを実施、すべてOkならばsave()を実行 -->
 function validate(){
  <!-- connection.js上のメソッドを呼び出し -->
  <!-- $Api/$APIはSalesforce上のメタデータを入手する際に -->
  <!-- 使用するキーワード、ほかにも$Referenceなどがある -->
  sforce.connection.init(
   "{!$API.Session_ID}", "{!$Api.Partner_Server_URL_140}");
  <!-- 判定結果格納用変数 -->
  var ok2Go = true;

  <!-- preSelectorリストボックスの選択値を取得 -->
  var ps = document.getElementById("posSelector");
  if (ps.options.length != 0){
   var posId = ps.options[ps.selectedIndex].value;
  }

  <!-- 選択値がnullもしくは空文字の場合 -->
  if ((posId == null)||(posId == "")) {
   <!-- ダイアログでアラート表示し判定結果をNGに -->
   alert("A position must be selected");
   ok2Go = false;
  }

  <!-- form"refForm"内のID値"lastname"の値を取得 -->
  var ln = document.forms["refForm"].elements["lastname"].value;
  <!-- lastname値が空文字もしくはnullの場合 -->
  if ((ln == "")||(ln == null)){
   <!-- ダイアログでアラート表示し判定結果をNGに -->
   alert("Last name is required");
   ok2Go = false;
  }

  // form"refForm"内のID値"email"の値を取得 -->
  var email = document.forms["refForm"].elements["email"].value; 
  <!-- email値が空文字もしくはnullの場合 -->
  if ((email == "")||(email == null)){
   <!-- ダイアログでアラート表示し判定結果をNGに -->
   alert("Email is required");
   ok2Go = false;
  }

  <!-- 判定結果OKの場合 -->
  if (ok2Go) {
   <!-- save()を呼び出しCandidateレコードを追加 -->
   <!-- 引数posIdは-->
   save(posId);

  <!-- 判定結果NGの場合 -->
  } else {
   <!-- 戻り値falseを返す -->
   return false;
  }
 }

 <!-- ※使用されていない※ -->
 <!-- グローバルオブジェクトの情報を入手する -->
 <!-- apexドキュメントをdescribeGlobalで検索→サンプルコード参考の事 -->
 function doDescribeGlobal(){
  try{
   var dgResults = sforce.connection.describeGlobal();
  } catch (e) {
   sforce.debug.open();
   sforce.debug.log(e);
  }

  return dgResults;
 }

 <!-- ※使用されていない※ -->
 <!-- グローバルSObjectの情報を入手する -->
 <!-- apexドキュメントをdescribeSObject検索→サンプルコード参考の事 -->
 function doDescribeSObject(entity){
  try{
   var dso = sforce.connection.describeSObject(entity);
  }catch (e) {
   sforce.debug.open();
   sforce.debug.log(e);
  }
  return dso;
 }

 <!-- 引数で与えられたIDのリストボックス内のオプションをクリア -->
 function clearSelect(name){
  <!-- IDからオブジェクトを入手 -->
  var sel = document.getElementById(name);

  <!-- 空になるまでループ -->
  while (sel.length > 0){
   <!-- 先頭のオプションを削除 -->
   sel.remove(0);
  }
 }

 <!-- Department欄が選択されたら呼び出される -->
 <!-- Department欄の状態によってposSelectorリストボックスを更新する -->
 function deptChanged(department){
  <!-- connection.jsを使用してコネクションを張る -->
  sforce.connection.init("{!$API.Session_ID}", "{!$Api.Partner_Server_URL_140}");

  <!-- IDが"posSelector"のリストボックス(Open Positions)を -->
  <!-- クリア -->
  clearSelect("posSelector");

  <!-- 選択された部門(Department)に合致する募集職種レコード -->
  <!-- を取得するクエリ文字列構築 -->
  var qStr = "select Id, Name, Location__c, Department__c, Type__c, Status__c from Position__c where Status__c='Open' and Department__c = '" + department + "'";

  try{ 
   <!-- クエリを実行し結果を取得 -->
   var queryResults = sforce.connection.query(qStr);
   <!-- 1件以上取得できたら'records'レコード情報を -->
   <!-- 配列として取得 -->
   if (queryResults != null){
    if (queryResults.size > 0){ 
     var records = queryResults.getArray('records'); 
    }
   }

  <!-- 例外発生時処理 -->
  } catch (e){
   <!-- Salesforceデバッグログへ例外情報を書き込む -->
   sforce.debug.open();
   sforce.debug.log(e);
  }

  <!-- IDが"posSelector"であるオブジェクトを取得 -->
  var ps = document.getElementById("posSelector");

  <!-- 結果が存在する場合 -->
  if (records != null){
   <!-- 結果レコードの全件ループ -->
   for (var i=0; i<records.length; i++) {
    <!-- JavaScriptのDOMを使ってoptionタグを -->
    <!-- 新規生成 -->
    var optNew = document.createElement('option');

    <!-- value属性値としてPosition__cの -->
    <!-- Force.com ID値を設定 -->
    optNew.value = records[i].Id;

    <!-- text属性値としてPosition__cのNameと -->
    <!-- Location__c、Type__cを連結した文字列 -->
    <!-- を設定 -->
    optNew.text = records[i].Name + " : " + records[i].Location__c + " : " + records[i].Type__c;

    <!-- IEのDOM方言対応のためのtry-catch句 -->
    try {
     <!-- posSelectorリストボックスの -->
     <!-- オプションとして格納 -->
     ps.add(optNew, null); // IE以外の場合
    <!-- 例外発生時(IE)処理 -->
    } catch(ex) {
     <!-- IEの場合は引数1つらしい -->
     ps.add(optNew); // IE の場合
    }
   }
  }
 }

 <!-- 応募者レコードを新規格納する関数 -->
 <!-- 引数posId: 応募者レコードに紐づける募集職種レコードID -->
 function save(posId){

  <!-- Candidate__c(応募者)レコードを新規生成
  var candidate = new sforce.SObject("Candidate__c");
  <!-- firstname値を応募者レコードのFirst_Name__cへ設定 -->
  candidate.First_Name__c = document.forms["refForm"].elements["firstname"].value; 
  <!-- lastname値を応募者レコードのLast_Name__cへ設定 -->
  candidate.Last_Name__c = document.forms["refForm"].elements["lastname"].value;
  <!-- phone値を応募者レコードのPhone__cへ設定 -->
  candidate.Phone__c = document.forms["refForm"].elements["phone"].value;
  <!-- mobile値を応募者レコードのMobile__cへ設定 -->
  candidate.Mobile__c = document.forms["refForm"].elements["mobile"].value;
  <!-- email値を応募者レコードのEmail__cへ設定 -->
  candidate.Email__c = document.forms["refForm"].elements["email"].value;

  <!-- Webサービスを呼び出し、応募者レコードを格納している -->
  try {
   <!-- Apexクラス(WebService): CandidateKeyWebService -->
   <!-- 呼び出すメソッド名:    submitEmployeeReferral -->
   <!-- 渡す引数:              募集職種レコードID -->
   <!--                         応募者レコード -->
   <!-- 戻り値:                成功→true -->
   var success = sforce.apex.execute("CandidateKeyWebService","submitEmployeeReferral",{a:posId,b:candidate});

   <!-- 値が返ってきた場合 -->
   if (success != null) {
    <!-- DB格納成功の場合 -->
    if (success == "true") {
     <!-- DOMを使ってBODYタグ内を下記 -->
     <!-- HTMLに書き換える-->
     document.body.innerHTML = "<h1>Referral Successfully Submitted. Thank You!</h1><br/><br/><br/><br/>";

    <!-- DB格納失敗の場合 -->
    } else {
     <!-- DOMを使ってBODYタグ内を下記 -->
     <!-- HTMLに書き換える-->
     document.body.innerHTML = "<h1>Temporarily unable to submit referrals. Please try again later.</h1><br/><br/><br/><br/>";
    }
   <!-- SOAP API呼び出し失敗の場合 -->
   <!-- デバッグログのトレースをtrueに変更 -->
   } else { sforce.debug.trace = true; }

  <!-- 例外発生時 -->
  } catch(e) {
   <!-- デバッグログのトレースをtrueに変更 -->
   sforce.debug.trace = true;
   <!-- デバッグログに例外情報を書き込む -->
   sforce.debug.open();
   sforce.debug.log(e);
  }
 }
</script>

<!-- formタグ相当 -->
<form id="refForm" name="refForm">

<!-- 申込レコードに設定するための既存のオープンされた募集職種 -->
<!-- レコードを選択させるためのテーブル -->
<table ID="Table1">
<tr>
<th colspan="2">Department</th>
<th colspan="2">Open Positions</th>
</tr>

<tr>
<td>Choose:</td>
<td>
<!-- Department欄のリストボックス -->
<!-- 選択状態変更→deptChanged関数呼び出し -->
<!-- 引数には選択ぽうしょんのテキスト値が渡される -->
<select id="deptSelector" type="select-one" size="1" NAME="deptSelector" onchange="javascript:deptChanged(this.options[this.selectedIndex].text);">
<!-- ここは決め打ちリストボックスとして定義されている -->
<option value="none">-- None --</option>
<option value="Engineering">Engineering</option>
<option value="IT">IT</option>
<option value="Finance">Finance</option>
<option value="Support">Support</option>
<option value="Sales">Sales</option>
</select>
</td>
<td>Choose:</td>
<td>
<!-- Open Positions欄のリストボックス -->
<!-- Department欄の値によりオプションが変わる -->
<select id="posSelector" NAME="posSelector" type="select-one" size="1">
<!-- 初期状態ではoptionタグは存在しない -->
</select>
</td>
</tr>

<tr>
<td colspan="4"> <br /></td>
</tr>

<td colspan="1"><h3>Candidate Info:</h3></td>
<td colspan="3"> </td>

<tr>
</tr>

</table>

<!-- 応募者レコードの属性値を入力させるためのテーブル -->
<table id="candidate">

<tr>
<!-- First_Name__cを格納するためのテキストフィールド -->
<td>First Name:</td><td>
<input type="text" id="firstname" /></td>
<!-- Phone__cを格納するためのテキストフィールド -->
<td>Phone:</td>
<td><input type="text" id="phone" /></td>
</tr>

<tr>
<!-- Last_Name__cを格納するためのテキストフィールド -->
<td><font color="#ff2222">Last Name:</font></td>
<td><input type="text" id="lastname" /></td>
<!-- Mobile__cを格納するためのテキストフィールド -->
<td>Mobile:</td>
<td><input type="text" id="mobile" /></td>
</tr>

<tr>
<!-- Email__cを格納するためのテキストフィールド -->
<td><font color="#ff2222">Email:</font></td>
<td><input type="text" id="email" /></td>
<td colspan="2"> </td>
</tr>

<tr>
<!-- レジュメ(ファイル)を格納するためのファイル入力フィールド -->
<!-- ※使用していない※ -->
<td>Resume:</td><td>
<input type="file" id="resume" name="resume" /></td>
<td colspan="2"> </td>
</tr>

<tr>
<td colspan="4"><br /></td>
</tr>

<table cellpadding="0" cellspacing="0" border="0" ID="Table2">
<tr>
<td class="pbTitle"><img src="/s.gif" alt="" width="1" height="1" class="minWidth" title="" /> </td>
<td class="pbButtonb">
<!-- saveボタン:押下→validate()呼び出し -->
<input value=" Save " class="btn" type="button" title="Save" name="save" onclick='javascript:validate();' ID="Button1" />
</td>
</tr>
</table>
</table>
</form>
</apex:page>




このサンプルでは
SOAP APIへ接続するために
Salesforceが用意している
・function.js
・connection.js
・apex.js
を使っている。
これだとWSDLをかませて云々の行程が不要だ。

SOAP APIのほかに
SOQLを文字列加工して実行する方法、
それとDepartment欄が変更された際
即時にForce.com上のDBをアクセスしてとなりの
リストボックス内容を書き換える
なんちゃってAjax(完全非同期ではないので..)の
コードなども読むことが出来る
おいしいつくりになっている。

ご参考まで。

Apexクラスのテストコードサンプル

Force.comの特徴に

本番環境に配置するには

テストコードを必ず書いて75%のコードカバレッジ

を実現しないといけない

という点がある。



なので

Force.comはPaaSとして

JUnitのようなテスティングフレームワークを

自分で持っている。


以下はForce.com開発者コースで使用したサンプル

Apexトリガのサンプルコード

のテストクラスである。



// Apex Code 演習6-1 テストクラスの作成(1)
// HelloWorldTestTriggerが実行され正常に動作しているかをテストする
//
// テストクラスにはアノテーションを指定する
// コード記述上限行数のガバナ制約対象外となる
@isTest
private class HelloWorldTestClass {

// テストメソッドには
// testMethodキーワードを必ず付ける
static testMethod void helloWorldTest(){

// テストデータを準備する

// システム管理者ユーザレコード入手
User adminuser = [SELECT id from User where alias = 'User'];

// 募集職種(Position__c)に20件テストデータを格納する
List<position__c> positions = new List<position__c>();
for (Integer i=0; i < 20; i++){
Position__c p = new Position__c();
p.Name = 'testName' + i;
p.Job_Description__c = 'testDescription' + i;
p.Hiring_Manager__c= adminuser.id;
positions.add(p);
}
// テスト実行
try{
// この行までで消費したガバナ制約は一旦カウント外にする
Test.starttest();
System.debug('Attempting to insert positions');
// 募集職種データ新規追加
// トリガがデータ新規追加により起動される
insert positions;
System.debug('Insert complete');
// テスト結果の評価
// トリガ実行後のデータを取得する
Map<id,position__c> positionMap =
new Map<id,position__c>(positions);
positions =
[select id,
name,
Hello__c
from Position__c
where id IN :positionMap.keySet()];

// 1件づつ結果を評価する
for (Position__c position:positions){
// 募集職種レコードIDがnullでないか
System.assertNotEquals(position.id,null);

// 項目「Hello__c」がWorldになっているか
System.assertEquals(position.Hello__c, 'World');
// デバッグログに
System.debug('position.id=' + position.id);
System.debug('position.name=' + position.name);
System.debug('position.Hello__c=' +
position.Hello__c);
}
} catch (System.DmlException e){
System.debug('We caught a DML exception: ' +
e.getDmlMessage(0));
}
// ガバナ制限リセット終了
Test.stoptest();

// 処理は書いていないが
// テスト中にinsertされたデータはロールバックされる
}
}



とても簡単アノテーション@isTestを

通常のApexクラスにつけるだけ。

メソッドのtestMethodキーワードをつけた箇所をてすとしてくれる。

Force.com IDEにはJUitのTestRunner風のGUIがあるので

テストも簡単。



しかもテストメソッド内でDB操作した内容がチャラになるのがうれしい。



なお

テストクラスで特定のユーザで実行したい場合は

Systemクラスをリファレンスで検索すれば

メソッドが出てくるはず(ど忘れしてしまった..)。

レコードの追加更新時にApexトリガで重複チェックしてはじくサンプル

Apexトリガを使用する場合のよくあるパターンの一つに

重複チェックがある。



Salesforceのオンライン研修でも

レコード(見込み顧客)を追加する前に

ユーザにかならず重複チェックを

手動で行うように指導している。



とはいえ

営業担当者はIT弱者は少なくない(失礼)ので

自動でチェックさせてはじくようにしたい。



以下のサンプルは

Force.com開発者コース演習5-2のサンプルコード

である。





// Apex Code 演習5-2
// 応募者(Candidate)の重複防止
// 応募者(Candidate__c)が新規作成・更新直前→重複チェックApexクラス呼び出し
trigger CandidateKeyTrigger on Candidate__c (before insert, before update) {

// トリガ発生元となった応募者レコード群
private Candidate__c[] newCandidates = Trigger.new;
// 重複チェック
CandidateKey.hasCandidateDuplicates(newCandidates);
}


重複チェックなのでトリガとしては

追加更新の「直前」になるのだけど、

当然チェックしたレコードを格納させたくはない。



重複レコードをはじく方法として

以下のようにaddError()を使用する。

これが最大のポイントである。




// Apex Code 演習5-2
// 注意:
// 応募者の「姓」と「電子メールアドレス」を連結した文字列を使って
// 応募者の"名寄せ"を行っている
// 応募者(Candidate__c)オブジェクトの「unique_key__c」には
// 「LOWER(Last_Name__c & Email__c)」が設定されている
public class CandidateKey {

// 重複チェック処理
// 引数: candidates チェック対象の応募者レコード群
public static void hasCandidateDuplicates(Candidate__c[] candidates){

// unique_key__cとCandidate__cレコードのMap
Map<String,Candidate__c> candidateMap =
new Map<String,Candidate__c>();

// 応募者レコードループ
for(Candidate__c candidate:candidates) {
// 電子メールが設定されている場合
if (candidate.email__c != null){
// Map内の応募者レコードと重複している場合
if(candidateMap.containsKey(
candidate.unique_key__c))
// エラーメッセージを追加
candidate.addError(
'Duplicate LastName + Email found '+
'in batch');
else
// 重複がないのでMapへ追加
candidateMap.put(
candidate.unique_key__c,
candidate);
}
}

// Mapにレコードが存在する場合
if (!candidateMap.isEmpty()){
// Mapのレコードと重複するDB上の応募者レコードを取得
for(Candidate__c[] candidatesCheck:
[select unique_key__c
from Candidate__c
where unique_key__c IN
:candidateMap.keySet()]) {

// フェッチレコードループ
for(Candidate__c candidate:candidatesCheck) {
// 応募者レコードID
String currentCandidateId =
candidate.Id;

// Map内の重複レコードのIDが異なる場合
if(candidateMap.containsKey(
candidate.unique_key__c) &&
(currentCandidateId !=
candidateMap.get(
candidate.unique_key__c).Id)) {

// エラーメッセージ追加
candidateMap.get(
candidate.unique_key__c).
addError('Duplicate LastName' +
' + Email found in salesforc' +
'e(id: ' + candidate.id + ')');
}
}
}
}
}
}





このトリガはbeforeなので、

新規追加や更新される前の処理である。

addError()されたレコードは、

Database.insert() or Database.update()で呼び出された場合は

該当レコードは処理されず

画面呼び出しの場合は

addError()で渡された文字列が

画面の所定位置に表示され

こちらもDBは追加・更新されない。

参照項目をたどって項目を更新するApexトリガのサンプル

Apexトリガで一番最初に考える使い方は

トリガ起動により別のレコードを更新する

というもの。



以下、Force.com開発者コース演習5-1のコード。





// Apex Code 演習5-1
//
// 内定(Offer__c)の新規追加or更新→申込(Job_Application__c)の状況を変更
//
// トリガが複数存在する場合、実行される順番は任意
// ただしSalesforceがいうには現時点での実装はトリガ名の昇順らしい
trigger OfferTrigger on Offer__c (after insert, after update) {

// 申込リスト生成
List<Job_Application__c> jobApps = new List<Job_Application__c>();

// トリガ対象の内定レコードに紐づく申込レコードの名前を取得し
// 内定リストとして入手する
for(List<Offer__c> offers:
[select
// Joinされる場合は
// 内定オブジェクト上に定義されている関連を使用して
// 表現する
// 内定(Offer__c)に関連付けされている親である
// 申込(Job_Application__c)の項目nameをさす
job_application__r.name
// 起点となるオブジェクトを1つだけ指定する
from offer__c
// 内定(Offer__c)のidがトリガ対象レコードのIDに含まれている
where id IN :Trigger.newMap.keySet()]){

// フェッチされた内定リストを1件づつ処理するループ
for(Offer__c offer:offers){

// 内定レコードに紐づく申込レコードのステージを
// Offer Extendedに変更
offer.job_application__r.stage__c = 'Offer Extended';

// 内定レコードに紐づく申込レコードのステータスを
// Holdに変更
offer.job_application__r.status__c = 'Hold';

// 内定リストに追加
jobApps.add(offer.job_application__r);
}
}

// 内定リストに1件以上データがある場合
if (jobApps.size() > 0){
try{
// 内定リストに格納されているレコードを更新し
// 結果を取得する
// 失敗したレコードがある場合でも処理を継続する
// Databaseメソッドの利点の一つとして結果をSaveResult
// クラスインスタンス群として取得できる点にある
Database.SaveResult[] saveResults =
Database.update(jobApps,false);

// エラーとなった件数をカウントする変数
Integer x = 0;
// 更新結果を1件づつ処理するループ
for(Database.SaveResult result:saveResults){
// 更新失敗した場合
if(!result.isSuccess()){
// エラー情報を取得
Database.Error err =
result.getErrors()[0];
// デバッグログへ書き出し
System.debug(
'Unable to update Job Application, ' +
jobApps[x].name +
'. Error:'+ err.getMessage());
}
x++;
}
// 例外発生時処理
} catch (Exception e){
// 例外情報をデバッグログへ出力し、処理継続
System.debug('error updating job applications:' + e);
}
}
}





このサンプルでは

申込と内定の関係を

内定オブジェクト上に申込を格納する参照項目

として定義している。




Salesforce/Force.comでは

DBの関連を

主従関係もしくは参照関係という項目を定義して

実現する。



ここが通常のRDBを使って開発をしている人が

多少違和感を感じてしまう点でもある。



ここでは申し込んだ全員が内定をもらえるわけではないので

主従関係ではなく、参照関係を使用している。


主従関係は強いコンポジション関係になるし
1つのオブジェクトに定義できるカスタム主従関係項目は
数量限定もきびしい(たしか3つだっけ?)されている。

それになにより
ワークフローの項目自動更新は主従関係でないとたどれない。

なので参照項目をたどったトリガ自動更新処理は
Apexトリガを使うことになる。

Apexトリガで共有を動的に操作するサンプル

Apexトリガが利用されるよくある使い方の一つに

ロールや共有ルールでは記述できない

動的な個別の共有設定を行いたい場合

というものがある。



以下のコードは、

Force.com開発者コース研修のDEV541内演習4-1のもの。



研修中ほとんど全員が理解せずにコピペですませてしまう演習だが、

よくよむとApexトリガを使ったApex共有の代表的な実装方法を

教えてくれているので

ここにコメント付きで紹介しておく。



Apexで共有をいじる場合は、

あらかじめ宣言的開発でApex共有の理由で

これから作成する共有ロジックの発生理由を

設定しておく。





// 演習4-1 PositionSharingTrigger
// 募集職種(Position__c)にレコードが新規追加、更新直後に起動する
// Apex共有としてHiring_Manager(採用マネージャ)、Approved_Position(組織全体へ
// の共有:承認された募集職種)が用意されている
trigger PositionSharingTrigger on Position__c (after insert, after update) {

// 募集職種IDと新採用マネージャIDのマップ
Map<ID,ID> posIdToNewMgrIdMap = new Map<ID,ID>(); // 募集職種IDと新部署名のマップ
Map<ID,String> posIdToDeptMap = new Map<ID,String>(); // 承認された募集職種レコードのリスト
List<Position__c> approvedPositions = new List<Position__c>(); // 未承認募集職種レコードのリスト
List<Position__c> nonApprovedPositions = new List<Position__c>();
// クローズもしくは未承認の募集職種IDのセット
Set<ID> removeOrgWideSharingSet = new Set<ID>();
// 未承認な募集職種レコードのリスト
List<Position__c> nonClosedPositions = new List<Position__c>();
// クローズされた募集職種IDのリスト
List<Position__c> closedPositions = new List<Position__c>();

// トリガ発生元となった募集職種レコードループ
for (Position__c position:Trigger.new){
// 更新によってトリガ起動した場合
if (Trigger.isUpdate){
// 採用マネージャが更新された場合
if(position.Hiring_Manager__c !=
Trigger.oldMap.get(position.Id).Hiring_Manager__c){
// 新採用マネージャIDマップへ格納
posIdToNewMgrIdMap.put(
position.Id,
position.Hiring_Manager__c);
}

// 部署が更新された場合
if(position.Department__c !=
Trigger.oldMap.get(position.Id).Department__c){
// 新部署名マップへ格納
posIdToDeptMap.put(
position.Id,
position.Department__c);
}
}

// 募集職種のステータスがクローズになっていない場合
if (position.Status__c != 'Closed'){
// 未クローズ募集職種リストへ格納
nonClosedPositions.add(position);
} else {
// クローズ済み募集職種リストへ格納
closedPositions.add(position);
}

// 承認済み募集職種の場合
if ((position.Status__c == 'Open') &&
(position.Sub_Status__c=='Approved')){
// 承認済み募集職種リストへ格納
approvedPositions.add(position);
} else {
// 未承認/クローズ募集職種IDセットへ格納
removeOrgWideSharingSet.add(position.id);
// 未承認募集職種リストへ格納
nonApprovedPositions.add(position);
}
}

// 更新によってトリガ起動した場合
if (Trigger.isUpdate){
// 新採用マネージャ情報から不要な共有設定を削除する
PositionSharing.deleteHiringMgrSharing(
posIdToNewMgrIdMap);

// SalarySharingクラスは給与(Salary__c)の共有設定を
// 操作するあらかじめ研修組織に配置されたクラス
// 新採用マネージャ情報から不要な共有設定を削除する
// SalarySharing.deleteHiringMgrSharing(
// posIdToNewMgrIdMap);
// JobAppSharingクラスは申込(Job_Application__c)の
// 共有設定を操作するあらかじめ研修組織に配置された
// クラス
// 新採用マネージャ情報から不要な共有設定を削除する
// JobAppSharing.deleteHiringMgrSharing(
// posIdToNewMgrIdMap);
// 共有理由がDepartment_VP関連である給与(Salary__c)の
// 共有設定を削除する
// SalarySharing.deleteVPSharing(posIdToDeptMap);
}

// 未承認/クローズである
// 募集職種レコードの
// 組織全体、採用マネージャへの参照のみ共有設定を
// 新規追加する
PositionSharing.addSharing(nonApprovedPositions,'Read');

// 承認済みである
// 募集職種レコードの
// 組織全体、採用マネージャへの編集可能共有設定を
// 新規追加する
PositionSharing.addSharing(approvedPositions,'Edit');

// トリガ発生元となった募集職種レコードに紐づく
// 申込レコードの
// 採用マネージャへの編集可能共有設定を
// 新規追加する
// JobAppSharing.createSharing(
// Trigger.new,'Hiring_Manager__c','Edit');
// 未承認/クローズ募集職種に紐づく
// 給与レコードの
// 組織全体、採用マネージャへの参照のみ共有設定を
// 新規追加する
// SalarySharing.addSharing(nonClosedPositions,'Read');
// 承認済み募集職種に紐づく
// 給与レコードの
// 組織全体、採用マネージャへの編集可能共有設定を
// 新規追加する
// SalarySharing.addSharing(approvedPositions,'Edit');
// クローズされた募集職種に紐づく
// 給与レコードの
// 組織全体、採用マネージャへの共有設定すべてを
// 参照のみに変更する
// SalarySharing.closeSharing(closedPositions);

// クローズされた
// 募集職種レコードの
// 組織全体、採用マネージャへの参照のみ共有設定を
// 新規追加する
PositionSharing.addSharing(ClosedPositions,'Read');

// クローズされた
// 募集職種レコードの
// 組織全体、採用マネージャへの共有設定すべてを
// 参照のみに変更する
PositionSharing.closeHiringMgrSharing(closedPositions);

// JobAppSharing.createSharing(
// closedPositions,'Hiring_Manager__c','Read');
// JobAppsharing.closeHiringMgrSharing(closedPositions);

// 承認済みの募集職種レコードに対して
// 組織全体、採用マネージャへの編集可能共有設定の新規追加を行う
// ただし、共有理由はHiring_Magager__cがセットされる
PositionSharing.addSharing(
approvedPositions,'Approved_Position__c','Edit');

// 未承認/クローズ募集職種レコードに張られている
// 組織全体に対する共有設定を削除する
PositionSharing.removeOrgWideSharing(removeOrgWideSharingSet);
}



Apexトリガでは

・操作対象のレコードを抽出してコレクションクラスにまとめる

・共有処理は、古い共有をはずす、新しい共有を張る、の順

・実際の共有操作は別Apexクラスに書く

 - ここではオブジェクト単位で共有操作を行うクラスを分けている

といったことルールづけしておいたほうがよいということか。






// PositionSharing
// 募集職種オブジェクトの共有設定を操作するクラス
// 呼び出し元の共有ルールに従って実行される
public class PositionSharing {

// 暗黙公開グループ「組織全体」を表す Force.com ID
public static final ID ENTIRE_ORG_GROUP_ID_CONST;

// staticブロックを使って組織全体のIDを取得
static {
ENTIRE_ORG_GROUP_ID_CONST = getEntireOrgGroupId();
}

// 組織のIDを取得するメソッド
private static ID getEntireOrgGroupId(){
// Groupは、ロール、公開グループなどのメタデータが格納された
// 標準オブジェクト
// 組織全体をあらわすGroupレコードのタイプにはOrganizationが
// 格納されているので、それを利用して組織全体をあらわすIDを
// 入手している
return [select Id from Group where Type='Organization'].Id;
}

// 不要になった共有設定を削除する
// 引数には募集職種IDと削除対象でない共有先IDのマップを指定する
public static void deleteHiringMgrSharing(
Map<ID,ID> posIdToNewMgrIdMap){
// 1件以上存在する場合
if (posIdToNewMgrIdMap.size() > 0){

// 共有元(募集職種)レコードの採用マネージャが更新され
// 共有理由が「採用マネージャ」である共有設定レコード
// のループ
// リストで受けることでフェッチ回数を効率化させている
for(List<Position__Share> batchOfShares:
[select
// 共有させている個人/グループのID
UserOrGroupId,
// 共有理由
RowCause,
// 共有元レコード
ParentId,
// 共有設定自身のID
Id,
// アクセスレベル(参照のみ、フル..)
AccessLevel
// Position__Shareは、Position__c(募集職種)の
// 共有設定が格納されているオブジェクト
From Position__Share
// 共有元レコードIDが新採用マネージャリストに
// 含まれる
where ParentId IN
:posIdToNewMgrIdMap.keySet()
// 共有の理由が「採用マネージャ」
and  RowCause='Hiring_Manager__c']
){

// 削除対象となる共有設定を格納するリスト
List<Position__Share> deleteShares =
new List<Position__Share>();

// フェッチ結果から共有設定を1件づつ取得する
// ループ
for(Position__Share posShare:batchOfShares){

// 新採用マネージャIDが共有対象IDと
// 異なる場合
if (posIdToNewMgrIdMap.get(
posShare.ParentId) !=
posShare.UserOrGroupId){

// 不要な共有設定なので
// 削除対象リストへ共有設定を
//格納する
deleteShares.add(posShare);
}
}


try {
// DBから削除対象の共有設定を削除
// 成功すると自動的にコミットされる
delete deleteShares;

// 削除処理に失敗した場合 System.DmlExceptionが
// 発生する
} catch (System.DmlException e){
// デバッグログへメッセージ書き出し
System.debug(
'error bulk deleting position shares');

// 失敗したメッセージ群をデバッグログへ
for (Integer k = 0;
k < e.getNumDml(); k++) {
// デバッグログへ書き出し
System.debug(
e.getDmlMessage(k));
}
}
}
}
}

//
public static void closeHiringMgrSharing(
List<Position__c> closedPositions){

// クローズ済み募集職種レコードが1件以上存在する場合
if (closedPositions.size() > 0){
// クローズ済み募集職種IDセットを抽出
Set<ID> closedPositionSet =
new Map<ID,Position__c>(closedPositions).keySet();

// 共有元(募集職種)レコードがクローズ済みでかつ
// 共有理由が「採用マネージャ」である共有設定レコード
// のループ
// リストで受けることでフェッチ回数を効率化させている
for(List<Position__Share> batchOfShares:
[select
// 共有させている個人/グループのID
UserOrGroupId,
// 共有理由
RowCause,
// 共有元レコード
ParentId,
// 共有設定自身のID
Id,
// アクセスレベル(参照のみ、フル..)
AccessLevel
// Position__Shareは、Position__c(募集職種)の
// 共有設定が格納されているオブジェクト
From Position__Share
// 共有元レコードIDがクローズ済み募集職種セット
// に含まれる
where ParentId IN
:closedPositionSet
// 共有の理由が「採用マネージャ」
and RowCause='Hiring_Manager__c']){

// フェッチ結果すべてのアクセスレベルを
// 参照のみに設定
for(Position__Share posShare:batchOfShares){
posShare.AccessLevel = 'Read';
}

try {
// 共有設定すべてを一括更新
// 1件でも失敗した場合は全件
// ロールバックされる
// Database.update(batchOfShares, true)
// 相当の処理
update batchOfShares;

} catch (System.DmlException e){
// デバッグログへメッセージ書き出し
System.debug(
'error bulk updating position shares');
for (Integer k = 0; k < e.getNumDml(); k++) {
System.debug(e.getDmlMessage(k));
}
// 処理は継続する
}
}
}
}

// 組織全体、採用マネージャへの共有設定の新規追加を行う
// 引数:募集職種レコードの配列、
// 共有理由(Hiring_Manager__cなど)、
// アクセスレベル(参照のみ、フルなど)
public static void addSharing(
Position__c[] positions, String apexType, String accessLevel){
// デバッグログへメッセージ出力
System.debug('creating bulk Position__Share records');
// 新規追加する共有設定を格納するリスト
List<Position__Share> posShares = new List<Position__Share>();

// 引数として受領した募集職種リストから1件づつ取り出すループ
for (Position__c p:positions){
// 新規共有設定を生成
Position__Share ps = new Position__Share();

// 共有元IDとして募集職種レコードのIDを代入
ps.ParentId = p.Id;

// 引数で指定された共有理由がHiring_Magager__cの場合
if (apexType == 'Hiring_Manager__c'){
// 共有対象IDとして募集職種レコード上の
// 項目「採用マネージャ(Hiring_Manager)」の値
// を代入
ps.UserOrGroupId = p.Hiring_Manager__c;
// Position__Share(募集職種の共有設定オブジェ
// クト)の共有理由「Hiring_Manager__c」を共有
// 理由として代入
ps.RowCause =
Schema.Position__Share.RowCause.Hiring_Manager__c;
// 引数で指定された共有理由がApproved_Position__cの場合
} else if(apexType =='Approved_Position__c'){
// 共有対象IDとして組織全体IDを代入
ps.UserOrGroupId = ENTIRE_ORG_GROUP_ID_CONST;
// Position__Share(募集職種の共有設定オブジェ
// クト)の共有理由「Approved_Position__c」を
// 共有理由として代入
ps.RowCause =
Schema.Position__Share.RowCause.Approved_Position__c;
}

// 引数で渡されたアクセスレベルを格納
ps.AccessLevel = accessLevel;
// デバッグログに新規追加するレコードを出力
System.debug('this is ps: ' + ps);
// 新規追加する共有設定リストへ追加
posShares.add(ps);
}

// 新規追加対象の共有設定が1件以上存在する場合
if (posShares.size() > 0){
try {
// 格納
System.debug(
'performing bulk Position__Share insert');
// バルク操作メソッドを使って新規格納
// 新規追加できるレコードは格納してしまい、
// 処理後エラーになったレコードがあっても
// 処理継続する
// insert posShares(スタンドアロンステート
// メントという)の場合、1件でもエラーに
// なると成功しているレコードも含め全件
// ロールバックがかかる
// 第2引数がtrueの場合、insert posSharesと同じ
// 処理になる
Database.insert(posShares,false);

// 一部の更新レコードが失敗した場合
} catch (System.DmlException e) {
// デバッグログ出力
System.debug(
'error bulk inserting position shares');
// DmlExceptionのエラーメッセージを
// デバッグログに出力し、処理継続
for (Integer k = 0; k < e.getNumDml(); k++) {
// Process exception here
System.debug(e.getDmlMessage(k));
}
}
}
}

// Apexコードでもオーバロードメソッドは作成可能
// 組織全体、採用マネージャへの共有設定の新規追加を行う
// ただし、共有理由はHiring_Magager__cがセットされ、
// アクセスレベルは第2引数で渡された値で設定される
public static void addSharing(Position__c[] positions,String accessLevel){
// 採用マネージャが設定されている募集職種リストの新規作成
List<Position__c> hiringMgrPositions = new List<Position__c>();

// 引数で渡された募集職種リストから1件づつレコードを抽出する
// ループ
for(Position__c position:positions){
// 項目「採用マネージャ」が空ではない場合
// 採用マネージャが設定されている募集職種リストへ追加
if(position.hiring_manager__c != null)
hiringMgrPositions.add(position);
}

// 採用マネージャが設定されている募集職種が1件以上存在する場合
if (hiringMgrPositions.size() > 0)
// 組織全体、採用マネージャへの共有設定の新規追加を行う
// 共有理由:Hiring_Magager__c、
// アクセスレベル:第2引数で渡されたアクセスレベル
PositionSharing.addSharing(
hiringMgrPositions,
'Hiring_Manager__c',
accessLevel);
}

//
public static void removeOrgWideSharing(
Set<ID> removeOrgWideSharingSet){

// 削除対象募集職種IDリストが1件以上の場合
if (removeOrgWideSharingSet.size() > 0){
// go through the list of related sharing records in batch and delete any Approved_Position__c
// sharing records

//
for(List<Position__Share> batchOfShares:
[select
// 共有させている個人/グループのID
UserOrGroupId,
// 共有理由
RowCause,
// 共有元レコード
ParentId,
// 共有設定自身のID
Id,
// アクセスレベル(参照のみ、フル..)
AccessLevel
// Position__Shareは、Position__c(募集職種)の
// 共有設定が格納されているオブジェクト
From Position__Share
// 共有先のIDが引数で渡された削除対象IDリスト
// に含まれている
where ParentId IN
:removeOrgWideSharingSet
// 共有理由がApproved_Position__c
and RowCause='Approved_Position__c'
// 共有対象のIDが「組織全体」
and UserOrGroupId =
:ENTIRE_ORG_GROUP_ID_CONST]){
try {
// データベースから削除
// 削除できるものは実行し、
// できなかったレコードがあれば処理終了
// 後も継続する
Database.delete(batchOfShares,false);

} catch (System.DmlException e){
// 例外に格納されたエラーメッセージを
// すべてデバッグログへ出力
System.debug('error bulk deleting position shares');
for (Integer k = 0; k < e.getNumDml(); k++) {
// Process exception here
System.debug(e.getDmlMessage(k));
}
// 処理は継続
}
}
}

}
}





共有操作は<オブジェクト名>__Shareという暗黙オブジェクト

を操作する。



ポイントとしては、

共有操作はSystem権限で行うので

クラスにwith sharingをつけていないこと。

ClaudeをOpenAI O1のように思考させるDifyサンプルを試す

 Difyの「探索」タブにはさまざまなサンプルが載っており、その1つに「Thinking Claude (OpenAI O1 Alternative)」というものがある。   このサンプルがどういうものか、未だに自分でも解けない以下の問題をためしに聞いてみることにした。 『人類の...