2012年5月18日金曜日

Spring Social - Connection の管理

前回の『Facebook に接続』で、Spring Social を使い Facebook との接続(Connection)を確立しました。今回は、確立した Connection をローカルユーザーアカウントと紐付けて DB で管理する部分を作ります。

ConnectionRepository と UsersConnectionRepository
前回のような直球型では、アプリケーションを起動する度に facebook との“OAuth2 ダンス”が必要になります。一度確立した Connection オブジェクトを永続化し、再利用する仕組みを用意すれば、そうした手間がなくなります。

Spring Social リファレンスの“2.3 Persisting connections”を読むと Connection 管理の中核は、永続化された各(ローカル)ユーザーの Connection オブジェクトに対するデータアクセスインターフェースを提供する ConnectionRepository と、Connection のグローバルストアーとして機能する UsersConnectionRepository であることがわかります。

DB(MySQL)側の準備
今回は、UsersConnectionRepository の実装クラスである JdbcUsersConnectionRepository を使用します。そのためには DB の準備が必要です。“2.3.1 JDBC-based persistence”に倣い、以下のスキーマを考えました。
create table UserConnection (
  userId varchar(127) not null,
  providerId varchar(64) not null,
  providerUserId varchar(64),
  rank int not null,
  displayName varchar(127),
  profileUrl varchar(512),
  imageUrl varchar(512),
  accessToken varchar(127) not null,
  secret varchar(255),
  refreshToken varchar(127),
  expireTime bigint,
  constraint UserConnection_pkc primary key(userId, providerId, providerUserId),
  constraint UserConnection_idx1 unique index(userId, providerId, rank)
  );

@Configuration クラス
4. Connecting to Service Providers”に、Spring Social に標準添付されている ConnectController を利用するための設定手順が記されています。この手順を若干アレンジして、前回作ったコードでも利用できるようにしてみます。とは言ってもほぼ書き写しですが...

SocialConfig.java
まずは外枠の部分です。
package wrider.fb.test.config;

import javax.sql.DataSource;

import org.slf4j.Logger;
    :
import org.springframework.social.facebook.connect.FacebookOAuth2Template;

@Configuration
@ComponentScan(basePackages="wrider")
@ImportResource("/WEB-INF/applicationContext.xml")
@PropertySource("classpath:wrider/fb/test/config/facebook.properties")
public class SocialConfig {
  final static Logger logger = LoggerFactory.getLogger(SocialConfig.class);
    :
}
@Configuration でこれが Spring コンテナーを構成するクラスであることを指示しています。@ComponentScan@ImportResource は、それぞれ <component-scan/>, <import/>と同様に機能するアノテーションです。@PropertySource で、Environment インターフェースに食わせる .properties ファイル(facebook.properties)の所在を指示しています。

続いて諸々の bean の定義です。
@Autowired
  private Environment environment;
  
  @Autowired
  DataSource pooledDataSource;
  
  @Bean
  public TextEncryptor textEncryptor() {
    return Encryptors.noOpText();
  }
  
  @Bean
  public FacebookOAuth2Template facebookOAuth2Template() {
    return new FacebookOAuth2Template(
        environment.getProperty("facebook.clientId"), environment.getProperty("facebook.clientSecret"));
  }
  
  @Bean
  public FacebookConnectionFactory facebookConnectionFactory() {
    return new FacebookConnectionFactory(
        environment.getProperty("facebook.clientId"), environment.getProperty("facebook.clientSecret"));
  }
  

DataSource には『Hibernate でデータアクセス(3)』で定義した“pooledDataSource”を DI しています。本体コード(FacebookTestController.java)での記述を簡素化するために FacebookOAuth2Template および FacebookConnectionFactory のビーンを定義しています。この中に

environment.getProperty("facebook.clientId"), environment.getProperty("facebook.clientSecret"));

という記述がありますが、ここで @PropertySource で指示した場所にあるファイルから値を読み込み設定しています。そのファイル(facebook.properties)の内容は以下のような感じです。

facebook.clientId=123456...
facebook.clientSecret=11aa22bb33cc..

続く ConnectionFactoryLocatorUsersConnectionRepository のビーン定義は、ほぼリファレンスの通りです。
@Bean
  @Scope(value="singleton", proxyMode=ScopedProxyMode.INTERFACES)
  public ConnectionFactoryLocator connectionFactoryLocator() {
    ConnectionFactoryRegistry registry = new ConnectionFactoryRegistry();
    registry.addConnectionFactory(facebookConnectionFactory());
    return registry;
  }
  
  @Bean
  @Scope(value="singleton", proxyMode=ScopedProxyMode.INTERFACES)
  public UsersConnectionRepository usersConnectionRepository() {
    logger.info("**UsersConnectionRepository");
    return new JdbcUsersConnectionRepository(this.pooledDataSource, connectionFactoryLocator(), textEncryptor());
  }

次の ConnectionRepository と Facebook の scope はユーザーからのリクエストに応じて個別に生成されなければならないので "request" になります。

facebook ビーンでは、connectionRepository の findPrimaryConnection()メソッドで Connection の有無を調べ、ローカルユーザーに紐づいた Connection が DB に存在すれば facebook の API を返します。存在しなかった場合、つまり、当該ローカルユーザーが始めて facebook に接続を試みる場合は 未認証の FacebookTemplate を生成し返します。
@Bean
  @Scope(value="request", proxyMode=ScopedProxyMode.INTERFACES)
  public ConnectionRepository connectionRepository() {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    if (authentication == null) {
      throw new IllegalStateException("Unable to get a ConnectionRepository: no user signed in");
    }
    logger.info("**ConnectionRepository: {}", authentication.getName());
    return usersConnectionRepository().createConnectionRepository(authentication.getName());
  }
  
  @Bean
  @Scope(value="request", proxyMode=ScopedProxyMode.INTERFACES)
  public Facebook facebook() {
    Connection facebook = connectionRepository().findPrimaryConnection(Facebook.class);
    if (facebook != null) {
      if (!facebook.test()) {
        facebook.refresh();
        logger.info("**facebook connection has been refleshed");
      } else {
        logger.info("**facebook connection alived");
      }
      return facebook.getApi();
    }
    else {
      logger.info("**return FacebookTemplate");
      return new FacebookTemplate();
    }
  }

SocialExceptionResolver.java(抜粋)
FacebookTemplate を返されたユーザーが OAuth2 認証を必要とする処理(例えば、Profile の取得など)を呼び出した場合、NotAuthorizedException が発生します。これの処理方法として例えば、『例外処理』の要領で HandlerExceptionResolver の実装クラスで捕まえ、『Facebook に接続』で作成した FacebookTestController クラスの singnin()メソッドに飛ばす、といった方法が考えられます。
public class SocialExceptionResolver implements HandlerExceptionResolver,
    Ordered {
  final static Logger logger = LoggerFactory.getLogger(SocialExceptionResolver.class);

  private int order = Integer.MAX_VALUE;
  
  public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
    ModelAndView mav = new ModelAndView();
    if (ex instanceof NotAuthorizedException) {
      logger.info("<<NotAuthorizedException>>");
      mav.setViewName("redirect:/facebooktest/signin.html");
      return mav;
    }
    else {
      return null;
    }
  }
  
  public int getOrder() {
    return this.order;
  }
}

ここで request scope のビーンに関する注意点が一つ。もし [servlet-name]-servlet.xml に以下の設定がある場合、

<aop:aspectj-autoproxy proxy-target-class="true"/>

ブート時に怒られるので一先ず proxy-target-class="false" にしておきます。

コントローラーの変更
以上を踏まえて前回のコントローラーを次のように変更しました。

FacebookTestController.java(抜粋)
signin()メソッドはこんな感じです。DI した facebookOAuth2Template の buildAuthorizeUrl()メソッドで authUrl を生成して facebook にリダイレクトしています。
@RequestMapping(value = "facebooktest/signin.html", method = RequestMethod.GET)
  public void signin(HttpServletResponse response) 
    throws IOException {
    
    OAuth2Parameters params = new OAuth2Parameters();
    params.setRedirectUri(callbackUrl);
    params.setScope("read_stream");
    String authUrl = facebookOAuth2Template.buildAuthorizeUrl(GrantType.AUTHORIZATION_CODE, params);
    logger.info("auth url: {}", authUrl);
    response.sendRedirect(authUrl);
    
  }

コールバックを受け取る hello()メソッドです。DI した facebookOAuth2Template の exchangeForAccess()メソッドで認証コードを交換し、生成した Connection が connectionRepository に登録されていなかったら addConnection()メソッドで追加し、登録されていたら updateConnection()メソッドで内容を更新しています。
@RequestMapping(value = "facebooktest/hello.html", method = RequestMethod.GET)
  public ModelAndView hello(@RequestParam(value = "code", required = false) String code) {
    
    ModelAndView mav = new ModelAndView();
    
    if (code == null) {
      mav.setViewName("redirect:signin.html");
      return mav;
    }
    
    AccessGrant accessGrant = facebookOAuth2Template.exchangeForAccess(code, callbackUrl, null);
    Connection<Facebook> connection = facebookConnectionFactory.createConnection(accessGrant);
    
    if (this.connectionRepository.findPrimaryConnection(Facebook.class) == null) {
      this.connectionRepository.addConnection(connection);
      logger.info(">>connection added");
    }
    else {
      this.connectionRepository.updateConnection(connection);
      logger.info(">>connection updated");
    }
    logger.info(">>access token: {}", accessGrant.getAccessToken());
    mav.setViewName("redirect:home/index.html");
    
    return mav;
  }

レファレンスを参考に facebook アカウントとアプリケーションのローカルアカウントを連携させる環境は構築できました。果たしてこのやり方が正しいのか否かは定かではありませんが....

これをベースに簡単なアプリケーションを作ってみましたが、(極)個人的には使えるものになりました。ソーシャルアプリの開発にはまりそうです。

0 件のコメント:

コメントを投稿