らぼるてっく。

てっくてっく歩いてっく。

学生ハッカソン用にSpringBoot3とNuxt3でSPAのサンプルアプリケーションを作ったよ

ラボルの川村です。

この度学生ハッカソン用に、

BackendのAPIとしてSpringBoot3、FrontendとしてNuxt3を用いた認証付きCRUD操作が可能なサンプルプロジェクト

を作成したので紹介します!コードはGitHubにあげてます。

github.com

この記事では、サンプルプロジェクトがどのようなシステム構成となっているかや、どういう機能があるかなどを紹介していきます。 また、どのようにサンプルプロジェクトを作っていったかも解説していくので、SpringBootの我流サンプルプロジェクト作ってみたい!という人はぜひ参考にしてください。

サンプルプロジェクトの紹介

システム構成

GitHubのReadmeにも記述していますが、開発環境におけるシステムの構成は以下の通りです。

※具体的な各種バージョンはReadmeを参照してください。

本プロジェクトはBackend APIとしてSpringBoot3を使用しており、そのAPIをFrontendのNuxt3からリクエストしています。

BackendとFrontendが完全に分離しているSPAの構成です。 FrontendからBackendのAPIを呼び出し、ブラウザ側でレンダリングを行います。ちなみに、このブラウザ側でレンダリングを行うことをCSRと呼びます。

Backend

BackendのAPIとしてSpringBoot3を用いています。

認証にはSpring Securityを使っています。

これらは開発PCのJVM上で動作させています。 Readmeでも触れていますが、「SDKMAN!」を入れてJDKを管理すると色々なバージョンを自由に変更することができるのでおすすめです。

SpringBootで作成されたBackend APIはRedisに認証情報を保存したり、MySQLに接続したりしてユーザーデータの出し入れなどを行っています。

Frontend

FrontendはNuxt3で作成しています。

Nuxt3はVue3のComposition APIを実装できたり、TypeScriptがネイティブサポートされていたりします。もちろんOptions APIの記述もJSでの記述も可能です。

今回はFrontendはNuxtで構築しましたが、今後はReactやSvelteなどでの実装も追加していこうと思います。

FrontendはBackendと完全に分離されており、APIコールしているだけなので、何を使ってもいいです。

データストア

データストアとしてはRedisとMySQLを用いておりそれぞれ以下の使い分けとしています。

  • Redis
    • ユーザーセッション(ログイン情報)保存用
  • MySQL
    • ユーザーのデータの保存用

これらのデータストアはDocker上で起動させており、同じくAdminer(DBクライアントツール)もDocker上に起動しています。

機能

機能としては以下を用意しています。

  • ログイン時
    • ログアウト
    • ログインユーザー取得(自分)
    • ユーザー登録
    • ユーザー更新(自分)
    • ユーザー削除(自分)
  • 非ログイン時
    • ログイン
    • ユーザー一覧表示(全ユーザー)

データとしてはuserテーブルしか存在しません。

以下に各画面で操作したGIFを作成しておきました。 スタイルは一切いじってないので、素朴な感じですがいい感じに動作してます。

サンプルプロジェクトの構築の流れ

ここからは、サンプルプロエジェクトをどういう流れで作っていったのかを解説していきます(Nuxtの構築は省きます、APIコールしているだけなので)

初めてSpringBootのプロジェクトを作成する人向けに解説していくので、順を追って試してみてください。

※またSpringBootのプロジェクトの作成方法はここで解説している内容に限りません。一例と思ってください。

また以降の解説でプログラムコードが出てきますが、参考程度にみてください。完全に動くコードであればGitHubにあるので、そちらを参照してください。

SpringBootのプロジェクトの作成

SpringBootのプロジェクトの雛形をまずは作成するのですが、「spring initializr」というサイトがあるので、そこで作成していきます。

https://start.spring.io/

ここでは画像のように以下を設定していきました。

DB接続したり、データ扱ったり、認証したりなどなどするためのライブラリを追加していきました。

GENERATEを押すとzipファイルとしてダウンロードできます。

zipファイルを解凍して、プロジェクト直下で以下実行して各種ライブラリをインストールしていきます。

./gradlew

※JDK入ってないと上記動きません。

SpringBootの起動は

./gradlew bootRun

http://localhost:8080でアクセスできるので、SpringBootが起動しているか確認してください。

認証周りの設定

Spring Securityを使って認証周りを作っていきます。ここが一番ややこしい部分です。

ログインする対象ユーザーの取得

UserDetailsServiceを継承したクラスを作成し、ログイン時にDBに入っているユーザーを取得する処理です。

このクラスでユーザーを取得し、UserDetailsを返すことでSpring Securityでパスワードの検証を行いセッションにユーザー情報を保存します。

パスワード検証や、セッションへのユーザー情報の保存はSpring Security側で隠蔽されているので、開発者が意識することはあまりありません。

「DBに入っているユーザーを取得」といいましたが、DBに接続せず固定でユーザー情報を返してもログインさせることが可能です。

@Service
public class CustomUserDetailService implements UserDetailsService {
    private final UserRepository userRepository;

    public CustomUserDetailService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return new CustomUserCredential(1, "テストユーザー", "test@example.com", "ハッシュ化されたパスワード")
    }
}

CustomUserCredentialはUserDetailsを継承しているクラスで、ここにユーザー情報を詰め込みます。

適宜Getter/Setterつけてください。Lombokを用いればGetter/Setter/Constructorをアノテーションで生成できるようになるので便利ですが、今回は用いてません。

public class CustomUserCredential implements UserDetails, Serializable {
    private Long userId;
    private String name;
    private String email;
    private String password;

    public CustomUserCredential(Long userId, String name, String email, String password) {
        this.userId = userId;
        this.name = name;
        this.email = email;
        this.password = password;
    }

Spring Security Configの設定

Spring Securityの設定を記述していくために「SpringSecurityConfig」というクラスを作成していきます。

このクラスではSpring Securityの細かい設定内容を記述します。

先ほど作成した、CustomUserDetailServiceを指定していますが、これによりログイン時に独自のユーザー取得を実装できるようになります。

長くなるので一部端折ってます。詳細はGitHub

@Configuration
@EnableWebSecurity
public class SpringSecurityConfig {

    private final CustomUserDetailService customUserDetailService;

    private final CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;
    private final CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    private static final String[] PUBLIC_ENDPOINTS = {
            "/",
            "/sample/**",
    };

    public SpringSecurityConfig(CustomUserDetailService customUserDetailService, CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler, CustomAuthenticationFailureHandler customAuthenticationFailureHandler) {
        this.customUserDetailService = customUserDetailService;
        this.customAuthenticationSuccessHandler = customAuthenticationSuccessHandler;
        this.customAuthenticationFailureHandler = customAuthenticationFailureHandler;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                // 先程作成したログイン時のユーザー取得クラスを指定しています。
                .userDetailsService(this.customUserDetailService)
                .authorizeHttpRequests(customizer -> customizer
                        // PUBLIC_ENDPOINTSに記述されたパスはログイン不要にしています。それ以外のパスはログイン必須
                        .requestMatchers(PUBLIC_ENDPOINTS)
                        .permitAll()
                        .anyRequest().authenticated()
                )
                .formLogin(customizer -> {
                    customizer
                            .loginProcessingUrl("/api/login")
                            .permitAll()
                            .successHandler(this.customAuthenticationSuccessHandler)
                            .failureHandler(this.customAuthenticationFailureHandler)
                    ;
                })
                .cors((e) -> {})
                // CSRFについては今回サンプルプロジェクトなので無効化しています。必要な場合はこの行を削除してください。
                .csrf(AbstractHttpConfigurer::disable)
                .exceptionHandling(exception -> exception.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)))
                .build();
    }

    @Component
    static class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
        // ログイン成功した場合の処理を記述できます。ログイン後〇〇ページヘリダイレクトされたり、ログインのログをDBに書き込んだりなどする時によく使います。
    }

    @Component
    static class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler  {
        // ログイン失敗したときの処理を記述できます。こちらもリダイレクトしたり、ログを書き込んだり良くします。
        // ログインの試行回数に制限を設けたい場合などは、ここでログインが何回失敗したかカウントアップさせたりします。
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }


    // CORSの設定です。今回のSpringBootで作成されたBackendのAPIはブラウザからXHRリクエストされるので、CORSの仕組みが適用されます。
    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        configuration.setAllowCredentials(true);
        configuration.addAllowedOrigin("http://localhost:3000");
        configuration.addAllowedHeader("*");
        configuration.addAllowedMethod("*");

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

このようにSpring Securityは複雑になりがちで、そこそこ難しいです。今回のセキュリティの設定は不要な部分は削り取ったので、まだマシですが。

今回のプロジェクトはNuxt側からBackendAPIに対してAPIリクエスト(XHRリクエスト)するため、それぞれ異なるオリジンとなります。

なのでブラウザのCORSの仕組みが大いに関わってきます。

Nuxtはlocalhost:3000で起動するようにしているので、今回の設定では

        configuration.addAllowedOrigin("http://localhost:3000");

とすることでlocalhost:3000からのリクエストを許可しています。

ちなみに異なるオリジンとは以下のいずれかが異なる場合、異なるオリジンということになります。

  • スキーム(httpとかhttpsとか)
  • ホスト(localhostとかexample.comとか)
  • ポート(3000とか80とか)

今回であれば、

とポートだけが異なっていますが、オリジンとしてみると「異なるオリジン」とみなされます。

CORS設定をせずにFrontendからBackend APIに対してXHRリクエストを行うと、ブラウザのCORSによりエラーとなります。 ブラウザのCORSは小難しいので、また別の機会に詳しく解説します。

データ操作について

Spring Data JPA

今回のプロジェクトではSpring Data JPAというO/Rマッパー用いてデータアクセスを行っています。

簡単に操作の解説をしておきます。

まずはDBのテーブルとマッピングするためのクラスを作成します。JPAではEntityと呼んでいます。

適宜Getter/Setter作ってください。

@Entity
@Table(name = "user")
public class UserEntity {
    @Id // プライマリーキーであることを明示的に示しています
    @Column(name = "user_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY) // DBのAuto incrementを使う設定
    private Long userId;

    @Column(name = "name")
    private String name;

    @Column(name = "email")
    private String email;

    @Column(name = "password")
    private String password;
}

このUserEntityを使ってDBのデータとマッピングしていきます。

userデータ一覧を取得する処理を書くと

public interface UserRepository extends JpaRepository<UserEntity, Long> {}

JpaRepositoryを継承しUserEntityとLongを指定しています。userデータにマッピングするためのクラスとプライマリーキーの型を指定しています。

このリポジトリを使用するには、DIすることで使用可能になります。

以下ではfindAll()とすることで、全userデータを取得しています。

見ての通りListで返されています。

@RequestMapping("sample/users")
@RestController
public class SampleUserController {

    private final UserRepository userRepository;

    public SampleUserController(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @GetMapping
    public List<UserEntity> getUsers() {
        return this.userRepository.findAll();
    }
}

Flyway

FlywayはDBマイグレーションツールです。データの作成、更新、リセットなどを行うことができます。

なくても問題にはならないですが、開発時データ初期化したいなどという場合もあると思います。

またコードとしてテーブルやデータを管理できるのも良いですね。チームで開発するときは共通のデータを扱いたい場合が多いと思うので、重宝します。

Flywayではresources/db/migration配下にSQLファイルを配置することで、データを初期化したり、再投入したりすることができます。

Flywayには色々とメソッドがありますが、とりあえずデータの初期化ができるものを覚えておくと良いです。

データ全削除(テーブル定義も削除)

./gradlew flywayClean

データ全投入(create table/insertなど)

./gradlew flywayMigrate

今後の展望

このサンプルプロジェクトではかなり限定的な機能しかありませんが、今後はもっと様々な実装を施していく予定です。

例えばJooqやMyBatisなどのO/Rマッパーを使ったり、backend側でのAPI通信の機構を入れたり、Frontendで様々なFWを入れて同様の実装をしたりなど。

今はシンプルな実装しかありませんが、より複雑なロジックを組み込んで設計していったりなどもしていきます。

学生or初学者の皆様へ

本サンプルプロジェクトはかなりシンプルな構成となっているものの、あまりアプリケーションの環境構築をしたことがない人にとっては、有意義なものとなると思います。

初めてアプリケーションを動かそうとした時は、1回でうまくいくことなんで、まあないと思います。

このサンプルプロジェクトでさえ、色々と詰まると思います。

ただその経験が一番重要で、それを繰り返すことで知識・経験となりエンジニアとしてのスキルがより上がっていくかと思います。

一発で成功しないからダメだと思わず、何度も壊しては作ってを繰り返してみてください。

一発で成功してしまうと、1通りの経験しか積めません。何度も失敗すると何通りもの経験が積めます。

このサンプルプロジェクトに限らず、最初のうちはこのようなサンプルを用いて動作させてみるというところから始めて、徐々に機能を付け加えたり設計を見直したりしてより良いプロジェクトを作って行ってください。

最初から完璧を目指さず、一歩ずつ着実に前に進むことが重要ですよ。

最後に

ラボルでは、エンジニアを積極採用中です。1、2年目のエンジニアから経験豊富なテックリードやエンジニリングマネージャーまで、興味がある方はぜひご応募ください!!

labol.co.jp