はじめに
iimonでサーバーサイドエンジニアをしている腰丸です。皆さんはアプリケーションのデバッグをどのように行っているでしょうか?
今回は、簡単なログイン認証機能のコードをもとに、自分がどのような手順でデバッグ作業を行っているかをご紹介します。
あくまで、自分がどうやっているかの紹介なので必ずしも、今回の手順が良いというわけではないのです。
アプリの概要
機能の概要
ログインボタンをクリックすると
ログイン完了後の画面に遷移する
デバッグする前の準備
個人的には、Webアプリのコードを書く場合は、ブレークポイントを貼ってデバッグできる環境を整えておくことが重要だと思っています。
(自分が開発、デバックするうえでは必須の設定です)
今回はVSCodeを使ってデバッグを行います。VsCodeでデバックする場合は、launch.jsonというファイルを作成して、デバッグの設定を行います。
{ "version": "0.2.0", "configurations": [ { "type": "chrome", "request": "launch", "name": "Front Launch debugger", "url": "http://localhost:3333", "webRoot": "${workspaceFolder}/my-sample" }, { "type": "node", "name": "Server Launch debugger", "request": "launch", "runtimeArgs": [ "run", "start:debug" ], "runtimeExecutable": "npm", "skipFiles": [ "<node_internals>/**" ], "sourceMaps": true, "envFile": "${workspaceFolder}/.env", "cwd": "${workspaceRoot}/nest-sample/github-auth-sample", "console": "integratedTerminal", }, ] }
関連するコード
フロント側
- ログイン画面のコンポーネント
import { createLazyFileRoute, useNavigate } from '@tanstack/react-router' import { useContext } from 'react' import { AuthContext } from '../provider/AuthProvider' export const Route = createLazyFileRoute('/')({ component: Index, }) function Index() { const navigate = useNavigate() const authContext = useContext(AuthContext) const isLogin = authContext?.isLogin; console.log('isLogin:', isLogin) return ( <div className="p-2"> <h3>Welcome Home!</h3> {isLogin ? <> <div>ログイン成功 \(^o^)/</div> <button onClick={() => { localStorage.removeItem('accessToken') localStorage.removeItem('refreshToken') authContext?.setIsLogin(false) navigate({ to: '/' }) }}>Logout</button> </> : <a href="https://github.com/login/oauth/authorize?client_id=Iv23livPRpM0vcEU4CYp" className="font-bold"> ログイン </a> } </div> ) }
import { createFileRoute } from '@tanstack/react-router' const login = async (code: string) => { const response = await fetch('http://localhost:8080/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ code }), }) if (!response.ok) { throw new Error('Login failed: ' + response.statusText) } const data = await response.json() return data } export const Route = createFileRoute('/auth/login')({ validateSearch: (search: { code: string }) => { return search }, loaderDeps: ({ search: { code } }) => { return { code } }, loader: async ({ deps: { code } }) => { try { const data = await login(code) localStorage.setItem('accessToken', data.access_token) localStorage.setItem('refreshToken', data.refresh_token) } catch (error) { console.error('Error during login:', error) } window.location.href = '/' }, component: () => { return (<div>ログインに失敗したかも...</div>) } })
- 認証状態を管理するProvider
import { createContext, useEffect, useState, ReactNode } from "react"; type AuthContextType = { isLogin: boolean; setIsLogin: React.Dispatch<React.SetStateAction<boolean>>; }; export const AuthContext = createContext<AuthContextType | undefined>(undefined); export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [isLogin, setIsLogin] = useState(localStorage.getItem('accessToken') !== null); return ( <AuthContext.Provider value={{ isLogin, setIsLogin }}> {children} </AuthContext.Provider> ); }
import { StrictMode } from 'react' import ReactDOM from 'react-dom/client' import { RouterProvider, createRouter } from '@tanstack/react-router' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; // Import the generated route tree import { routeTree } from './routeTree.gen' import { AuthProvider } from './provider/AuthProvider'; import ErrorBoundary from './ErrorBoundary'; // Create a new router instance export const router = createRouter({ routeTree }) // Register the router instance for type safety declare module '@tanstack/react-router' { interface Register { router: typeof router } } // Set up TanStack Query const queryClient = new QueryClient(); // Render the app const rootElement = document.getElementById('root')! if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement) root.render( // <StrictMode> <QueryClientProvider client={queryClient}> <ErrorBoundary> <AuthProvider> <RouterProvider router={router} /> </AuthProvider> </ErrorBoundary> </QueryClientProvider> // </StrictMode>, ) }
サーバー側
- エンドポイント
import { Body, Controller, Get, Post, Query, Req, Res } from '@nestjs/common'; import { AuthService } from './auth.service'; import { UserQuery } from 'src/db/user.query'; @Controller('auth') export class AuthController { constructor(private authService: AuthService, private userQuery: UserQuery) {} @Post('login') login(@Body("code") code: string, @Req() request: Request) { const response = this.authService.login(code); return response; } }
- ログイン処理ロジック
import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { firstValueFrom } from 'rxjs'; import { UserQuery } from 'src/db/user.query'; @Injectable() export class AuthService { constructor( private configService: ConfigService, private readonly httpService: HttpService, private readonly userQuery: UserQuery, ) { } async login(code: string) { const github_url = `https://github.com/login/oauth/access_token?client_id=Iv23livPRpM0vcEU4CYp&client_secret=${this.configService.get<string>('SECRET')}&code=${code}`; const github_headers = { Accept: 'application/json', }; const response = await firstValueFrom(this.httpService.get(github_url, { headers: github_headers })); const get_user_url = 'https://api.github.com/user'; const auth_headers = { Accept: 'application/vnd.github+json', Authorization: `Bearer ${response.data.access_token}`, }; const user_info_res = await firstValueFrom(this.httpService.get(get_user_url, { headers: auth_headers })); const user = await this.userQuery.user({ githubId: user_info_res.data.id }); if (!user) { await this.userQuery.createUser({ githubId: user_info_res.data.id, email: user_info_res.data.email, name: user_info_res.data.name, }); } return response.data; } }
- UserQuery(SQL操作)
import { Injectable } from '@nestjs/common'; import { PrismaService } from './prisma.service'; import { User, Prisma } from '@prisma/client'; @Injectable() export class UserQuery { constructor(private prisma: PrismaService) {} async user( userWhereUniqueInput: Prisma.UserWhereUniqueInput, ): Promise<User | null> { return this.prisma.user.findUnique({ where: userWhereUniqueInput, }); } async createUser(data: Prisma.UserCreateInput): Promise<User> { return this.prisma.user.create({ data, }); } async updateUser(params: { where: Prisma.UserWhereUniqueInput; data: Prisma.UserUpdateInput; }): Promise<User> { const { where, data } = params; return this.prisma.user.update({ data, where, }); } }
デバッグ時に把握すること(コードを読むときに意識すること)
入力から出力までに大まかに起きていること(巨大なコードだと全部をまともに見ていられない)
理解できない処理がある場合に何が理解できていないか
扱っているコードのみで完結する処理なのかどうか(データベースや、外部の認証基盤、インフラサービスとの連携がある処理かどうか)
どうやってデバックするか
- 結論なのですが、ではどうやってデバックするかというと、開発者が書いたコードのうち、デバックしたい一連の処理の一番最初の部分にブレークポイントを貼って、以降の処理を読んでいく というのが良い方法なのかなと思っています。(コードの内容をまったく把握していないときは) 実際にどうやってやるかを見ていきましょう。
ブレークポイントを貼る
- サーバー側(エンドポイントの一番最初の処理に、ブレークポイントを貼る)
とりあえず、ログイン画面を見てみる
- この状態で、launch.jsonでフロントのデバック設定を入れてログイン画面を開いてみます。
1番最初にAuthProviderの部分でコードが止まるはずです。
localStorage.getItem('accessToken') !== null
ローカルストレージのaccessTokenという判定をして、isLoginにBoolの値を入れて、コンポーネントを返していることがわかります。 (もしこの時点で、処理の内容が全くわからなければ調べます。: createContext – React )
処理を進めると、ログイン画面のコンポーネントで、上記のAuthContextから渡された値をもとに、画面上のログイン or 未ログインの切り替えが行われていることがわかります。
ということは、これからの見るログイン処理のなかでは、何かしらで、
localStorage.getItem('accessToken') !== null
がTrueになり、その状態でAuthProviderのコードが実行されて画面表示が行われることになるはずですね。
実際にログインのフローを見ていく
- さっそく、ログイン処理の中身を見たいのですが、肝心のログインボタンのリンクの意味が説明無しだとよくわからないはずです。
こういう外部の処理を使っているコードで、外部サービスの仕様が全くわからない場合は、把握が難しいので知っている人に聞きましょう
(大体は、どこかにドキュメントや仕様書があるはずですが)
<a href="https://github.com/login/oauth/authorize?client_id=Iv23livPRpM0vcEU4CYp" className="font-bold"> ログイン </a>
- ということで今回使用するGithubAPIのドキュメントです。(今回は、ユーザーがGithub上で認証をするとログイン処理のURLへcallbackされます)
GitHub アプリのユーザー アクセス トークンの生成 - GitHub Docs
では、ボタンを押して処理を見てみます。
ログイン処理ロジックを見てみる
一度AuthProviderで処理が止まりますが、処理をすすめると、ログイン処理を行うコンポーネントで止まるはずです
const data = await login(code)
の部分の処理を見たいのでステップ・インして確認します。
- どうやらサーバー側にリクエストを送っていそうですね。ということで処理を進めてサーバー側の処理を見てみます。
const response = await fetch('http://localhost:8080/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ code }), })
サーバー側の処理を見る
- 先のほどのフロントのコードを進めると、今度はサーバー側のコードで処理が止まるはずです。
const response = await this.authService.login(code);
の処理が見たいのでステップ・インします
ひどく見づらいコードですが、1つずつコードを読めば下記のような処理になっていることがわかるかと思います。
- Githubからアクセストークンを受け取るためのリクエストを投げる
- 取得したアクセストークンを使用して、Githubからユーザー情報を取得
- 取得したユーザー情報のうち、idを使用してユーザー情報を検索
- ユーザーがいなければ、DBにユーザーを登録、いればなにもしない
- 1で取得したレスポンスを返す
再度フロント側のコードを見る
サーバー側からのレスポンスを取得して、ローカルストレージにアクセストークン、リフレッシュトークンを保存して、トップページにリダイレクトしていることがわかります。
リダイレクト後は、最初にログイン画面を見たときの処理と同じですが、ローカルストレージへの保存が行われているため、localStorage.getItem('accessToken')がTrueになり、
ログイン状態で画面が表示されることがわかるかと思います。
以上の作業で、今回のログイン処理の大まかな内容は把握できたかと思います。
おわりに
- 実際に仕事で使うコードは、もっと巨大なものですが、コードを理解するうえで、入力から出力までの流れを見る手順はだいたい同じような手順になるのかなと思ってます。
- 今回の手順をもとに、コード上で起きている問題の解決や、原因部分の特定がしやすくなれば幸いです。