Help us understand the problem. What is going on with this article?

Spring AOPでテーブルの作成者・作成日時・更新者・更新日時を設定する

More than 3 years have passed since last update.

AOPの使用例として、ログ、キャッシュ、トランザクション管理、処理時間計測、例外対応などが有名だと思います。
今回、業務システムで使えそうなものを作ってみたいと思います。

業務システムで使用するテーブルには、作成者、作成日時、更新者、更新日時を保持することが多いと思います。
これらの項目をアプリケーション側で設定してもいいのですが、設定漏れがあったり、本質的でない処理をいろいろなところに書かなければいけなかったりで、結構面倒です。

そこで、AOPを使って、これらの項目を設定してみます。
図で示すと、(ビジネスロジックを実装した@Serviceクラスから)Mapperのcreate*メソッドとupdate*メソッドへの呼び出しをJoin Pointとし、Mapperの引数のモデルオブジェクトに対して、作成者、作成日時、更新者、更新日時を設定してみるということです。

aspect.png

作成者と更新者はSpring Securityの認証情報を使用します。
データ周りのフレームワークはMyBatis、データベースはPostgreSQLを使用しています。

環境

  • Spring Framework 4.3.2
  • Spring Security 4.1.3
  • Thymeleaf 3.0.0
  • GlassFish Server Open Source Edition 4.1
  • JDK 1.8
  • MyBatis 3.4.1
  • PostgreSQL 9.4

ソースコードはこちらにあります。
https://github.com/kenjihori/spring

前提

各テーブルに下記の項目を持ちます。

データ項目名 データ型 データ項目名(日本語)
CREATED_BY_ID VARCHAR 作成者ID
CREATE_DATETIME DATETIME 作成日時
LAST_UPDATED_BY_ID VARCHAR 更新者ID
LAST_UPDATE_DATETIME DATETIME 更新日時

そして、テーブルとマッピングされるモデルクラスにも以下のプロパティを持ちます。

項目名 データ型
createdById String
createdTimestamp Date
updatedById String
updatedTimestamp Date

Mapperのルールは以下とします。

  • Mapperのクラス名 → *Mapper
  • insertのメソッド名 → create*
  • updateのメソッド名 → update*
  • insert、updateのメソッドの引数はテーブルと同じ項目を保持するモデルクラス

Spring Securityを使用して、ログインユーザーの認証情報を保持します。
Spring Securityによる認証の詳細は、今回のテーマから離れるので、概略にとどめておきます。

AuthenticationManagerとして登録したLoginComponentでは、ログイン画面で入力したユーザー名(username)を元に、MyAppUserService(@Service)クラス経由でデータベースよりユーザー情報を取得します。
そして、SpringのUserDetailsを拡張したLoginUserInfoにユーザー情報を格納して返却します。
このLoginUserInfoを後ほど認証情報として利用します。

LoginComponent.java
@Service
public class LoginComponent implements UserDetailsService {

    @Autowired
    private MyAppUserService myAppUserService;

    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        MyAppUser myAppUser = myAppUserService.findMyAppUser(username);
        if (myAppUser == null) {
            throw new UsernameNotFoundException("MyAppUser ID " + username + " Not Found");
        }
        return new LoginUserInfo(myAppUser, AuthorityUtils.NO_AUTHORITIES);

    }
}

Aspectの作成

それでは早速、Aspectを作成します。

MapperAspect.java
@Aspect
@Component
public class MapperAspect {

    @Before("execution(* com.mycompany.myapp.domain.mapper.*Mapper.create*(..)) || " + 
            "execution(* com.mycompany.myapp.domain.mapper.*Mapper.update*(..))")
    public void setCommonProperty(JoinPoint jp) throws Throwable {

        // Mapperのメソッド名を取得
        MethodSignature signature = (MethodSignature) jp.getSignature();
        Method method = signature.getMethod();
        String methodName = method.getName();

        // Spring Securityの認証情報を取得
        LoginUserInfo loginUserInfo = null;
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if(authentication.getPrincipal() instanceof LoginUserInfo) {
            loginUserInfo = LoginUserInfo.class.cast(authentication.getPrincipal());
        }

        // 現在日時の取得
        Date now = new Date();

        // Mapperの第一引数(モデルオブジェクト)を取得
        Object[] args = jp.getArgs();
        Object dto = args[0];

        // create*メソッドは作成者・作成日時・更新者・更新日時をセット
        if(methodName.startsWith("create")) {
            setCreatedProperty(loginUserInfo, now, dto);
            setUpdatedProperty(loginUserInfo, now, dto);
        // update*メソッドは更新者・更新日時をセット
        } else if(methodName.startsWith("update")) {
            setUpdatedProperty(loginUserInfo, now, dto);
        }

    }

    // 作成者IDと作成日時をセット
    private void setCreatedProperty(LoginUserInfo loginUserInfo, Date now, Object dto) throws Throwable {

        // Mapperの引数にsetCreatedByIdメソッドがある場合、認証情報をセット
        Method setCreatedById = ReflectionUtils.findMethod(dto.getClass(), "setCreatedById", String.class);
        if(setCreatedById != null) {
            setCreatedById.invoke(dto, loginUserInfo.getId());
        }

        // Mapperの引数にsetCreatedTimestampメソッドがある場合、現在日時をセット
        Method setCreatedTimestamp  = ReflectionUtils.findMethod(dto.getClass(), "setCreatedTimestamp", Date.class);
        if(setCreatedTimestamp != null) {
            setCreatedTimestamp.invoke(dto, now);
        }

    }

