SAStrutsをStruts形式のURLでも動くようにする

SAStrutsではURLがきれいなURLになっている。これは今時必須の機能だと思うが、既存のシステムをStrutsからSAStrutsに置き換えるような場合は、既存のURLを変更しにくいことも多いと思う。
社内システム等閉じた範囲で使用するシステムであれば、URL変更の調整もしやすいと思うが、例えばB2CやB2Bサイトの場合は、コンシューマや取引先にまでURL変更の影響が及ぶので、きれいなURLだけではなく、既存のStrutsのXxxAction.doといったURLでも動いてほしいという要望がそれなりにあるんじゃないか?と思う。
この要望を実現するために、既存の形式のURLでリクエストされたらstruts-config.xmlを見て該当するActionを実行し、そうでなければSAStrutsの通常の処理を行うことを考えてみた。
既存のActionは新旧両方のURLで動くようにし、SAStrutsでシステムを再構築した後に追加したActionは新URLでのみ動かすという想定。再構築後も旧URLで動かすためにstruts-config.xmlを書くのは本末転倒だからね。

Strutsと一口に言っても素のActionやらDispatchActionやら色々なActionがあり、それに応じてURLや処理も異なるので完全互換とはいかないが、まずまずよく使うであろう形式のURL互換機能を実装してみた。
org.seasar.struts.filter.RoutingFilterを継承して実装する。

2009/10/3 追記
WARM deploy時に例外が発生して動作しなくなっていたので修正。
2009/10/6 追記
XXxActionのようにActionクラス名の2文字目が大文字の場合に404エラーになっていたので修正。

public class MyRoutingFilter extends RoutingFilter {
    public static final String INCLUDE_PATH_INFO = "javax.servlet.include.path_info";

    public static final String INCLUDE_SERVLET_PATH = "javax.servlet.include.servlet_path";

    protected static final MessageResources messages = MessageResources
            .getMessageResources("org.apache.struts.actions.LocalStrings");

    public static final String ACTION_CONFIGS_KEY = "tutorial.filter.ACTION_CONFIGS_KEY";

    /** Struts URLパターンの正規表現 */
    protected String strutsUrlPattern = ".*Action\\.do$";

    @Override
    public void init(FilterConfig config) throws ServletException {
        String configUrlPattern = config.getInitParameter("strutsUrlPattern");
        if (!StringUtil.isBlank(configUrlPattern)) {
            strutsUrlPattern = configUrlPattern;
        }
        super.init(config);
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;
        HashMap<String, ActionConfig> actionConfigs = (HashMap<String, ActionConfig>) req
                .getSession().getServletContext().getAttribute(ACTION_CONFIGS_KEY);

        // 初回アクセス時の処理
        if (actionConfigs == null) {
            // HotDeployではリクエストのたびに、S2ModuleConfig#disposeでプロパティ(actionConfigsを含む)がクリアされるため、
            // ModuleConfigに保持されているstruts-config.xml設定を取得し、アプリケーションスコープに保存する。
            ModuleConfig moduleConfig = S2ModuleConfigUtil.getModuleConfig();
            Field f;
            try {
                f = moduleConfig.getClass().getSuperclass().getDeclaredField("actionConfigs");
                f.setAccessible(true);
                HashMap<String, ActionConfig> configs = (HashMap) f.get(moduleConfig);
                actionConfigs = new HashMap<String, ActionConfig>();
                for (Object key : configs.keySet()) {
                    ActionConfig config = (ActionConfig) configs.get(key);
                    if (!(config instanceof S2ActionMapping)) {
                        // SAStruts ではない、Struts の ActionMapping を保存する
                        actionConfigs.put((String) key, config);
                    }
                }
                req.getSession().getServletContext().setAttribute(ACTION_CONFIGS_KEY, actionConfigs);
            } catch (SecurityException e) {
                e.printStackTrace();
            } catch (NoSuchFieldException e) {
                e.printStackTrace();
            } catch (IllegalArgumentException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            }
        }

        String path = RequestUtil.getPath(req);

        if (!processDirectAccess(request, response, chain, path)) {
            return;
        }

        if (actionConfigs != null && isStrutsPath(path)) {
            ActionConfig actionConfig = (ActionConfig) actionConfigs.get(processPath(req, res));
            if (actionConfig != null) {
                // struts-config.xmlに定義されているActionのフルクラス名を取得
                String actionType = actionConfig.getType();

                // ForwardAction の場合は、struts-config.xml の parameter
                // に定義されているパスへフォワードする
                if ("org.apache.struts.actions.ForwardAction"
                        .equals(actionType)) {
                    executeForwardAction(actionConfig, req, res);
                    return;
                }

                // struts-config.xml に forward 属性が定義されている場合は、定義されているパスへフォワードする
                if (actionConfig.getForward() != null) {
                    request.getRequestDispatcher(actionConfig.getForward())
                            .forward(request, response);
                    return;
                }

                // IncludeAction の場合は、struts-config.xml の parameter
                // に定義されているパスをインクルードする
                if ("org.apache.struts.actions.IncludeAction".equals(actionType)) {
                    executeIncludeAction(actionConfig, req, res);
                    return;
                }

                String actionPath = getActionPath(actionType);

                // Actionの実行メソッド名を取得
                String methodName = getActionMethodName(actionConfig, req);

                if (SingletonS2ContainerFactory.getContainer()
                    .hasComponentDef(ActionUtil.fromPathToActionName(actionPath))) {
                    if (StringUtil.isEmpty(methodName)) {
                        // RoutingFilter では、URLにメソッド指定がない場合は、
                        // S2ExecuteConfigUtil#findExecuteConfig(String,
                        // HttpServletRequest)で
                        // S2ExecuteConfigを取得しているが、その場合、SAStrutsの仕様により
                        // URLのパラメータ名にメソッド名と一致するものがあれば
                        // そのメソッドが実行されてしまうためStrutsと挙動が変わってしまう。
                        // Strutsと挙動を合わせるため、デフォルトメソッド名を補完する。
                        S2ExecuteConfig executeConfig = findExecuteConfig(actionPath, "index");
                        if (executeConfig != null) {
                            forward(req, res, actionPath, null, null);
                            return;
                        }
                    } else {
                        S2ExecuteConfig executeConfig = findExecuteConfig(actionPath, methodName);
                        if (executeConfig != null) {
                            forward(req, res, actionPath, methodName, executeConfig);
                            return;
                        }
                    }
                }
            }
        } else {
            super.doFilter(request, response, chain);
            return;
        }
        res.sendError(HttpServletResponse.SC_NOT_FOUND, req.getRequestURI());
    }

    protected String getActionPath(String actionClassName) {
        // ルートパッケージ名の取得
        NamingConvention namingConvention = SingletonS2Container
                .getComponent(NamingConvention.class);
        String[] rootPackageNames = namingConvention.getRootPackageNames();

        // Actionのフルクラス名からルートパッケージ名.action.を除く
        for (String rootPackageName : rootPackageNames) {
            if (actionClassName.startsWith(rootPackageName)) {
                actionClassName = actionClassName.replace(rootPackageName
                        + ".action.", "");
                break;
            }
        }

        // xxx.XxxAction -> xxx/xxx に変換
        int actionNameIndex = actionClassName.lastIndexOf(".");
        StringBuilder actionName = new StringBuilder();
        actionName.append(actionClassName.substring(0, actionNameIndex + 1));
        if (Character.isUpperCase(actionClassName.codePointAt(actionNameIndex + 2))) {
            // クラス名の2文字目が大文字の場合は、先頭は小文字にしない(xxx.XXxAction -> xxx/XXx)
            actionName.append(actionClassName.substring(actionNameIndex + 1,
                    actionNameIndex + 2));
        } else {
            actionName.append(actionClassName.substring(actionNameIndex + 1,
                    actionNameIndex + 2).toLowerCase());
        }
        actionName.append(actionClassName.substring(actionNameIndex + 2));
        StringBuilder actionPath = new StringBuilder();
        actionPath.append("/").append(
                actionName.toString().replace(".", "/").replaceFirst("Action$", ""));
        return actionPath.toString();
    }

    protected boolean isStrutsPath(String path) {
        if (path.matches(strutsUrlPattern)) {
            return true;
        }
        return false;
    }

    /**
     * Action の実行メソッド名を返します。<br>
     * Struts で、MappingDispatchAction を使用していた場合は、<br>
     * このメソッドをオーバーライドして、actionConfig.getParameter()を返すように実装してください。<br>
     */
    protected String getActionMethodName(ActionConfig actionConfig,
            HttpServletRequest request) {
        String methodName = "";

        String parameter = actionConfig.getParameter();
        if (parameter != null) {
            String paramValue = (String) request.getParameter(parameter);
            if (paramValue != null) {
                methodName = paramValue;
            }
        }

        // メソッド名が Struts のデフォルトメソッド名の場合は、index に変換。
        if (methodName.equals(getStrutsActionDefaultMethodName())) {
            methodName = "index";
        }
        return methodName;
    }

    /**
     * Struts のデフォルトメソッド名を返します。<br>
     * このメソッドがリクエストされた場合は、index メソッドを実行します。<br>
     * デフォルトでは null を返します。必要に応じて、このメソッドをオーバーライドしてください。<br>
     */
    protected String getStrutsActionDefaultMethodName() {
        return null;
    }

    protected String processPath(HttpServletRequest request,
            HttpServletResponse response) throws IOException {
        String path = null;

        path = (String) request.getAttribute(INCLUDE_PATH_INFO);
        if (path == null) {
            path = request.getPathInfo();
        }
        if ((path != null) && (path.length() > 0)) {
            return (path);
        }

        path = (String) request.getAttribute(INCLUDE_SERVLET_PATH);
        if (path == null) {
            path = request.getServletPath();
        }
        String prefix = S2ModuleConfigUtil.getModuleConfig().getPrefix();
        if (!path.startsWith(prefix)) {
            ActionServlet servlet = (ActionServlet) request.getSession()
                    .getServletContext().getAttribute(
                            Globals.ACTION_SERVLET_KEY);
            String msg = servlet.getInternal().getMessage("processPath");
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, msg);
            return null;
        }

        path = path.substring(prefix.length());
        int slash = path.lastIndexOf("/");
        int period = path.lastIndexOf(".");
        if ((period >= 0) && (period > slash)) {
            path = path.substring(0, period);
        }
        return (path);
    }

    protected void executeForwardAction(ActionConfig config,
            HttpServletRequest request, HttpServletResponse response)
            throws IOException, ServletException {
        String parameter = config.getParameter();
        if (parameter == null) {
            throw new ServletException(messages.getMessage("forward.path"));
        }
        request.getRequestDispatcher(parameter).forward(request, response);
    }

    protected void executeIncludeAction(ActionConfig config,
            HttpServletRequest request, HttpServletResponse response)
            throws IOException, ServletException {
        String path = config.getParameter();
        if (path == null) {
            throw new ServletException(messages.getMessage("include.path"));
        }

        ActionServlet servlet = (ActionServlet) request.getSession()
                .getServletContext().getAttribute(Globals.ACTION_SERVLET_KEY);
        RequestDispatcher rd = servlet.getServletContext()
                .getRequestDispatcher(path);

        if (rd == null) {
            throw new ServletException(messages.getMessage("include.rd", path));
        }

        if (request instanceof MultipartRequestWrapper) {
            request = ((MultipartRequestWrapper) request).getRequest();
        }

        rd.include(request, response);
    }

    protected S2ExecuteConfig findExecuteConfig(String actionPath,
            String methodName) {
        S2ModuleConfig moduleConfig = S2ModuleConfigUtil.getModuleConfig();
        S2ActionMapping actionMapping = (S2ActionMapping) moduleConfig
                .findActionConfig(actionPath);
        // S2ExecuteConfigUtil#findExecuteConfig(String, String)ではメソッド名が一致していても
         // Action でurlPattern を指定していた場合は、URLパスがマッチしないため
        // executeConfig が取得できないので、メソッド名を指定して取得する。
        S2ExecuteConfig executeConfig = actionMapping
                .getExecuteConfig(methodName);
        return executeConfig;
    }
}

使い方

Struts形式のURLで動作させたいActionをstruts-config.xmlのactionタグに記述する。
標準で、Action、ForwardAction、IncludeAction、DispatchActionに対応している。
ForwardActionを使用する場合、action タグの parameter 属性には次のようにRoutingFilter通過後の形式のパスを設定する。

例.AddAction#hogeを実行する場合

<action path="/ForwardAction"
    type="org.apache.struts.actions.ForwardAction" parameter="/add.do?SAStruts.method=hoge">
</action>

MappingDispatchAction、LookupDispatchAction に対応させるには、getActionMethodName(ActionConfig, HttpServletRequest)をオーバーライドする。
struts-config.xml の action タグの属性については、path、type、parameter、forward のみ対応。他の属性については未対応。
また、Struts のモジュール機能にもおそらく未対応(使用したことがないので、よくわかりません。。。)
xxxAction.do 以外の形式の Struts URL に対応させる場合は web.xml の filter タグで、以下のように設定する。

<filter>
    <filter-name>routingfilter</filter-name>
    <filter-class>hoge.filter.MyRoutingFilter</filter-class>
    <init-param>
        <param-name>jspDirectAccess</param-name>
        <param-value>false</param-value>
    </init-param>
    <init-param>
        <param-name>strutsUrlPattern</param-name>
        <param-value>Struts URLパターンの正規表現(例 .*Action\.do$)</param-value>
    </init-param>
</filter>

ただし、/do/xxxAction などのようにStruts デフォルト形式から拡張子変更以上の変更を行っている場合は上記設定を行った上で、struts-config.xml の action タグの path 属性もURLに合うように変更必要。
例. /do/xxxAction の場合

<action-mappings>
    <action path="/do/AddAction" ...>


ModuleConfigのprotectedなactionConfigsにリフレクションで無理矢理アクセスしていたり等、あまりきれいなコードではないが、Filterひとつで実装してみた。
なお、初回アクセス時の処理の一部は、SAStrutsAOS(http://sourceforge.jp/projects/javasth/docs/sa-struts-aos.jar/ja/2/sa-struts-aos.jar)のS2AOSModuleConfigを参考にさせていただきました。
Filterひとつで無理矢理実装するのではなく、SAStrutsAOSのようにS2ModuleConfigを継承すれば、もう少しマシなコードにできますね。
それはまた今度にでも。