    // 更新者IDと更新日時をセット
    private void setUpdatedProperty(LoginUserInfo loginUserInfo, Date now, Object dto) throws Throwable {

        // Mapperの引数にsetUpdatedByIdメソッドがある場合、認証情報をセット
        Method setUpdatedById  = ReflectionUtils.findMethod(dto.getClass(), "setUpdatedById", String.class);
        if(setUpdatedById != null) {
            setUpdatedById.invoke(dto, loginUserInfo.getId());
        }

        // Mapperの引数にsetUpdatedTimestampメソッドがある場合、現在日時をセット
        Method setUpdatedTimestamp  = ReflectionUtils.findMethod(dto.getClass(), "setUpdatedTimestamp", Date.class);
        if(setUpdatedTimestamp != null) {
            setUpdatedTimestamp.invoke(dto, now);
        }

    }

}

まず、@Beforeの引数で、Join PointをPointcut式で指定します。
対象のパッケージ、Mapperのクラス名(*Mapper)、メソッド名(create*もしくはupdate*)を指定します。

@Before("execution(* com.mycompany.myapp.domain.mapper.*Mapper.create*(..)) || " + 
        "execution(* com.mycompany.myapp.domain.mapper.*Mapper.update*(..))")

Spring Securityの認証情報は、SecurityContextHolderから取得できます。

LoginUserInfo loginUserInfo = null;
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if(authentication.getPrincipal() instanceof LoginUserInfo) {
    loginUserInfo = LoginUserInfo.class.cast(authentication.getPrincipal());
}

そして、Mapperのメソッド名に応じて、createメソッドの場合は作成者・作成日時を、updateメソッドの場合は作成者・作成日時・更新者・更新日時をセットします。モデルオブジェクトのメソッドを呼び出すため、SpringのReflectionUtilsを利用しています。

作成者IDの設定例

Method setCreatedById = ReflectionUtils.findMethod(dto.getClass(), "setCreatedById", String.class);
if(setCreatedById != null) {
    setCreatedById.invoke(dto, loginUserInfo.getId());
}

画面からMapperまでの流れ

Aspectができたので、IDと名称を持つ商品テーブルの新規作成と更新をやってみたいと思います。

登録画面のイメージ

item-input-image.png

更新画面のイメージ

item-update-image.png

コントローラーはこちらです。
登録処理(createメソッド)では、フォームの内容をモデルに格納し、サービスクラスを呼び出します。
更新処理(updateメソッド)では、更新画面表示時(showメソッド)にsessionに格納したモデルに対して、フォームの内容で上書きし、サービスクラスに渡します。

@Controller
public class ItemController {
    // 省略
    @RequestMapping(value = "item", method = RequestMethod.POST)
    public String create(@ValidatedItemForm form, BindingResult bindingResult, Model model) {
        // 省略
        // フォームからモデルを作成
        Item item = new Item();
        item.setId(form.getItemCode());;
        item.setName(form.getItemName());
        // 商品を登録
        itemService.createItem(item);
        // 完了画面に遷移
        model.addAttribute("itemForm", form);
        return "itemComplete";
    }

    @RequestMapping(value = "item/{itemCode}", method = RequestMethod.GET)
    public String show(HttpSession session, @PathVariable("itemCode") String itemCode, ItemForm form, Model model) {
        // 商品を取得
        Item item = itemService.findItem(itemCode);
        // セッションに格納
        session.setAttribute("sessioitem", item);
        // 更新画面に遷移
        model.addAttribute("item", item);
        return "itemUpdate";
    }

    @RequestMapping(value = "item/{itemCode}", method = RequestMethod.POST)
    public String update(HttpSession session, @Validated ItemForm form, BindingResult bindingResult, Model model) {
        // 省略
        // 更新画面表示時にセッションに格納したモデルクラスに対して、フォームの内容で更新
        Item item = (Item)session.getAttribute("sessioitem");
        item.setName(form.getItemName());
        // 商品を更新
        itemService.updateItem(item);
        // 完了画面に遷移
        model.addAttribute("itemForm", form);
        return "itemComplete";
    }
}

サービスクラスはMapperを呼び出すだけです。

@Service
public class ItemService {

    @Autowired
    ItemMapper itemMapper;

    public void createItem(Item item) {
        itemMapper.createItem(item);
    }

    public void updateItem(Item item) {
        itemMapper.updateItem(item);
    }

}

Mapperはメソッド定義だけで、SQLはマッピングファイルに作成しています。

public interface ItemMapper {

    void createItem(Item item);

    void updateItem(Item item);

}
<insert id="createItem" parameterType="Item">
INSERT INTO item (
    id,
    name,
    created_by_id,
    created_timestamp,
    updated_by_id,
    updated_timestamp
) VALUES (
    #{id},
    #{name},
    #{createdById},
    #{createdTimestamp},
    #{updatedById},
    #{updatedTimestamp}
)
</insert>
<update id="updateItem" parameterType="Item">
UPDATE item SET 
    name = #{name},
    updated_by_id = #{updatedById},
    updated_timestamp = #{updatedTimestamp}
WHERE
    id = #{id}
</update>

動作確認

それでは、実際に登録処理をしてみます。
ユーザー「11111」でログインし、商品を登録します。

login1.png

item-new.png

item-new-complete.png

登録されたデータを見てみると、

data1.png

確かに作成者・作成日時・更新者・更新日時が設定されています。

続いて、更新処理をしてみます。
ユーザーを変えて、「22222」でログインし、先ほど登録した商品名を変更してみます。

login2.png

item-upate.png

item-update-complete.png

こちらも、データを見てみると、

data2.png

更新者と更新日時が正しく設定されました。

さいごに

AOPをSpring Frameworkで使用すると、楽観ロック、権限チェック(ログインユーザーと登録データのチェックなど)などいろいろと応用ができそうです。
他の例も今後投稿してみたいと思います。

kenhori
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした