default eye-catch image.

React+Next.jsでDummy JSONのCRUDをCSR/SSRの両方で作成して違いを調べてみた話

Next.jsでCRUDを作りSSRの挙動を調べてみた。いったんPage Routerを選択。 バックエンドとなるAPIとしてDummyJSONのPosts-Docs APIを使用した。 一覧、詳細、更新、削除が用意される。ただし更新、削除はダミーで永続化されない。 [clink implicit=\"false\" url=\"https://dummyjson.com/docs/posts\" imgurl=\"https://dummyjson.com/public/img/hero-image.svg\" title=\"Free Fake REST API for Placeholder JSON Data\" excerpt=\"Develop, Build, Test.Get instant dummy JSON data for your frontend with DummyJSON Server — no backend setup needed!\"] 目次は以下。 [arst_toc tag=\"h4\"] 構成 CSR版/SSR版の2パターンについてCRUDを行うアプリをClaude(Sonnet4.5)で環境を構築した。 ルーティングについては今回の調査範囲外のため、いったんシンプルなPage Routerを使用した。 npm run dev で next dev --turbopack が動く何かが作られた。turbopackはrust製のwebpackの後継。 いったん実行環境の詳細な把握をスキップして上物の理解を進めることにする。上物の構成は以下。 . ├── app/ │ ├── page.tsx # ホームページ │ └── posts/ │ ├── page.tsx # 投稿一覧選択(CSR/SSR) │ ├── csr/ │ │ ├── page.tsx # 投稿一覧(Client Component版) │ │ ├── [id]/ │ │ │ ├── page.tsx # 投稿詳細(Client Component版) │ │ │ └── edit/ │ │ │ └── page.tsx # 投稿編集(Client Component版) │ │ └── new/ │ │ └── page.tsx # 新規投稿作成(Client Component版) │ ├── ssr/ │ │ ├── page.tsx # 投稿一覧(Server Component版) │ │ ├── [id]/ │ │ │ ├── page.tsx # 投稿詳細(Server Component版) │ │ │ └── edit/ │ │ │ └── page.tsx # 投稿編集(Server Actions版) │ │ └── new/ │ │ └── page.tsx # 新規投稿作成(Server Actions版) │ └── _components/ │ └── DeleteButton.tsx # 削除ボタン(Client Component) ├── lib/ │ └── api.ts # API関数 ├── types/ │ └── post.ts # 型定義 ├── Dockerfile # Dockerイメージ設定 ├── docker-compose.yml # Docker Compose設定 └── next.config.ts # Next.js設定 以下のようなアプリができた。 [video width=\"2280\" height=\"1792\" webm=\"https://ikuty.com/wp-content/uploads/2025/10/recording.webm\"][/video] ReactとNext.jsの関係性と役割分担 ReactはUIを作るためのJavaScriptライブラリとして機能する。コンポーネント、フック、JSXを提供する。 Next.jsはReactを使ったフレームワークであり、ルーティング、ビルド、最適化などの機能を提供する。 React (ライブラリ) Next.js (フレームワーク) 役割の焦点 UI (ユーザーインターフェース) 構築 Webアプリケーション全体の構築 主な提供物 コンポーネント (UI要素)、JSX、Hooks (状態管理など) ルーティング、レンダリング戦略、最適化機能、バックエンド連携 ルーティング 非搭載。別途React Routerなどのライブラリが必要。 ファイルベースルーティングが組み込みで提供される。 レンダリング クライアントサイドレンダリング (CSR) が基本。ブラウザでJavaScriptが実行されてUIを描画する。 プリレンダリング (SSR/SSG) が組み込みで提供される。レンディングのタイミングと場所を制御する。 データ取得 非搭載。fetch APIなどをコンポーネント内で使用する。 データ取得パターン (Server Components, Route Handlersなど) とキャッシュの仕組みが組み込みで提供される。 クライアントサイドレンダリング(CSR) ブラウザ側で動的にHTMLを生成する。useState、useEffect、イベントハンドラが使える。 Step1.初期レンダリング(サーバ側) app/layout.tsx がサーバーで実行される。<html><body>の枠組みを作る。 app/posts/[id]/page.tsxがClinet Componentとして認識され初期HTMLを作る。 ブラウザに初期HTMLを送り、ブラウザは初期HTMLを表示する // app/layout.tsx (20-34行目) export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang=\"en\"> <body> {children} {/* ← ここに子コンポーネントが入る */} </body> </html> ); } ... //ブラウザに送られる初期HTMLの例 <html> <body> <div class=\"min-h-screen p-8\"> <p>読み込み中...</p> ← loading=trueの状態 </div> <script src=\"/_next/...\"></script> ← クライアント用JS </body> </html> Step2.ハイドレーション(Hydration) JavaScriptが読み込まれる Reactがコンポーネントを「水分補給」(Hydrate)、HTMLに機能を追加 初期state: loading = true, post = null Step3.useEffectの実行(副作用) コンポーネントが画面に表示された直後に1回実行される。 // app/posts/[id]/page.tsx (16-18行目) useEffect(() => { loadPost(); // ← コンポーネントがマウントされたら実行 }, [params.id]); Step4.データフェッチとstate更新 api.getPost()を実行。-> fetch(\'https://dummyjson.com/posts/1\') const data でレスポンスを受け取る setPost(data)でstateを更新 setLoading(false)でローディング終了。loading=false // app/posts/[id]/page.tsx (20-32行目) const loadPost = async () => { try { setLoading(true); // ローディング表示 const data = await api.getPost(Number(params.id)); // API呼び出し setPost(data); // ← state更新! setError(null); } catch (err) { setError(\'投稿の読み込みに失敗しました\'); } finally { setLoading(false); // ← state更新! } }; Step5.再レンダリング(stateが変わったので) post stateが更新されたので再レンダリング 条件分岐を再評価 最終的なJSXをDOMに反映 // app/posts/[id]/page.tsx (47-55行目) if (loading) { // loading = false なので通過 return 読み込み中...; } if (error || !post) { // error = null, post = データあり なので通過 return エラー表示; } // ここが実行される! return ( <div className=\"min-h-screen p-8\"> <h1>{post.title}</h1> {/* ← post.title を表示 */} <p>{post.body}</p> {/* ← post.body を表示 */} {/* ... */} </div> ); Step6.リストの動的レンダリング // app/posts/[id]/page.tsx (133-140行目) {post.tags.map((tag, index) => ( // ← 配列をループ <span key={index} className=\"...\"> {tag} {/* ← 各タグを表示 */} </span> ))} 実行結果: post.tags = [\"history\", \"american\", \"crime\"] ↓ map() で変換 <span key={0}>history</span> <span key={1}>american</span> <span key={2}>crime</span> 全体のレンダリングフロー [ユーザーが /posts/1 にアクセス] ↓ ┌──────────────────────────────┐ │ サーバー側(Next.js Server) │ ├──────────────────────────────┤ │ 1. app/layout.tsx を実行 │ │ → を生成 │ │ │ │ 2. app/posts/[id]/page.tsx │ │ を「クライアントコンポーネント」│ │ として認識 │ │ → 初期HTML生成 │ │ (loading=true状態) │ └──────────────────────────────┘ ↓ HTML + JS送信 ┌──────────────────────────────┐ │ ブラウザ側(Client) │ ├──────────────────────────────┤ │ 3. HTMLを表示 │ │ 「読み込み中...」 │ │ │ │ 4. JavaScriptロード │ │ → Hydration実行 │ │ │ │ 5. useEffect発火 │ │ → loadPost()実行 │ │ │ │ 6. API呼び出し │ │ fetch(https://dummyjson.com/posts/1) │ ↓ │ │ レスポンス受信 │ │ │ │ 7. setState実行 │ │ setPost(data) │ │ setLoading(false) │ │ ↓ │ │ 8. 再レンダリング │ │ → 投稿詳細を表示 │ └──────────────────────────────┘ サーバーサイドレンダリング(SSR) サーバ側でHTMLが生成される。DBやAPIに直接アクセスできる。useState,useEffectを使わない。 例えば、当アプリにおいて / へのアクセスに対してNext.jsが app/page.tsx を実行する。 HTMLを生成してブラウザに送信し、ブラウザはHTMLを表示する。 1周回って戻ってきたというか、LaravelやRailsにフロントエンドから寄っていくスタイル。 バックエンドにAPIを用意せずDBを直接操作できるため、SPAが不要な簡易的な管理画面など、 大幅な工数削減が可能になると思う。 Laravel,Railsだと、フロントエンドの記述にVue/Reactを導入する必要があるため、 バックエンド・フロントエンド、という棲み分けが発生してしまうが、 Next.jsのSSR(+CSR混在)により、フロントエンドとバックエンドを同じ仕組みで実現できる点で 管理する対象が大幅に減るのもメリットだと思う。 import Link from \'next/link\'; import { api } from \'@/lib/api\'; import DeleteButton from \'@/app/posts/_components/DeleteButton\'; // Server Component(デフォルト) // \'use client\' ディレクティブがないため、サーバー側で実行される export default async function PostsPageSSR() { // サーバー側で直接データ取得 // useEffect や useState は不要 const data = await api.getPosts(); const posts = data.posts; return ( <div className=\"min-h-screen p-8 bg-gray-50\"> <div className=\"max-w-4xl mx-auto\"> {/* ヘッダー部分 */} <div className=\"bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6\"> <p className=\"text-sm text-blue-800\"> <strong>Server Component版 - このページはサーバー側でレンダリングされ、HTMLに既にデータが含まれています </p> </div> ... ); } SSRとCSRの統合 SSRモードとCSRモードの2つのモードが存在する訳ではなく、SSRとCSRは同時に存在し得る。 例えば、今回作成したSSR版アプリの投稿一覧画面において、CSRで削除ボタンを実現している。 コンポーネント単位でSSR/CSRの分離が起こるだけで、アーキ全体ではSSRとCSRは混在できる。 TypeScriptにより型安全にpropsを渡せるし、状態管理がReactの仕組みで統一できる。 部分的な更新は可能 (router.refresh() )。 // app/posts/ssr/page.tsx (Server Component) export default async function PostsPageSSR() { const data = await api.getPosts(); // サーバー側で実行 const posts = data.posts; return ( <div> {posts.map(post => ( <div key={post.id}> <h2>{post.title}</h2> {/* Client Componentをそのまま埋め込める */} <DeleteButton postId={post.id} /> </div> ))} </div> ); } // app/posts/_components/DeleteButton.tsx (Client Component) \'use client\'; export default function DeleteButton({ postId }: { postId: number }) { const router = useRouter(); const handleDelete = async () => { if (!confirm(\'削除しますか?\')) return; await api.deletePost(postId); router.refresh(); // この部分だけ更新 }; return ( <button onClick={handleDelete}>削除</button> ); } まとめ Next.jsのHello WorldをしつつSSRとCSRの挙動を確認した。 フロント側フレームワークの枠組みを越え、フロント・バックエンドを統一的に扱えることを確認した。 アプリケーションの要件次第で、SSRを中心に部分的にCSRとすることで大幅な工数削減を期待できそう。

default eye-catch image.

go-txdbを使ってgolang, gin, gorm(gen)+sqlite構成のAPI をテストケース毎に管理する

データベース依存のテストケースを作る際に、テストケース毎にDBがクリーンな状態を維持したい。 go-txdbはDBへの接続時にトランザクションを開始、切断時にトランザクションを終了するSQLドライバ。 テスト実行中にトランザクション内で発行したステートメント・行はテスト終了時には消滅する。 DB毎に実装方法は異なり、例えばSQLiteでは\"トランザクション\"ではなくsaveponitで実装される。 [clink implicit=\"false\" url=\"https://github.com/DATA-DOG/go-txdb\" imgurl=\"https://avatars.githubusercontent.com/u/6613360?s=48&v=4\" title=\"Single transaction based sql.Driver for GO\" excerpt=\"Package txdb is a single transaction based database sql driver. When the connection is opened, it starts a transaction and all operations performed on this sql.DB will be within that transaction. If concurrent actions are performed, the lock is acquired and connection is always released the statements and rows are not holding the connection.\"] [arst_toc tag=\"h4\"] 環境構築 Claude Code (Sonnet4.5) で以下の環境を構成した。途中15回のエラーリカバリを挟んで 期待通りの環境が出来上がった。 main.goがアプリケーションのルーティング(ハンドラ共有)、 main_test.goが main.goのルートに対するテスト。テストにはTestMain()が含まれている。 test_repository_test.goはGinが生成したリポジトリ層(モデル)をルートを経由せずテストする。 $ tree . -L 2 . ├── data │   └── db.sqlite # SQLite DBファイル ├── docker-compose.yml # Go+sqlite ├── Dockerfile # golang:1.23-alpineベース ├── gen.go # GORM Genコード生成スクリプト ├── go.mod # 依存関係の定義(go getやgo mod tidyで更新) ├── go.sum # 依存関係の検証用ハッシュ(自動) ├── init.sql # DDL,初期レコード ├── main.go # Gin初期化,ルーティング ├── main_test.go # main.goのルーティングに対するテストコード ├── models # モデル │   ├── model # testsテーブルと対応する構造体定義 (自動生成) │   └── query # (自動生成) ├── repository │   └── test_repository_test.go # リポジトリ層(データアクセス層)のテスト └── testhelper └── db.go # TxDB初期化等テスト用ヘルパー サンプルデータの準備 testsというテーブルに id, value というカラムを用意し、hoge, fuga レコードを挿入しておく。 簡略化のためにSQLiteを使用しており、ホスト側のファイルをbindマウントし初期実行判定して投入した。 -- Create tests table CREATE TABLE IF NOT EXISTS tests ( id INTEGER PRIMARY KEY, value TEXT NOT NULL ); -- Insert initial data INSERT OR IGNORE INTO tests (id, value) VALUES (1, \'hoge\'); INSERT OR IGNORE INTO tests (id, value) VALUES (2, \'fuga\'); CRUD ルーティング gin, gorm(gen) を使用して testsテーブルに対するCRUDを行う以下のルートを定義した。 それぞれ、genを使用しGolang言語のレベルでオブジェクトを操作している。 | メソッド | エンドポイント| 説明 | 仕様 | |--------|------------|----------------|-------------------------------------------------| | GET | /hello | 全レコード取得 | Find()で全レコードを取得し返却 | | GET | /hello/:id | 指定IDのレコード取得 | URLパラメータからIDを取得し、該当レコードを返却 | | POST | /hello | 新規レコード追加 | JSONリクエストボディからidとvalueを受け取り新規作成 | | PATCH | /hello/:id | 指定IDのレコード更新 | URLパラメータのIDとJSONボディのvalueでレコード更新 | | DELETE | /hello/:id | 指定IDのレコード削除 | URLパラメータのIDでレコード削除. | 各ハンドラの詳細な実装は冗長なので割愛。 手動リクエストと応答 各エンドポイント に対するリクエストとレスポンスの関係は以下。期待通り。 # 全件取得し応答 $ curl http://localhost:8080/hello [{\"id\":1,\"value\":\"hoge\"},{\"id\":2,\"value\":\"fuga\"}] # id=1を取得し応答 $ curl http://localhost:8080/hello/1 {\"id\":1,\"value\":\"hoge\"} # id=3を追加 $ curl -X POST http://localhost:8080/hello -H \"Content-Type: application/json\" -d \'{\"id\":3,\"value\":\"piyo\"}\' {\"id\":3,\"value\":\"piyo\"} $ curl http://localhost:8080/hello [{\"id\":1,\"value\":\"hoge\"},{\"id\":2,\"value\":\"fuga\"},{\"id\":3,\"value\":\"piyo\"}] # id=3を変更 $ curl -X PATCH http://localhost:8080/hello/3 -H \"Content-Type: application/json\" -d \'{\"value\":\"updated_piyo\"}\' {\"id\":3,\"value\":\"updated_piyo\"} # id=3を削除 $ curl -X DELETE http://localhost:8080/hello/3 {\"message\":\"record deleted successfully\"} # 全件取得し応答 $ curl http://localhost:8080/hello [{\"id\":1,\"value\":\"hoge\"},{\"id\":2,\"value\":\"fuga\"}] txdbを使用するためのテスト用ヘルパー関数 txdbを使用するためのテスト用ヘルパー関数を以下のように定義しておく。 package testhelper import ( \"database/sql\" \"fmt\" \"os\" \"sync\" \"sync/atomic\" \"github.com/DATA-DOG/go-txdb\" _ \"github.com/mattn/go-sqlite3\" \"gorm.io/driver/sqlite\" \"gorm.io/gorm\" ) var ( once sync.Once connID atomic.Uint64 ) // SetupTxDB initializes txdb driver for testing func SetupTxDB() { once.Do(func() { // Get database path dbPath := os.Getenv(\"DB_PATH\") if dbPath == \"\" { dbPath = \"./data/db.sqlite\" } // Register txdb driver with SQLite-specific options // Use WAL mode and configure for better concurrent access dsn := fmt.Sprintf(\"%s?_journal_mode=WAL&_busy_timeout=5000\", dbPath) txdb.Register(\"txdb\", \"sqlite3\", dsn) }) } // NewTestDB creates a new test database connection with txdb // Each connection will be isolated in a transaction and rolled back after test func NewTestDB() (*gorm.DB, error) { SetupTxDB() // Open connection using txdb driver with unique connection ID // This ensures each test gets its own isolated transaction id := connID.Add(1) sqlDB, err := sql.Open(\"txdb\", fmt.Sprintf(\"connection_%d\", id)) if err != nil { return nil, fmt.Errorf(\"failed to open txdb connection: %w\", err) } // Wrap with GORM db, err := gorm.Open(sqlite.Dialector{ Conn: sqlDB, }, &gorm.Config{}) if err != nil { return nil, fmt.Errorf(\"failed to open gorm connection: %w\", err) } return db, nil } テストの命名規則と共通処理 テストの関数名はTestXXX()のようにTestから始まりキャメルケースを続ける。 TestMain()内に全ての処理の前に実行する処理、後に実行する処理を記述できる。 package main import ( \"bytes\" \"encoding/json\" \"net/http\" \"net/http/httptest\" \"os\" \"testing\" \"gin_txdb/testhelper\" \"github.com/gin-gonic/gin\" \"github.com/stretchr/testify/assert\" \"github.com/stretchr/testify/require\" ) func TestMain(m *testing.M) { // Set DB_PATH for testing os.Setenv(\"DB_PATH\", \"./data/db.sqlite\") // Set Gin to test mode gin.SetMode(gin.TestMode) // Run tests code := m.Run() os.Exit(code) } 全件取得のテスト ヘルパー関数のNewTestDB()を使用することでtxdbを使用してDBに接続している。 defer func()内でコネクションを明示的にクローズする処理を遅延評価(=テスト完了時評価)しているが、 テスト実行中にエラーやpanicが起きた場合に開いたDBを切ることができなくなる問題への対処。 特にSQLiteの場合「接続は常に1つ」なので、切り忘れで接続が開きっぱなしになると、 次のテスト実行でロックエラーが発生する。明示的に閉じることでこの問題を確実に回避できる。 後はアサートを書いていく。 func TestGetAllTests(t *testing.T) { // Setup test database with txdb db, err := testhelper.NewTestDB() require.NoError(t, err) defer func() { sqlDB, _ := db.DB() sqlDB.Close() }() // Setup router using main.go\'s SetupRouter router := SetupRouter(db) // Create request req, _ := http.NewRequest(http.MethodGet, \"/hello\", nil) w := httptest.NewRecorder() // Perform request router.ServeHTTP(w, req) // Assert response assert.Equal(t, http.StatusOK, w.Code) var response []map[string]interface{} err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) // Should have 2 initial records assert.Len(t, response, 2) assert.Equal(t, float64(1), response[0][\"id\"]) assert.Equal(t, \"hoge\", response[0][\"value\"]) assert.Equal(t, float64(2), response[1][\"id\"]) assert.Equal(t, \"fuga\", response[1][\"value\"]) } このテストだけ実行してみる。-run オプションでテスト名を指定する。 $ go test -run TestGetAllTests [GIN] 2025/10/15 - 17:17:44 | 200 | 238.666µs | | GET \"/hello\" PASS ok gin_txdb 0.496s 1件取得のテスト(正常系) [GET] /hello/:id のテスト。指定したIDが存在する正常系。 func TestGetTestByID_Success(t *testing.T) { // Setup test database with txdb db, err := testhelper.NewTestDB() require.NoError(t, err) defer func() { sqlDB, _ := db.DB() sqlDB.Close() }() // Setup router router := SetupRouter(db) // Create request req, _ := http.NewRequest(http.MethodGet, \"/hello/1\", nil) w := httptest.NewRecorder() // Perform request router.ServeHTTP(w, req) // Assert response assert.Equal(t, http.StatusOK, w.Code) var response map[string]interface{} err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Equal(t, float64(1), response[\"id\"]) assert.Equal(t, \"hoge\", response[\"value\"]) } 実行結果は以下の通り。 go test -run TestGetTestByID_Success [GIN] 2025/10/15 - 17:24:41 | 200 | 207.25µs | | GET \"/hello/1\" PASS ok gin_txdb 0.330s 1件取得のテスト(異常系) [GET] /hello/:idのテスト。指定したIDが見つからない異常系。 func TestGetTestByID_NotFound(t *testing.T) { // Setup test database with txdb db, err := testhelper.NewTestDB() require.NoError(t, err) defer func() { sqlDB, _ := db.DB() sqlDB.Close() }() // Setup router router := SetupRouter(db) // Create request for non-existent ID req, _ := http.NewRequest(http.MethodGet, \"/hello/999\", nil) w := httptest.NewRecorder() // Perform request router.ServeHTTP(w, req) // Assert response assert.Equal(t, http.StatusNotFound, w.Code) var response map[string]interface{} err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Equal(t, \"record not found\", response[\"error\"]) } 実行結果は以下の通り。 go test -run TestGetTestByID_NotFound ./gin_txdb/main.go:52 record not found [0.105ms] [rows:0] SELECT * FROM `tests` WHERE `tests`.`id` = 999 ORDER BY `tests`.`id` LIMIT 1 [GIN] 2025/10/15 - 17:22:45 | 404 | 542.875µs | | GET \"/hello/999\" PASS ok gin_txdb 0.672s 1件追加のテスト(正常系) [POST] /helloが正常終了した場合、追加したレコードをレスポンスで返す処理のため、 レスポンスで返ってきたデータをアサートしている。 その後、[GET] /hello/:id のレスポンスを使ってアサートしている。 func TestCreateTest_Success(t *testing.T) { // Setup test database with txdb db, err := testhelper.NewTestDB() require.NoError(t, err) defer func() { sqlDB, _ := db.DB() sqlDB.Close() }() // Setup router router := SetupRouter(db) // Create request body payload := map[string]interface{}{ \"id\": 100, \"value\": \"test_value\", } body, _ := json.Marshal(payload) // Create request req, _ := http.NewRequest(http.MethodPost, \"/hello\", bytes.NewBuffer(body)) req.Header.Set(\"Content-Type\", \"application/json\") w := httptest.NewRecorder() // Perform request router.ServeHTTP(w, req) // Assert response assert.Equal(t, http.StatusCreated, w.Code) var response map[string]interface{} err = json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Equal(t, float64(100), response[\"id\"]) assert.Equal(t, \"test_value\", response[\"value\"]) // Verify the record was actually created req2, _ := http.NewRequest(http.MethodGet, \"/hello/100\", nil) w2 := httptest.NewRecorder() router.ServeHTTP(w2, req2) assert.Equal(t, http.StatusOK, w2.Code) } 実行結果は以下の通り。 $ go test -run TestCreateTest_Success [GIN] 2025/10/15 - 17:30:04 | 201 | 398.167µs | | POST \"/hello\" [GIN] 2025/10/15 - 17:30:04 | 200 | 47.625µs | | GET \"/hello/100\" PASS ok gin_txdb 0.505s 1件追加のテスト(異常なパラメタ。異常系) testsレコードはid,valueカラムを持つ。idのみ(valueなし)を渡した場合400エラーを返す。 func TestCreateTest_MissingFields(t *testing.T) { // Setup test database with txdb db, err := testhelper.NewTestDB() require.NoError(t, err) defer func() { sqlDB, _ := db.DB() sqlDB.Close() }() // Setup router router := SetupRouter(db) // Create request body with missing value field payload := map[string]interface{}{ \"id\": 100, } body, _ := json.Marshal(payload) // Create request req, _ := http.NewRequest(http.MethodPost, \"/hello\", bytes.NewBuffer(body)) req.Header.Set(\"Content-Type\", \"application/json\") w := httptest.NewRecorder() // Perform request router.ServeHTTP(w, req) // Assert response assert.Equal(t, http.StatusBadRequest, w.Code) } 実行結果は以下の通り。 期待通り400エラーが返ったことをアサートできた。 go test -run TestCreateTest_MissingFields [GIN] 2025/10/15 - 17:36:49 | 400 | 139.709µs | | POST \"/hello\" PASS ok gin_txdb 0.501s txdbが正しくトランザクションを分離していることのテスト Claude Code (Sonnet4.5) が (指示していないのに) 自動的にこのテストを作成してくれた。 お勉強を兼ねたテストプロジェクトであることを伝えたために、気を利かせてくれた感がある。 以下をテストする。 トランザクション内での一貫性 (トランザクション内で作成したデータを同一トランザクション内で観察できる) トランザクション間の分離 (別のトランザクションで作成したデータを観察できない。テストは独立している) 自動ロールバックの動作確認 (txdbがClose()時に自動的にロールバックを実行している。DBは初期状態に戻る) あくまで、一貫性、分離、ロールバックの一例を見せてもらうだけなのだが、 こういうことをやりたいのだな、という背景を理解できたのでお勉強としては十分。 func TestTransactionIsolation(t *testing.T) { // This test demonstrates that each test gets isolated transactions t.Run(\"Test1_CreateRecord\", func(t *testing.T) { db, err := testhelper.NewTestDB() require.NoError(t, err) defer func() { sqlDB, _ := db.DB() sqlDB.Close() }() router := SetupRouter(db) // Create a new record payload := map[string]interface{}{ \"id\": 200, \"value\": \"test200\", } body, _ := json.Marshal(payload) req, _ := http.NewRequest(http.MethodPost, \"/hello\", bytes.NewBuffer(body)) req.Header.Set(\"Content-Type\", \"application/json\") w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusCreated, w.Code) // Verify it exists in this transaction req2, _ := http.NewRequest(http.MethodGet, \"/hello/200\", nil) w2 := httptest.NewRecorder() router.ServeHTTP(w2, req2) assert.Equal(t, http.StatusOK, w2.Code) }) t.Run(\"Test2_VerifyRollback\", func(t *testing.T) { db, err := testhelper.NewTestDB() require.NoError(t, err) defer func() { sqlDB, _ := db.DB() sqlDB.Close() }() router := SetupRouter(db) // The record created in Test1 should not exist (rolled back) req, _ := http.NewRequest(http.MethodGet, \"/hello/200\", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) // Should still have only 2 original records req2, _ := http.NewRequest(http.MethodGet, \"/hello\", nil) w2 := httptest.NewRecorder() router.ServeHTTP(w2, req2) var response []map[string]interface{} json.Unmarshal(w2.Body.Bytes(), &response) assert.Len(t, response, 2) }) } まとめ go-txdbを使うことで、テストケース毎にDBを分離できることを確認した。 あればかなり便利だと思う。

default eye-catch image.

gorm互換の型安全なORMであるgenでCRUD APIを試作

GolangとGinでAPIを書くために必要な要素技術を学習中、 ORMは何が良いか考えてみたが、gormは無いなぁと思うに至った。 ORマッパーがどの程度の抽象化を担うべきか、については答えはないと思うが、 Webアプリケーションのシナリオで出てくるテーブル構造と関係程度は完全にSQLを排除して欲しい。 SQLを排除することで可読性が向上するし、静的型付けによる恩恵を得られる。 Genには以下のような特徴がある。 型安全: コンパイル時にエラー検出 自動補完: IDEでメソッドとフィールドが補完される クエリビルダー: Where(q.Product.Name.Like(\"%...\"))のような直感的なAPI GORM互換: 既存のGORMモデルをそのまま使用可能 なぜ\"Gen\"なのかは、ビルド時にGolangコードから静的に(ビルド前に)オブジェクトにアクセスする ために必要なGoオブジェクトを生成する、という仕組みから来ているのではないかと思う。 [clink implicit=\"false\" url=\"https://gorm.io/gen/index.html\" imgurl=\"https://gorm.io/gorm.svg\" title=\"Gen Guides\" excerpt=\"GEN: Friendly & Safer GORM powered by Code Generation.Idiomatic & Reusable API from Dynamic Raw SQL.100% Type-safe DAO API without interface{}.Database To Struct follows GORM conventions.GORM under the hood, supports all features, plugins, DBMS that GORM supports.\"] [arst_toc tag=\"h4\"] 環境構築 サクッとClaudeで環境を作った。実際に商用環境を作るとしたら必要な理解の度合いは上がるだろうが、 試してみるまでの時間が無駄にかかって勿体無いのと、Claudeに入口を教わるのは悪くない。 以下の構成で、Golang+GinにCRUDルートを設定しgenを介してDBアクセスできる。 models以下にテーブルと対応する型定義された構造体が格納される。 また、query以下にGormレベルの操作をGen(Golang)レベルに抽象化する自動生成コードが格納される。 query以下を読むと、GenがGormのラッパーであることが良くわかる。 $ tree . -n 2 . ├── cmd │   └── generate │   └── main.go # マイグレーション ├── config │   └── database.go # DB接続設定 ├── database │   └── database.go # Conenct(), Close(), GetDB()など ├── docker-compose.yml # Golangアプリケーション(8080), PostgreSQL(5432) ├── Dockerfile ├── go.mod ├── go.sum ├── handlers │   └── product.go ├── main.go # CRUD APIのルーティング ├── models │   └── product.go # テーブル->モデル ├── query │   ├── gen.go # モデルを操作するラッパー │   └── products.gen.go # SQLレベルのモデル操作をGolangレベルに抽象化するためのIF └── README.md CRUDルート 早速、CRUD APIのルートを作っていく。Claudeにお任せしたところ商品(Product)のCRUD APIが出来た。 その位置にMigrate置くの本当に良いの? という感があるが、本題はそこではないので省略。 package main import ( \"log\" \"github.com/gin-gonic/gin\" \"github.com/gin-gonic/gin/binding\" \"github.com/ikuty/golang-gin/database\" \"github.com/ikuty/golang-gin/handlers\" \"github.com/ikuty/golang-gin/models\" \"github.com/ikuty/golang-gin/query\" ) func main() { // データベース接続 if err := database.Connect(); err != nil { log.Fatalf(\"Failed to connect to database: %v\", err) } defer database.Close() // マイグレーション実行 db := database.GetDB() if err := db.AutoMigrate(&models.Product{}); err != nil { log.Fatalf(\"Failed to migrate database: %v\", err) } // Gen初期化 query.SetDefault(db) // Ginエンジンの初期化 r := gin.Default() // 8. GORM + PostgreSQL - CRUD操作 r.GET(\"/api/products\", handlers.GetProductsHandler) // 全商品取得 r.GET(\"/api/products/:id\", handlers.GetProductHandler) // 商品詳細取得 r.POST(\"/api/products\", handlers.CreateProductHandler) // 商品作成 r.PUT(\"/api/products/:id\", handlers.UpdateProductHandler) // 商品更新 r.DELETE(\"/api/products/:id\", handlers.DeleteProductHandler) // 商品削除 r.GET(\"/api/products/search\", handlers.SearchProductsHandler) // 商品検索 // サーバー起動 r.Run(\":8080\") } モデル さて、モデル定義(=テーブル構造)はどうなっているかというと、以下の通り。 フィールドの物理型をGenを介してGolangで厳密で管理できるのは動的型付け言語にはない利点。 package models import ( \"time\" \"gorm.io/gorm\" ) // Product は商品モデル type Product struct { ID uint `gorm:\"primarykey\" json:\"id\"` Name string `gorm:\"size:100;not null\" json:\"name\" binding:\"required\"` Description string `gorm:\"size:500\" json:\"description\"` Price float64 `gorm:\"not null\" json:\"price\" binding:\"required,gt=0\"` Stock int `gorm:\"default:0\" json:\"stock\"` Category string `gorm:\"size:50\" json:\"category\"` CreatedAt time.Time `json:\"created_at\"` UpdatedAt time.Time `json:\"updated_at\"` DeletedAt gorm.DeletedAt `gorm:\"index\" json:\"-\"` } // TableName はテーブル名を指定 func (Product) TableName() string { return \"products\" } ハンドラ(商品詳細取得) 素晴らしい。説明が不要なくらいDBアクセスが抽象化されている。 ただ、依存性注入があるEloquentと比べるとロジックと関係ない冗長な処理が残っている。 db,q,Contextは裏側に隠して欲しいという思いはあるものの、これでも良いかとも思う。 Find()はGenにより自動生成される。interfaceが用意されビルド時に全て解決される。 なお、VSCodeなどで補完が効く、というのは、例えば JetBrains環境であれば、 動的型付け言語であってもほぼ実現されているので、それほど実利があるメリットではない。 package handlers import ( \"net/http\" \"strconv\" \"github.com/gin-gonic/gin\" \"github.com/ikuty/golang-gin/database\" \"github.com/ikuty/golang-gin/models\" \"github.com/ikuty/golang-gin/query\" ) // GetProductsHandler は全商品を取得 func GetProductsHandler(c *gin.Context) { db := database.GetDB() q := query.Use(db) products, err := q.Product.WithContext(c.Request.Context()).Find() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ \"error\": \"Failed to fetch products\", }) return } c.JSON(http.StatusOK, gin.H{ \"data\": products, \"count\": len(products), }) } ハンドラ(指定の商品を取得) バリデータを介さず自力でバリデーション(IDがUintか)を行っている。 Productに対してWhereで条件指定し(Order By Ascした後に)先頭のオブジェクトを取得している。 もはや他に説明が必要ないくらい抽象化されていて良い。 // GetProductHandler は指定IDの商品を取得 func GetProductHandler(c *gin.Context) { id := c.Param(\"id\") idUint, err := strconv.ParseUint(id, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ \"error\": \"Invalid ID\", }) return } db := database.GetDB() q := query.Use(db) product, err := q.Product.WithContext(c.Request.Context()).Where(q.Product.ID.Eq(uint(idUint))).First() if err != nil { c.JSON(http.StatusNotFound, gin.H{ \"error\": \"Product not found\", }) return } c.JSON(http.StatusOK, gin.H{ \"data\": product, }) } ハンドラ(商品作成) 次はCreate。モデルオブジェクトを空から生成し入力値をバインドして整形した後に、 Create()に渡している。Create()の内部はGormレベルの(低レイヤの)コードが動く。 // CreateProductHandler は商品を作成 func CreateProductHandler(c *gin.Context) { var product models.Product if err := c.ShouldBindJSON(&product); err != nil { c.JSON(http.StatusBadRequest, gin.H{ \"error\": \"Invalid request\", \"details\": err.Error(), }) return } db := database.GetDB() q := query.Use(db) if err := q.Product.WithContext(c.Request.Context()).Create(&product); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ \"error\": \"Failed to create product\", }) return } c.JSON(http.StatusCreated, gin.H{ \"message\": \"Product created successfully\", \"data\": product, }) } ハンドラ(商品更新) 基本的にはCreate()と同じ。空モデルに入力値をバインドしUpdate()に渡している。 実行後に更新対象のオブジェクトを取得しているがEloquentは確か更新の戻りがオブジェクトだった。 // UpdateProductHandler は商品を更新 func UpdateProductHandler(c *gin.Context) { id := c.Param(\"id\") idUint, err := strconv.ParseUint(id, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ \"error\": \"Invalid ID\", }) return } db := database.GetDB() q := query.Use(db) ctx := c.Request.Context() // 既存の商品を取得 product, err := q.Product.WithContext(ctx).Where(q.Product.ID.Eq(uint(idUint))).First() if err != nil { c.JSON(http.StatusNotFound, gin.H{ \"error\": \"Product not found\", }) return } // 更新データをバインド var updateData models.Product if err := c.ShouldBindJSON(&updateData); err != nil { c.JSON(http.StatusBadRequest, gin.H{ \"error\": \"Invalid request\", \"details\": err.Error(), }) return } // 更新実行 _, err = q.Product.WithContext(ctx).Where(q.Product.ID.Eq(uint(idUint))).Updates(&updateData) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ \"error\": \"Failed to update product\", }) return } // 更新後のデータを取得 product, _ = q.Product.WithContext(ctx).Where(q.Product.ID.Eq(uint(idUint))).First() c.JSON(http.StatusOK, gin.H{ \"message\": \"Product updated successfully\", \"data\": product, }) } ハンドラ(論理削除) DeletedAtフィールドがNULLの場合、そのレコードはアクティブ。非Nullなら論理削除済み。 Unscoped()を介さずDelete()した場合(つまりデフォルトでは)論理削除となる。 DeletedAtは他のAPIから透過的に扱われる。論理削除状態かどうかは把握しなくて良い。 DeletedAtはデフォルトでは*time.Time型だが、のデータ形式の対応も可能。 // DeleteProductHandler は商品を削除(ソフトデリート) func DeleteProductHandler(c *gin.Context) { id := c.Param(\"id\") idUint, err := strconv.ParseUint(id, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ \"error\": \"Invalid ID\", }) return } db := database.GetDB() q := query.Use(db) // ソフトデリート実行 _, err = q.Product.WithContext(c.Request.Context()).Where(q.Product.ID.Eq(uint(idUint))).Delete() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ \"error\": \"Failed to delete product\", }) return } c.JSON(http.StatusOK, gin.H{ \"message\": \"Product deleted successfully\", }) } ハンドラ(商品検索) Where句を複数記述する場合など、手続き的に条件用のオブジェクトを足していける。 一見、productQueryを上から上書きしているように見えるが、Genのクエリビルダーはimmutableパターン として振る舞い、都度実行によりWhereの戻りとなるオブジェクトが累積していく動作となる。 // SearchProductsHandler は商品を検索 func SearchProductsHandler(c *gin.Context) { db := database.GetDB() q := query.Use(db) ctx := c.Request.Context() // クエリパラメータを取得 name := c.Query(\"name\") category := c.Query(\"category\") minPrice := c.Query(\"min_price\") maxPrice := c.Query(\"max_price\") // クエリビルダー productQuery := q.Product.WithContext(ctx) if name != \"\" { productQuery = productQuery.Where(q.Product.Name.Like(\"%\" + name + \"%\")) } if category != \"\" { productQuery = productQuery.Where(q.Product.Category.Eq(category)) } if minPrice != \"\" { if price, err := strconv.ParseFloat(minPrice, 64); err == nil { productQuery = productQuery.Where(q.Product.Price.Gte(price)) } } if maxPrice != \"\" { if price, err := strconv.ParseFloat(maxPrice, 64); err == nil { productQuery = productQuery.Where(q.Product.Price.Lte(price)) } } // 検索実行 products, err := productQuery.Find() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ \"error\": \"Failed to search products\", }) return } c.JSON(http.StatusOK, gin.H{ \"data\": products, \"count\": len(products), }) } 変換後のクエリを見てみる。 $ http://localhost:8080/api/products/search?name=Test&category=Electronics&min_price=1400&max_price=1600 SELECT * FROM \"products\" WHERE \"products\".\"name\" LIKE \'%Test%\' AND \"products\".\"category\" = \'Electronics\' AND \"products\".\"price\" >= 1400 AND \"products\".\"price\" <= 1600 AND "products"."deleted_at" IS NULL まとめ GolangのORMであるGormをラップしたGenを使って、CRUDを行うAPIをGinで書いて動かしてみた。 確かにGormレベル(SQLレベル)の記述が不要であることを確認した。 (まだ見ていないが)テーブルをJOINしていった先にGormを素で触らないといけない場面は発生するだろうが、 多くのシナリオでGenだけで行けるのであれば、Genを導入するメリットとなるのではないだろうか。

default eye-catch image.

Golang + Gin カスタムバリデーション

Golang+GinによるAPI構築で使いそうなフィーチャーを試してみるシリーズ。 今回はカスタムバリデーションを試してみる。 [clink implicit=\"false\" url=\"https://gin-gonic.com/ja/docs/examples/custom-validators/\" imgurl=\"https://gin-gonic.com/_astro/gin.D6H2T_2v_ZD2G7l.webp\" title=\"カスタムバリデーション\" excerpt=\"カスタムしたバリデーションを使用することもできます。サンプルコード も見てみてください。\"] [arst_toc tag=\"h4\"] ルーティング バリデーションを外部に移譲することで、ハンドラからロジック以外の冗長な処理を除くことができる。 Ginはカスタムバリデータを用意している。以下の例では、ユーザ登録を行うPOSTリクエストの例。 組み込みのバリデーション・バインディングと合わせて、パスワードバリデーションロジックの 追加を行っている。 package main import ( \"github.com/gin-gonic/gin\" \"github.com/gin-gonic/gin/binding\" \"github.com/go-playground/validator/v10\" \"github.com/ikuty/golang-gin/handlers\" ) func main() { // Ginエンジンの初期化 r := gin.Default() // カスタムバリデーターを登録 if v, ok := binding.Validator.Engine().(*validator.Validate); ok { handlers.InitCustomValidators(v) } // 7. カスタムバリデーション r.POST(\"/api/register\", handlers.RegisterValidatorHandler) // サーバー起動 r.Run(\":8080\") } ハンドラ リクエストで受けたJSONをRegisterRequest構造体にバインディングする際に、組み込みの バリデーションルールを定義するのとは別に、strongpassword というカスタムルールを定義している。 strongpasswordルールの実体は strongPassword() 。 例に出現するオブジェクトの使い方は、まぁこう使うのかぐらいで、ありがちな感じ。 カスタムバリデータ関数がチェック結果をTrue/Falseで返せばよさそう。 組み込みバリデータ、または、カスタムバリデータのバリデーション結果と文字列の対応を定義し、 その文字列をレスポンスに付与して返す、というのは良くあるパターンで、 Ginで実装する場合は、 また、カスタムバリデータのバリデーション結果と文字列の対応を定義しレスポンスに含める、 というパターンは良くありそうで、構造体へのバインディングで発生したエラー(err)を取得し、 errに対する型アサーションを行った上で、errを validator.ValidationErrors型として扱う。 動的型付けだと、発生したerrが本当に期待したオブジェクトなのか実行するまで分からなが、 全ての処理が静的型付けを通して、実行前に実行可能であることが確認される。 package handlers import ( \"net/http\" \"regexp\" \"github.com/gin-gonic/gin\" \"github.com/go-playground/validator/v10\" ) // RegisterRequest はユーザー登録リクエストの構造体(高度なバリデーション付き) type RegisterRequest struct { Username string `json:\"username\" binding:\"required,min=3,max=20,alphanum\"` Email string `json:\"email\" binding:\"required,email\"` Password string `json:\"password\" binding:\"required,min=8,max=50,strongpassword\"` Age int `json:\"age\" binding:\"required,gte=18,lte=100\"` Website string `json:\"website\" binding:\"omitempty,url\"` Phone string `json:\"phone\" binding:\"omitempty,e164\"` // E.164 形式の電話番号 } // カスタムバリデーター: 強力なパスワードチェック func strongPassword(fl validator.FieldLevel) bool { password := fl.Field().String() // 最低1つの大文字、1つの小文字、1つの数字を含む hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password) hasLower := regexp.MustCompile(`[a-z]`).MatchString(password) hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password) return hasUpper && hasLower && hasNumber } // RegisterValidatorHandler はカスタムバリデーターを使用するハンドラー func RegisterValidatorHandler(c *gin.Context) { var req RegisterRequest // JSON をバインド if err := c.ShouldBindJSON(&req); err != nil { // バリデーションエラーを詳細に返す c.JSON(http.StatusBadRequest, gin.H{ \"error\": \"Validation failed\", \"details\": formatValidationError(err), }) return } c.JSON(http.StatusCreated, gin.H{ \"message\": \"Registration successful\", \"username\": req.Username, \"email\": req.Email, }) } // formatValidationError はバリデーションエラーをわかりやすく整形 func formatValidationError(err error) []string { var errors []string if validationErrors, ok := err.(validator.ValidationErrors); ok { for _, e := range validationErrors { var message string switch e.Tag() { case \"required\": message = e.Field() + \" is required\" case \"email\": message = e.Field() + \" must be a valid email address\" case \"min\": message = e.Field() + \" must be at least \" + e.Param() + \" characters\" case \"max\": message = e.Field() + \" must be at most \" + e.Param() + \" characters\" case \"alphanum\": message = e.Field() + \" must contain only letters and numbers\" case \"gte\": message = e.Field() + \" must be greater than or equal to \" + e.Param() case \"lte\": message = e.Field() + \" must be less than or equal to \" + e.Param() case \"url\": message = e.Field() + \" must be a valid URL\" case \"e164\": message = e.Field() + \" must be a valid phone number (E.164 format)\" case \"strongpassword\": message = e.Field() + \" must contain at least one uppercase letter, one lowercase letter, and one number\" default: message = e.Field() + \" is invalid\" } errors = append(errors, message) } } else { errors = append(errors, err.Error()) } return errors } // InitCustomValidators はカスタムバリデーターを登録する func InitCustomValidators(v *validator.Validate) { v.RegisterValidation(\"strongpassword\", strongPassword) } 実行結果 リクエストに対してバリデーションが行われ、期待通りバリデーションエラーがアサートされていて、 アサートと対応するカスタム文字列がレスポンスに含まれていることが確認できる。 $ curl -X POST http://localhost:8080/api/register -H \"Content-Type: application/json\" -d \'{\"username\":\"john123\",\"email\":\"john@example.com\",\"password\":\"SecurePass123\",\"age\":25,\"website\":\"https://example.com\"}\' {\"email\":\"john@example.com\",\"message\":\"Registration successful\",\"username\":\"john123\"} 2. ユーザー名が短すぎる {\"details\":[\"Username must be at least 3 characters\"],\"error\":\"Validation failed\"} 3. 弱いパスワード(カスタムバリデーター) {\"details\":[\"Password must contain at least one uppercase letter, one lowercase letter, and one number\"],\"error\":\"Validation failed\"} 4. 年齢が18歳未満 {\"details\":[\"Age must be greater than or equal to 18\"],\"error\":\"Validation failed\"} まとめ 組み込みバリデーションの他に、カスタムバリデーションを追加できることを確認した。 静的型付けにより実行時エラーに頼ることのないある種の堅牢さがあることも見てとれた。

default eye-catch image.

Golang + Gin Framework で Hello World してみた話 〜基本的なルーティング、バスパラメタ・クエリパラメタ・JSON Req/Res、フォームデータ

Golang+GinでAPIを大量に書くことになりそうなので予習することにする。 コード自体はAI Agentで書こうと思うが、まずはGinのフィーチャーを把握する必要がある。 AI Agentを使用してAPI毎にフィーチャーを試せる学習用プロジェクトを構築する。 著者のスペックは、昔仕事でLaravelでWebアプリを書いたことがある。 [arst_toc tag=\"h4\"] Ginについて 🚀 高速なパフォーマンス martini に似たAPIを持ちながら、httprouter のおかげでそれより40倍以上も速いパフォーマンスがあります。 **基数木(Radix Tree)**ベースのルーティングを採用しており、メモリ効率が良く、高速なルーティングを実現しています。 他のGo製Webフレームワークと比較して、ベンチマークで優れた速度を示すことが多く、特に高スループットな REST API や マイクロサービス の構築に適しています。 Laravelは遅くて有名だったが、速いのは良いこと。 Golang自体ネイティブ実行だし、Golang用フレームワークの中でも速度にフィーチャーした構造。 たいした同時実行数を捌かないなら別に遅くても良いし、速いなら良いよね、ぐらい。 🧩 ミドルウェアのサポート 受信したHTTPリクエストを、ミドルウェアのチェーンと最終的なアクション(ハンドラー)で処理する仕組みを提供します。 ロガー、認証、GZIP圧縮など、様々な機能を簡単に組み込むことができます。 ミドルウェアくらい使えないと困るよね。認証を書きたい。 🛡️ クラッシュフリー HTTPリクエスト処理中に発生したpanicをキャッチし、**リカバリー(回復)**する機能が組み込まれています。 これにより、サーバーがクラッシュするのを防ぎ、サービスを常に利用可能な状態に保ちます。 🔗 ルートのグループ化 認証が必要なルートやAPIのバージョンごとなど、関連するルートをグループ化して整理する機能があり、共通のミドルウェアを適用しやすいです。 フルスタックフレームワークではないので、これだけしか書かれていない。 シンプルであることは良いこと。 学習用プロジェクトの構成 いったん、こんな感じで構成。 golang-gin/ ├── docker-compose.yml ├── Dockerfile ├── go.mod ├── go.sum ├── main.go ├── README.md └── handlers/ ├── hello.go # Hello World API ├── params.go # パラメータ処理 ├── json.go # JSON処理 ├── middleware.go # ミドルウェア ├── validation.go # バリデーション ├── file.go # ファイルアップロード └── grouping.go # ルートグループ化 学習計画とAPI API毎にフィーチャーを実装していくスタイルとする。 Claude Codeにその一覧を出力すると以下の通り。 | No. | 機能 | エンドポイント | メソッド | 説明 | |-----|--------------|----------------------|------|----------------------| | 1 | 基本的なルーティング | /hello | GET | Hello World を返す基本API | | 2 | パスパラメータ | /users/:id | GET | URL パスからパラメータを取得 | | 3 | クエリパラメータ | /search | GET | クエリ文字列からパラメータを取得 | | 4 | JSON レスポンス | /api/user | GET | 構造体を JSON で返す | | 5 | JSON リクエスト | /api/user | POST | JSON をバインドして処理 | | 6 | フォームデータ | /form | POST | フォームデータの受け取り | | 7 | バリデーション | /api/register | POST | 入力データのバリデーション | | 8 | ファイルアップロード | /upload | POST | 単一ファイルのアップロード | | 9 | 複数ファイルアップロード | /upload/multiple | POST | 複数ファイルのアップロード | | 10 | ミドルウェア (ログ) | /api/protected | GET | カスタムミドルウェアの実装 | | 11 | ルートグループ化 | /v1/users, /v2/users | GET | API バージョニング | | 12 | エラーハンドリング | /error | GET | エラーレスポンスの処理 | | 13 | カスタムバリデーター | /api/validate | POST | カスタムバリデーションルール | | 14 | リダイレクト | /redirect | GET | リダイレクト処理 | | 15 | 静的ファイル配信 | /static/* | GET | 静的ファイルの提供 | Hello World まずは Hello World を返すAPIを作る。 main.goは以下の通り。./handlers 以下に実態を書いていく。 package main import ( \"github.com/gin-gonic/gin\" \"github.com/ikuty/golang-gin/handlers\" ) func main() { // Ginエンジンの初期化 r := gin.Default() // Hello World API r.GET(\"/hello\", handlers.HelloHandler) // サーバー起動 r.Run(\":8080\") } ./handlers/hello.go は以下の通り。 package handlers import ( \"net/http\" \"github.com/gin-gonic/gin\" ) // HelloHandler は Hello World を返すハンドラー func HelloHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ \"message\": \"Hello World\", }) } 試す。入門した。 $ curl http://localhost:8080/hello {\"message\":\"Hello World\"} パスパラメータ URL内にプレースホルダを設定し、URLのプレースホルダと対応する値を変数で受けられる機能。 package main import ( \"github.com/gin-gonic/gin\" \"github.com/ikuty/golang-gin/handlers\" ) func main() { // Ginエンジンの初期化 r := gin.Default() // 1. 基本的なルーティング r.GET(\"/hello\", handlers.HelloHandler) // 2. パスパラメータ r.GET(\"/users/:id\", handlers.GetUserByIDHandler) // サーバー起動 r.Run(\":8080\") } ./handlers/params.goは以下。 Laravelと同じところに違和感。型はどこいった..? Ginでは、パスパラメータは常に文字列(string)として取得される。 URLから取得したパラメータを別の型(intやuintなど)として扱いたい場合は、 取得した文字列を明示的に型変換する必要がある。 package handlers import ( \"net/http\" \"github.com/gin-gonic/gin\" ) // GetUserByIDHandler は URL パスパラメータからユーザーIDを取得するハンドラー func GetUserByIDHandler(c *gin.Context) { // パスパラメータ :id を取得 id := c.Param(\"id\") c.JSON(http.StatusOK, gin.H{ \"user_id\": id, \"message\": \"User ID retrieved from path parameter\", }) } 実行。 # 数値IDのテスト $ curl http://localhost:8080/users/123 {\"message\":\"User ID retrieved from path parameter\",\"user_id\":\"123\"} # 文字列IDのテスト $ curl http://localhost:8080/users/alice {\"message\":\"User ID retrieved from path parameter\",\"user_id\":\"alice\"} クエリパラメータ クエリパラメータを受け取る方法は以下。 まぁシンプル。 package handlers import ( \"net/http\" \"github.com/gin-gonic/gin\" ) // SearchHandler は クエリパラメータから検索条件を取得するハンドラー func SearchHandler(c *gin.Context) { // クエリパラメータを取得 query := c.Query(\"q\") // ?q=keyword page := c.DefaultQuery(\"page\", \"1\") // ?page=2 (デフォルト値: \"1\") limit := c.DefaultQuery(\"limit\", \"10\") // ?limit=20 (デフォルト値: \"10\") // オプショナルなパラメータ sort := c.Query(\"sort\") // 値がない場合は空文字列 c.JSON(http.StatusOK, gin.H{ \"query\": query, \"page\": page, \"limit\": limit, \"sort\": sort, \"message\": \"Query parameters retrieved successfully\", }) } 実行結果は以下。 # パスパラメータ $ curl http://localhost:8080/users/123 {\"message\":\"User ID retrieved from path parameter\",\"user_id\":\"123\"} # クエリパラメータ $ curl \"http://localhost:8080/search?q=test&page=2\" {\"limit\":\"10\",\"message\":\"Query parameters retrieved successfully\",\"page\":\"2\",\"query\":\"test\",\"sort\":\"\"} JSONリクエスト/JSONレスポンス Content-Type: application/json で半構造化データ(JSON)を送り、構造体で受けることができる。 また、構造体を Content-Type: application/json でJSON文字列を返すことができる。 構造体のメンバに型を定義しておくことで、文字列がメンバ型に変換(バインド)できる。 まずルーティングは以下の通り。 package main import ( \"github.com/gin-gonic/gin\" \"github.com/ikuty/golang-gin/handlers\" ) func main() { // Ginエンジンの初期化 r := gin.Default() // 4. JSON レスポンス r.GET(\"/api/user\", handlers.GetUserHandler) // 5. JSON リクエスト r.POST(\"/api/user\", handlers.CreateUserHandler) // サーバー起動 r.Run(\":8080\") } ハンドラは以下の通り。 バインドの記述が興味深い。バインド時にバリデーションを実行している。 package handlers import ( \"net/http\" \"github.com/gin-gonic/gin\" ) // User 構造体 type User struct { ID int `json:\"id\"` Name string `json:\"name\"` Email string `json:\"email\"` Age int `json:\"age\"` IsActive bool `json:\"is_active\"` } // GetUserHandler は 構造体を JSON で返すハンドラー func GetUserHandler(c *gin.Context) { // サンプルユーザーデータ user := User{ ID: 1, Name: \"John Doe\", Email: \"john@example.com\", Age: 30, IsActive: true, } c.JSON(http.StatusOK, user) } // CreateUserRequest はユーザー作成リクエストの構造体 type CreateUserRequest struct { Name string `json:\"name\" binding:\"required\"` Email string `json:\"email\" binding:\"required,email\"` Age int `json:\"age\" binding:\"required,gte=0,lte=150\"` } // CreateUserHandler は JSON リクエストをバインドして処理するハンドラー func CreateUserHandler(c *gin.Context) { var req CreateUserRequest // JSON をバインド(バリデーションも実行される) if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{ \"error\": \"Invalid request\", \"details\": err.Error(), }) return } // 作成されたユーザーを返す(実際はDBに保存する) user := User{ ID: 100, // 仮のID Name: req.Name, Email: req.Email, Age: req.Age, IsActive: true, } c.JSON(http.StatusCreated, gin.H{ \"message\": \"User created successfully\", \"user\": user, }) } 実行結果は以下。 1. GET - JSON レスポンス $ curl http://localhost:8080/api/user {\"id\":1,\"name\":\"John Doe\",\"email\":\"john@example.com\",\"age\":30,\"is_active\":true} 2. POST - 正常なリクエスト $ curl -X POST http://localhost:8080/api/user -H \"Content-Type: application/json\" -d \'{\"name\":\"Alice\",\"email\":\"alice@example.com\",\"age\":25}\' {\"message\":\"User created successfully\",\"user\":{\"id\":100,\"name\":\"Alice\",\"email\":\"alice@example.com\",\"age\":25,\"is_active\":true}} 3. POST - バリデーションエラー(メール形式) $ curl -X POST http://localhost:8080/api/user -H \"Content-Type: application/json\" -d \'{\"name\":\"Bob\",\"email\":\"invalid-email\",\"age\":30}\' {\"details\":\"Key: \'CreateUserRequest.Email\' Error:Field validation for \'Email\' failed on the \'email\' tag\",\"error\":\"Invalid request\"} 4. POST - バリデーションエラー(年齢範囲) $ curl -X POST http://localhost:8080/api/user -H \"Content-Type: application/json\" -d \'{\"name\":\"Charlie\",\"email\":\"charlie@example.com\",\"age\":200}\' {\"details\":\"Key: \'CreateUserRequest.Age\' Error:Field validation for \'Age\' failed on the \'lte\' tag\",\"error\":\"Invalid request\"} フォームデータ フォームデータの送信例。ルーティングは以下。 POSTで送ったフィールドを丸っと構造体にする例と、 それぞれのフィールドを個別に取得する例の2つ。 package main import ( \"github.com/gin-gonic/gin\" \"github.com/ikuty/golang-gin/handlers\" ) func main() { // Ginエンジンの初期化 r := gin.Default() // 6. フォームデータ r.POST(\"/form/login\", handlers.LoginHandler) r.POST(\"/form/post\", handlers.PostFormHandler) // サーバー起動 r.Run(\":8080\") } ハンドラは以下。丸っとフォームデータを構造体にバインドできるし、 個別にアクセスすることもできる。 シンプルというか、少ない道具でなんとかするタイプ。 package handlers import ( \"net/http\" \"github.com/gin-gonic/gin\" ) // LoginForm はログインフォームの構造体 type LoginForm struct { Username string `form:\"username\" binding:\"required\"` Password string `form:\"password\" binding:\"required,min=6\"` Remember bool `form:\"remember\"` } // LoginHandler はフォームデータを受け取るハンドラー func LoginHandler(c *gin.Context) { var form LoginForm // フォームデータをバインド if err := c.ShouldBind(&form); err != nil { c.JSON(http.StatusBadRequest, gin.H{ \"error\": \"Invalid form data\", \"details\": err.Error(), }) return } // 実際はここで認証処理を行う c.JSON(http.StatusOK, gin.H{ \"message\": \"Login successful\", \"username\": form.Username, \"remember\": form.Remember, }) } // PostFormHandler は個別にフォームフィールドを取得するハンドラー func PostFormHandler(c *gin.Context) { // 個別のフォームフィールドを取得 title := c.PostForm(\"title\") content := c.DefaultPostForm(\"content\", \"No content provided\") tags := c.PostFormArray(\"tags\") // 配列として取得 c.JSON(http.StatusOK, gin.H{ \"message\": \"Form data received\", \"title\": title, \"content\": content, \"tags\": tags, }) } 実行例は以下。 1. ログインフォーム - 正常 $ curl -X POST http://localhost:8080/form/login -d \"username=john&password=secret123\" {\"message\":\"Login successful\",\"remember\":false,\"username\":\"john\"} 2. ログインフォーム - remember 付き $ curl -X POST http://localhost:8080/form/login -d \"username=alice&password=pass123&remember=true\" {\"message\":\"Login successful\",\"remember\":true,\"username\":\"alice\"} 3. ログインフォーム - バリデーションエラー $ curl -X POST http://localhost:8080/form/login -d \"username=bob&password=123\" {\"details\":\"Key: \'LoginForm.Password\' Error:Field validation for \'Password\' failed on the \'min\' tag\",\"error\":\"Invalid form data\"} 4. 投稿フォーム - 配列データ $ curl -X POST http://localhost:8080/form/post -d \"title=Hello&content=World&tags=go&tags=gin&tags=api\" {\"content\":\"World\",\"message\":\"Form data received\",\"tags\":[\"go\",\"gin\",\"api\"],\"title\":\"Hello\"} まとめ いったん、以下を試した。 基本的なルーティング バスパラメタ・クエリパラメタ JSON Request/Response フォームデータ シンプルすぎてClaude Codeが機能を絞っているのか疑ったが、 公式を読む限り、若干バリエーションが増える程度の様子。 これならわざわざClaudeに入門コースを作ってもらわなくても上から読めば良いかな。

default eye-catch image.

ひたすらPythonチュートリアル第4版を読んでみる

Pythonの入門書「Pythonチュートリアル」。 もともとPython作者のGuido van Rossum自身が書いたドキュメントが出展で、 理解のしやすさを目指して日本語訳が作られている。 Pythonの更新に対応するため幾度か改版され、第4版は3.9対応を果たしている。 タイトルの通りひたすら「Pythonチュートリアル第4版」を読んでみる。 全てを1つの記事に書くスタイル。読み進めた部分を足していく。 [arst_toc tag=\"h3\"] Pythonインタープリタの使い方 対話モード コマンドをttyから読み込むモード。 >>> で複数行のコマンドを受け付ける。 2行目から...で受け付ける。 > python 月 4/11 23:35:41 2022 Python 3.9.11 (main, Apr 11 2022, 01:59:37) [Clang 10.0.1 (clang-1001.0.46.4)] on darwin Type \"help\", \"copyright\", \"credits\" or \"license\" for more information. >>> hoge = True >>> if hoge: ... print(\"hoge is true\") ... hoge is true ソースコードエンコーディング shebangとは別にファイルの先頭に特殊コメントを書くことでファイルのencodingを指定できる。 UTF8の場合は記述しない。非UTF8の場合にのみ書く。shebangがある場合2行目。 ちなみにコメントは「coding[=:]s*([-w.]+)」の正規表現にマッチすればよい。 #!/bin/sh # 🍣🍣🍣 coding=cp1252 🍣🍣🍣 とはいえ、教科書的には「# -*- coding: cp1252 -*-」。 気楽な入門編 対話モードの最終評価値はアンダースコア(_)に格納される。へぇ。 型と変数と評価 #加算 >>> 1+1 2 #減算 >>> 5-4 1 #乗算 >>> 3*2 6 #除算 >>> 5/3 1.6666666666666667 >>> 1*(3+9) 12 #変数への代入と評価 >>> hoge=100 >>> hoge 100 #最終評価値の記憶(アンダースコア) >>> price = 100 >>> tax = 0.25 >>> price * tax 25.0 >>> price + _ 125.0 文字列 シングルクォートまたはダブルクォート。バックスラッシュでエスケープ。 文字列リテラルにrを前置することでエスケープ文字をエスケープしない.へぇ。 >>> str = \'hogehoge\'; >>> str2 = str + \'100t200\'; >>> str2 \'hogehoge100t200\' >>> print(str2) hogehoge100 200 >>> str3 = str + r\'100t200\'; >>> str3 \'hogehoge100\\t200\' いわゆるヒアドキュメント。複数行の文字列リテラルはトリプルクォート。 >>> print(\"\"\" ... This is a pen. ... This was a pen. ... This will be a pen. ... \"\"\"); This is a pen. This was a pen. This will be a pen. 文字列リテラルを列挙すると結合される。 phpのドット演算子とは異なり文字列リテラルのみに作用する。 文字列リテラルと変数は無理。 phpに慣れてるとやりかねない。 >>> text = (\'文字列1\' ... \'文字列2\' \'文字列3\' ... \'文字列4\') >>> text \'文字列1文字列2文字列3文字列4\' >>> text2 = \'hogehoge\' >>> text text2 File \"\", line 1 text text2 ^ SyntaxError: invalid syntax インデックス演算子で文字列内の文字(1文字の文字列)にアクセス可。 負の値を指定すると後ろから何個目...というアクセスの仕方ができる。0と-0は同じ。 範囲外アクセス(Out of bounds)でエラー。 >>> str3 = \'123456789\' >>> str3[3] \'4\' >>> str3[-2] \'8\' >>> str3[0] \'1\' >>> str3[-0] \'1\' >>> str3[100] Traceback (most recent call last): File \"\", line 1, in IndexError: string index out of range 文字列とスライス スライス演算子で部分文字列にアクセス可。始点は含み終点は含まない。 >>> str3[2:5] \'345\' >>> str3[3:] \'456789\' >>> str3[-2:] \'89\' >>> str3[:5] \'12345\' 参考書にスライスについて面白い書き方がされている。 インデックスとは文字と文字の間の位置を表す。最初の文字の左端がゼロ。 インデックスiからインデックスjのスライス[i:j]は境界iと境界jに挟まれた全ての文字。 例えば[2,5]は t h o 。 +---+---+---+---+---+---+ | P | y | t | h | o | n | +---+---+---+---+---+---+ 0 1 2 3 4 5 6 -6 -5 -4 -3 -2 -1 スライスには範囲外アクセス(Out of range)はない。超えた分を含む最大を取ってくれる。 >>> str3[2:100] \'3456789\' Pythonの文字列はImmutable。インデックス演算子によりアクセスした部分文字を書き換えられない。 >>> str3[3] = \'A\' Traceback (most recent call last): File \"\", line 1, in TypeError: \'str\' object does not support item assignment コピーして新しい文字列を作って加工する。 >>> str4 = str3[2:5] >>> str4 = str4 + \"hoge\" >>> str4 \'345hoge\' リスト シンプルなコレクション。異なる型の値を格納できる。 リストはミュータブルでスライスアクセスによりシャローコピーを返す。 スライスで戻る新たなリストは元のリストのポインタで値を変更できる。 >>> list = [1,2,3,4,5] >>> list[2:4] = [100,200] >>> list [1, 2, 100, 200, 5] >>> list[:] = [] >>> list [] >>> list.append(100) >>> list [100] #入れ子 >>> list = [1,2,3,4,5,[6,7]] >>> list [1, 2, 3, 4, 5, [6, 7]] フィボナッチ数列 簡単なフィボナッチ数列を例にPythonのいくつかのフィーチャーが説明されている。 まず、多重代入が言語仕様としてサポートされている。 真偽のモデルは「0でない値が真、0だけが偽」のパターン。 ブロックをインデントで表現する。同一ブロックはインデントが揃っている必要がある。 >>> a,b = 0, 1 >>> while a < 10: ... print(a) ... a, b = b, a + b 0 1 1 2 3 5 8 制御構造 if ブロックはインデントで表現。else ifの短縮系として elif を使用できる。 if .. elif .. elif .. else 。 elifを続けて書ける。 >>> x = int(input(\"整数を入力:\")) 整数を入力:100 >>> if x < 0: ... x = 0 ... print('負数はゼロ') ... elif x == 0: ... print('ゼロ') ... elif x == 1: ... print('1つ') ... else: ... print('もっと') for C形式、つまり初期値、反復間隔、停止条件の指定では書けないのがポイント。 シーケンス(リスト、文字列)のアイテムに対してそのシーケンス内の順序で反復を書くことになる。 >>> words = [ \'hoge\', \'fuga\', \'foo\'] >>> for w in words: ... print(w, len(w)) ... hoge 4 fuga 4 foo 3 シーケンス内のアイテムがシーケンスの場合、アイテムを直接受け取れる。 >>> users = [ [\'kuma\',1], [\'peco\', 2], [\'hoge\', 3]] >>> for user, status in users: ... print(user, status) ... kuma 1 peco 2 hoge 3 Cスタイルの反復条件をループ内で変更する際に終了判定が複雑になるように、 Pythonのスタイルであっても反復対象のシーケンスを直接変更すると面倒なことになる。 本書では、シーケンスをコピーし新しいシーケンスを作って操作する例が示されている。 まぁそうだろうが、本書のここまで辞書(dict)の説明は出てきていない。まぁいいか。 >>> users = { \'hoge\':100, \'fuga\':200, \'peco\':300 } >>> for user, status in users.copy().items(): ... if status == 200: ... del users[user] ... >>> users {\'hoge\': 100, \'peco\': 300} >>> active_users = {} >>> for user, status in users.items(): ... if status == 300: ... active_users[user] = status ... >>> active_users {\'peco\': 300} range 任意の反復を実行するために反復条件を表すシーケンスを定義してやる必要がある。 ビルトイン関数のrange()を使うことで等差数列を持つiterableを生成できる。 range()は省メモリのため評価時にメモリを確保しない。 つまり、range()が返すのはiterableでありシーケンスではない。 第3引数はステップで省略すると1が使われる。 先頭から順に評価時に消費され遂には空になる、というイメージ。 >>> for i in range (1,100,10): ... print(i) ... 1 11 21 31 41 51 61 71 81 91 とはいえ他の処理でシーケンスを作成済みで再利用するケースは多い。 iterableではなく既にコレクションが存在する場合、以下のようになる。 >>> a = [\'hoge\', \'fuga\', \'kuma\',\'aaa\',\'bbb\'] >>> for i in range(len(a)): ... print(i, a[i]) ... 0 hoge 1 fuga 2 kuma 3 aaa 4 bbb iterableを引数に取る関数はある。例えばsum()はiterableを引数に取り合計を返す。 >>> sum(range(10)) 45 ループのelse forループでiterableを使い果たすか1件も消費できないケースでforループにつけたelseが評価される。 ただしforループをbreakで抜けた場合はforループのelseは評価されない。 例えば2から9までの数値について素数か素数でなければ約数を求める処理を構文で表現できる。 ループのelseはtryによる例外評価に似ているという記述がある。え..? 要は「forの処理が期待したパスを通らない場合に評価される」ということだろうか... イマジネーションの世界.. >>> for n in range(2, 10): ... for x in range(2, n): ... if n % x == 0: ... print(n, \'equals\', x, \'*\', n/x) ... break ... else: ... print(n, \'is a prime number\') ... 2 is a prime number 3 is a prime number 4 equals 2 * 2.0 5 is a prime number 6 equals 2 * 3.0 7 is a prime number 8 equals 2 * 4.0 9 equals 3 * 3.0 pass 構文的に文が必要なのにプログラム的には何もする必要がないときにpassを使う。 もうこれ以上説明は不要。やはり原著は良い。 >>> r = range(1,10) >>> for i in r: ... if i % 2 == 0: ... print(i) ... else: ... pass ... 2 4 6 8 関数の定義 本書においてスコープの実装が書かれている。言語仕様をわかりやすく説明してくれている。 プログラミング言語自体の実装において変数などのシンボルはスコープの範囲で格納され参照される。 本書においてPythonのスコープは内側から順に以下の通りとなると記述がある。 より外側のスコープのシンボル表は内側のスコープのシンボル表に含まれる。 内側のスコープから外側のシンボル表を更新することはできない。 関数内スコープ 関数を定義したスコープ グローバルスコープ ビルトインスコープ >>> hgoe = 100 >>> def bar(): ... hoge = 200 ... print(hoge) ... >>> bar() 200 >>> hoge 100 引数はcall by object reference Pythonの関数の引数は値渡しなのか参照渡しなのか。原著には簡潔に答えが書かれている。 関数のコールの時点でその関数にローカルなシンボル表が作られる。 ローカルなシンボル表に外側のシンボル表の値の参照がコピーされる。まさに事実はこれだけ。 call by valueに対して、call by object referenceという表現がされている。 引数が巨大であっても関数のコールの度に値がコピーされることはないし、 関数スコープで引数を弄っても外側のスコープに影響することはない。 関数の戻り値 Pythonにはprocedureとfunctionの区別がない。全てfunction。 procedureであっても(つまり明示的にreturnで返さなくても)暗黙的にNoneを返す。 >>> def bar(): ... hoge = 100 ... >>> print(bar()) None >>> def foo(): ... hoge = 100 ... return hoge ... >>> print(foo()) 100 本書で書かれているフィボナッチ級数をリストで返す関数を定義してみる。 >>> def fib(n): ... result = [] ... a, b = 0, 1 ... while a >> fib(100) [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89] 引数のデフォルト引数 デフォルト値の評価は関数を定義した時点で定義を書いたスコープで行われる。 まさに原著に書かれているこの書かれ方の通り。 >>> N=300 >>> def foo(hoge, fuga=100, bar=N): ... print(hoge, fuga, bar) ... >>> foo(100) 100 100 300 >>> foo(100,200) 100 200 300 >>> foo(100,200,500) 100 200 500 そして、デフォルト値の評価は一度しか起きない。デフォルト値がリストなどの可変オブジェクトの場合、 定義時に1度だけデフォルト値が評価されるだけで、コール時にはデフォルト値は評価されない。 本書の例がわかりやすかった。 >>> def foo(hoge,L=[]): ... L.append(hoge) ... return L ... >>> foo(100) [100] >>> foo(200) [100, 200] >>> foo(300) [100, 200, 300] キーワード引数 キーワード引数によりコール時の引数の順序を変更できる。 デフォルト引数の定義がキーワード引数の定義を兼ねている。 デフォルト定義がない引数は位置が制約された位置引数。 位置引数は必須でありキーワード引数よりも前に出現する必要がある。 >>> def foo(hoge, fuga=100, bar=N): ... print(hoge, fuga, bar) >>> foo(100,fuga=500) 100 500 300 「*名前」を引数に設定すると、仮引数にない位置指定型引数を全て含むタプルが渡る。 「**名前」を引数に設定すると、仮引数に対応するキーワードを除いた全てのキーワード引数がdictで渡る。 dict内の順序は関数のコール時の指定順序が保持される。 >>> def aaa(kind, *arguments, **keywords): ... for arg in arguments: ... print(arg) ... for kw in keywords: ... print(kw,\':\',keywords[kw]) ... >>> aaa(\"111\", \"222\", \"333\", hoge=\"444\", fuga=\"500\", poo=\"600\") 222 333 hoge : 444 fuga : 500 poo : 600 位置のみ,位置またはキーワード,キーワードのみ指定 引数は位置引数,キーワード引数のいずれにでもなることができるが出現位置は決められている。 引数リストの前半は位置引数, 後半はキーワード引数であり, 位置引数はMust、キーワード引数はOptional。 Optionalな部分は位置引数なのかキーワード引数なのか文脈で決まることになる。 言語仕様によって,どの引数が「位置引数限定」,「キーワード引数限定」,「どちらでも良い」かを指定できる。 特殊引数 / と * を使用する。 /の前に定義した引数は位置引数としてのみ使用できる。 また / と * の間に定義した引数は位置引数,キーワード引数のいずれでも使用できる。 * の後に定義した引数はキーワード引数としてのみ使用できる。 /が無ければ位置引数指定がないことを表す。*が無ければキーワード指定がないことを表す。 つまり / も * もない場合は、全ての引数が位置引数にもキーワード引数にもなれるデフォルトの挙動となる。 >>> def f(pos1, pos2, /, pos_or_kwd, *, kwd1, kwd2): ... print(pos1, pos2) ... print(pos_or_kwd) ... print(kwd1, kwd2) ... >>> f(10,20,30,kwd1=40,kwd2=50) 10 20 30 40 50 # 前から最大3個しか位置引数になれないため5個渡すとエラーとなる >>> f(10,20,30,40,50) Traceback (most recent call last): File \"\", line 1, in TypeError: f() takes 3 positional arguments but 5 were given # h, zを位置引数に限定。キーワード指定して呼ぶとエラーとなる >>> def j(h,z,/): ... print(h,z) ... >>> j(200, z=100) Traceback (most recent call last): File \"\", line 1, in TypeError: j() got some positional-only arguments passed as keyword arguments: \'z\' # h, zをキーワード引数に限定。位置指定して呼ぶとエラーとなる >>> def n(*,h,z): ... print(h, z) ... >>> n(100, z=200) Traceback (most recent call last): File \"\", line 1, in TypeError: n() takes 0 positional arguments but 1 positional argument (and 1 keyword-only argument) were given 本書に微妙な部分を説明する記述があった。 位置引数nameと, キーワード引数リストargsを取る関数fooを定義し, nameというキーを持つdictを第2引数として渡した場合, nameは必ず位置引数に設定され, argsには含まれない。そのような呼び方をすると呼んだ時点でエラーとなる。 >>> def foo(name, **args): ... return \'name\' in args ... >>> foo(1, **{\'name\': 2}) Traceback (most recent call last): File \"\", line 1, in TypeError: foo() got multiple values for argument \'name\' 引数リストにおいてnameを位置引数に限定した場合, **{\'name\':2}はnameに設定されず, *argsで受けられるようになる。 >>> def bar(name,/,**args): ... return \'name\' in args ... >>> bar(1, **{\'name\': 3}) True どの引数を位置引数限定,キーワード引数限定にすべきか手引きが書いてある。 ただ、ちょっとアバウトすぎるというか決めてに書ける。 位置引数にすべき場合は以下。 引数名に本当に意味がない場合 呼び出し時に引数の順序を強制したい場合 いくつかの位置引数と任意個数のキーワード引数を取りたい場合 キーワード引数に限定すべき場合は以下。 引数名に意味がある場合 明示することで関数宣言が理解しやすくなる場合 引数の位置に頼らせたくない場合 特に、キーワード引数とした場合将来引数名が変更されたときに破壊的変更になるから API定義時には位置引数とすべき、なんて書いてある。え... 位置引数の扱いが変わり、渡した引数が意図しない使われ方をすることを許容するのだろうか。 任意引数 仮引数リストの末尾に*から始まる仮引数を置くと任意の引数を吸収するタプルで受けられる。 # hogeは仮引数, hoge以降に指定した任意の数の値をタプルargsで受ける。 >>> def k(hoge, *args): ... print(hoge) ... print(\'/\'.join(args)) ... >>> k(100,\'a\',\'b\',\'c\',\'d\') 100 a/b/c/d 任意引数以降は全てキーワード引数となる。任意引数以降に位置引数を定義することはできない。 キーワード引数はOKなので,任意引数の後ろに新たな引数を置くことはできる。 その引数はキーワード引数となる。 >>> def concat(*args, sep=\'/\'): ... return sep.join(args) ... >>> concat(\'hoge\',\'fuga\',\'foo\') \'hoge/fuga/foo\' 引数のアンパック 変数のコレクションがあり、コレクションから変数にバラす操作をアンパックという。 引数として渡すべき変数の位置でコレクションからアンパックする、という操作をおこなえる。 *演算子によりシーケンスをアンパックできる。 例えば、シーケンス [1,5] があり、このシーケンスからrange(1,5) を作る場合は以下。 >>> cols = [1, 5] >>> v = range(*cols) >>> v range(1, 5) また**演算子によりdeictionaryをアンパックできる。 >>> def z(hoge=300, fuga=500): ... print(hoge, fuga) ... >>> z() 300 500 >>> dict = { \'hoge\': 100, \'fuga\' : 200 } >>> z(**dict) 100 200 lambda式 無名関数。関数オブジェクトを返す。通常の関数とは異なり単一の式しか持てない制限がある。 2個の引数を取り,それぞれの和を求める関数オブジェクトを返すlambdaを定義し使ってみる。 >>> bar = lambda a,b : a+b >>> bar(100,200) 300 lambdaが定義された位置の外側のスコープの変数を参照できる。 これはlambdaが関数のシュガーシンタックスで、関数の入れ子を書いているのと同じだから。 例えば以下のように1個の引数xをとるlambdaにおいて外側にある変数nを参照できる。 >>> def make_incrementor(n): ... return lambda x: x + n ... >>> f = make_incrementor(42) >>> f(0) 42 >>> f(10) 52 ドキュメンテーション文字列(docstring) 関数定義の中にコメントを書くPython固有のコメント仕様について決まりがまとまっている。 1行目は目的を簡潔に要約する。英文の場合大文字で始まりピリオドで終わること。 よくあるダメコメントパターンの1つである変数名自体の説明は避けるなどが書かれている。 2行目は空行。3行目以降の記述と1行目の要約を視覚的に分離する。 関数オブジェクトの__doc__属性を参照することでdocstringを取得できる。 >>> def my_func(): ... \"\"\"Do nothing, but document it. ... ... No, really, it doesn\'t do anything. ... \"\"\" ... pass >>> print(my_func.__doc__) Do nothing, but document it. No, really, it doesn\'t do anything. 関数アノテーション ユーザ定義関数で使われる型についてのメタデータ情報を任意に付けられる。 アノテーションは関数の__annotations__属性を参照することで取得できる。 仮引数のアノテーションは仮引数名の後にコロンで繋いで指定。 関数の型のアノテーションは def の最後のコロンの手前に->で繋いで指定。 >>> def f(ham: str, eggs: str = \'eggs\') -> str: ... print(\"Annotations:\", f.__annotations__) ... print(\"Arguments:\", ham, eggs) ... return ham + \' and \' + eggs ... >>> f(\'hoge\') Annotations: {\'ham\': , \'eggs\': , \'return\': } Arguments: hoge eggs \'hoge and eggs\' コーディング規約(PEP8) ざっくりPEP8の要点が書かれている。 インデントはスペース4つ。タブは使わない。 1行は79文字以下 関数内で大きめのブロックを分離するために空行を使う コメント行は独立 docstringを使う 演算子の周囲やカンマの後ろにはスペースを入れるがカッコのすぐ内側にはいれない クラス、関数名は一貫した命名規則を使う。クラス名はUpperCamelCase、関数名はlower_case_with_underscores メソッドの第1引数は常にself エンコーディングはUTF8 データ構造 リストの操作 コレクションに対する操作方法が解説されている。破壊的メソッドはデータ構造を変更した後Noneを返す。 # 末尾に追加 >>> hoge = [1,2,3,4,5] >>> hoge.append(6) >>> hoge [1, 2, 3, 4, 5, 6] # iterableを追加 >>> hoge.extend(range(7,9)) >>> hoge [1, 2, 3, 4, 5, 6, 7, 8] # これは以下と等価 >>> hoge = [1,2,3,4,5] >>> hoge[len(hoge):] = range(6,9) >>> hoge [1, 2, 3, 4, 5, 6, 7, 8] # insert >>> hoge.insert(3,100) >>> hoge [1, 2, 3, 100, 4, 5, 6, 7, 8] # remove >>> hoge.remove(3) >>> hoge [1, 2, 100, 4, 5, 6, 7, 8] # pop >>> hoge.pop() 8 >>> hoge [1, 2, 100, 4, 5, 6, 7] # pop(i) >>> hoge.pop(4) 5 >>> hoge [1, 2, 100, 4, 6, 7] # clear >>> hoge.clear() >>> hoge [] # [] >>> hoge = [1,2,3,4,5] >>> hoge[2:4] [3, 4] # count(i) リスト内のiの数を返す。リストの個数ではない >>> hoge.count(3) 1 # reverse >>> hoge.reverse() >>> hoge [5, 4, 3, 2, 1] >>> fuga = hoge.copy() >>> fuga [5, 4, 3, 2, 1] リストは比較不可能な要素を持つことができるが、sort()等のように順序を使うメソッドは比較を行わない。 >>> bar = [3,1,2,4,5] >>> bar.sort() >>> bar [1, 2, 3, 4, 5] >>> foo = [3,1,2,4,None,5] >>> foo [3, 1, 2, 4, None, 5] >>> foo.sort() Traceback (most recent call last): File \"\", line 1, in TypeError: \'<' not supported between instances of 'NoneType' and 'int' リストをスタック、キューとして使う 引数無しのpop()により末尾の要素を削除し返すことができる。append()とpop()でLIFOを作れる。 insert()とpop(0)によりFIFOを作ることもできるが,押し出されるデータの再配置により遅いため, deque()を使うとよい。deque()は再配置がなく高速。 # LIFO >>> stack = [1,2,3,4,5] >>> stack.append(6) >>> stack.pop() 6 >>> stack [1, 2, 3, 4, 5] # FIFO (Slow) >>> stack.insert(0,100) >>> stack.pop(0) 100 >>> stack [1, 2, 3, 4, 5] # FIFO (Fast) >>> from collections import deque >>> queue = deque([1,2,3,4,5]) >>> queue deque([1, 2, 3, 4, 5]) >>> queue.popleft() 1 >>> queue deque([2, 3, 4, 5]) リスト内包(list comprehension) list comprehensionの日本語訳がリスト内包。本書には等価な変形が書かれていて、説明にはこれで十分なのではないかと思う。 # forを使って2乗数からなるシーケンスを取得 >>> for x in range(10): ... squares.append(x**2) ... >>> squares [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] # Lambdaを使った等価表現 >>> squares2 = list(map(lambda x: x**2, range(10))) >>> squares2 [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] # list comprehension >>> squares3 = [x**2 for x in range(10)] >>> squares3 [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] 構文としては以下。 式 for節 0個以上のfor節やif節 2重のforを1つのリスト内包表記できる。外側のfor,内側のfor,ifの出現順序が保持されていることに注意、という記述がある。 # forによる表現 >>> for x in [1,2,3]: ... for y in [3,1,4]: ... if x != y: ... combs.append((x,y)) ... >>> combs [(1, 3), (1, 4), (2, 3), (2, 1), (2, 4), (3, 1), (3, 4)] # list comprehension >>> [(x,y) for x in [1,2,3] for y in [3,1,4] if x != y] [(1, 3), (1, 4), (2, 3), (2, 1), (2, 4), (3, 1), (3, 4)] タプルのリストなんかも作れる。 >>> [(x, x**2) for x in [1,2,3]] [(1, 1), (2, 4), (3, 9)] 式を修飾できる。 >>> from math import pi >>> [str(round(pi,i)) for i in range(1,6)] [\'3.1\', \'3.14\', \'3.142\', \'3.1416\', \'3.14159\'] 入れ子のリスト内包 本書には入れ子のリスト内包の等価表現が書かれている。 行列の転値を得る例で説明されているので追ってみる。 # 元の行列 >>> matrix = [ ... [1, 2, 3, 4], ... [5, 6, 7, 8], ... [9, 10, 11, 12], ... ] >>> matrix [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]] # 2重ループを全てforで書き下した >>> transposed = [] >>> for row in matrix: ... transposed_row = [] ... for i in range(4): ... transposed_row.append(row[i]) ... transposed.append(transposed_row) ... >>> transposed [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]] # 1つのループをfor、もう1つをリスト内包 >>> transposed = [] >>> for i in range(4): ... transposed.append([row[i] for row in matrix]) ... >>> transposed [[1, 5, 9], [2, 6, 10], [3, 7, 11], [4, 8, 12]] # 全部リスト内包 >>> [[row[i] for row in matrix] for i in range(4)] [[1, 5, 9], [2, 6, 10], [3, 7, 11], [4, 8, 12]] zip関数 例えばforループにおいて複数のiterableオブジェクトの要素を同時に取得したいときzip()を使う。 何とも書きづらいが, zip(hoge,fuga,foo)とすることでhoge,fuga,fooを1つにまとめることができ, それをforループ内の変数に展開することができる。 # zip()について >>> hoge = [1,2,3] >>> fuga = [4,5,6] >>> foo = [7,8,9] >>> zip(hoge,fuga,foo) # hoge, fuga, fooを固めたものから 変数x,y,zで取り出す >>> for x,y,z in zip(hoge,fuga,foo): ... print(x,y,z) ... 1 4 7 2 5 8 3 6 9 matrix=[[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]]をアンパックすることで、 [1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]が得られる。 これをzip()に与えると3つの要素を持つ4つのタプルにアクセス可能なオブジェクトが得られる。 forループの変数で受けると(1,5,9),(2,6,10),(3,7,11),(4,8,12)が得られる。 >>> for x in zip(*matrix): ... print(x) ... (1, 5, 9) (2, 6, 10) (3, 7, 11) (4, 8, 12) >>> list(zip(*matrix)) [(1, 5, 9), (2, 6, 10), (3, 7, 11), (4, 8, 12)] del リストの要素をインデックス指定またはスライス指定で削除できる。 変数自体を削除できる。 >>> hoge = [1,2,3,4,5,6] >>> del(hoge[3]) >>> hoge [1, 2, 3, 5, 6] >>> del(hoge[2:5]) >>> hoge [1, 2] >>> del hoge >>> hoge Traceback (most recent call last): File \"\", line 1, in NameError: name \'hoge\' is not defined タプル リスト、タプルともにシーケンスだがリストはmutable(可変体)、タプルはimutable(不変体)。 シーケンスであるから、文字列、リストと同様にインデックスアクセスできる。 本書では空のタプル、要素数が1のタプルの作り方が紹介されている。 >>> t = 1,2,3,4,5 >>> t (1, 2, 3, 4, 5) >>> u = t, (1,2,3,4,5) >>> u ((1, 2, 3, 4, 5), (1, 2, 3, 4, 5)) >>> u[1][2] 3 # 要素数がゼロのタプルを作る >>> empty = () >>> empty () # 要素数が1のタプルを作る >>> singleton = \'hoge\' , >>> singleton (\'hoge\',) # 1個の要素を()で囲ってもタプルにならない! >>> singleton2 = (\'hoge\') >>> singleton2 \'hoge\' タプルパッキングとシーケンスアンパッキングについて紹介されている。 要はカンマで区切った一連の要素はタプルに入る。 また、右辺のシーケンス(タプルでなくても良い)の要素を左辺の変数に代入できる。 多重代入はシーケンスアンパッキングであるという記述がある。 # タプルパッキング >>> foo = 1,2,3 >>> foo (1, 2, 3) # シーケンスアンパッキング >>> a,b,c = foo >>> a 1 >>> b 2 >>> c 3 集合 重複しない要素を順序を持たないで保持するコレクション。いわゆる集合演算を備えている。 主に存在判定に用いるという記述がある。重複と順序がなければ任意の値へ高速にアクセス可能なデータ構造で実装できる。 空集合の作り方は少し異なる。間違って空の辞書を作ってしまわないように注意。 >>> hoge = {1,2,3,4,5} >>> hoge {1, 2, 3, 4, 5} # 空の集合 >>> phi = set() >>> phi set() # 空のディクショナリ >>> phi2 = {} >>> phi2 {} 集合内包も可。 >>> z = set() >>> for x in \'abracadabra\': ... if x not in \'abc\': ... z.add(x) ... >>> z {\'d\', \'r\'} >>> z2 = { x for x in \'abracadabra\' if x not in \'abc\'} >>> z2 {\'d\', \'r\'} 辞書 連想配列。キーをインデックス、スライスで書き換えられないデータ構造。 辞書は、値を何らかのキーと共に格納しキー指定で値を取り出すことを目的とするデータ構造。 存在するキーを再代入することで上書き。存在しないキーによるアクセスはエラー。 キーに対してimmutableである前提を置くことでインデックス、スライスで書き換えられないことを保証する。 数値、文字列、immutableな要素だけからなるタプルはキーになる。 可変な要素を持つタプルやリストについては、キー自体を変更できてしまうことになるからNG。 言い換えると辞書は「キー:バリュー」を要素とする集合。 # 初期化 >>> c = { \'hoge\': 100, \'fuga\': 200, \'foo\': 300 } >>> c {\'hoge\': 100, \'fuga\': 200, \'foo\': 300} # キーバリュー追加 >>> c[\'bar\'] = 400 >>> c {\'hoge\': 100, \'fuga\': 200, \'foo\': 300, \'bar\': 400} # キーによるアクセス >>> c[\'fuga\'] 200 # キーバリューの削除 >>> del(c[\'foo\']) >>> c {\'hoge\': 100, \'fuga\': 200, \'bar\': 400} # キーの存在チェック >>> \'hoge\' in c True >>> \'hogehoge\' in c False 注釈に「連想記憶(associative memories)という名前のデータ型をもったプログラム言語はない」という記述がある。 この辺りの使われ方がカオスな言語としてphpがあると思うが、phpは「配列」の添え字として数値も文字列も使える、 という仕様であって「連想配列」という型があるわけでない。 # 順序なしでキーをリスト化 (キーの登録順??) >>> list(c) [\'hoge\', \'fuga\', \'bar\'] # キーでソートしてキーをリスト化 >>> sorted(c) [\'bar\', \'fuga\', \'hoge\'] 辞書内包もできる。 >>> { x: x**2 for x in (2,4,6)} {2: 4, 4: 16, 6: 36} 辞書の初期化は色々バリエーションがある。 # dictのコンストラクタにタプルのリストを指定する >>> d = dict([(\'hoge\',100),(\'fuga\',200),(\'foo\',300)]) >>> d {\'hoge\': 100, \'fuga\': 200, \'foo\': 300} # dictのコンストラクタに個数可変のキーワード引数を指定する >>> e = dict(hoge=100,fuga=200,foo=300) >>> e {\'hoge\': 100, \'fuga\': 200, \'foo\': 300} ループの仕方 辞書からキーバリューを取る。 >>> hoge = { \"hoge\" : 100, \"fuga\" : 200, \"foo\" : 300 } >>> for k, v in hoge.items(): ... print(k,v) ... hoge 100 fuga 200 foo 300 シーケンスからインデックスと値をとる。 >>> fuga = [ 1, 3, 5, 7 ] >>> for i,j in enumerate(fuga): ... print(i, j) ... 0 1 1 3 2 5 3 7 2つ以上のシーケンスから同時に値をとる。 >>> ary1 = [ \"a\", \"b\", \"c\" ] >>> ary2 = [ 100, 200, 300 ] >>> for i, j in zip(ary1, ary2): ... print(i, j) ... a 100 b 200 c 300 条件 条件についての諸々が書いてある。 論理演算子の優先順位は not > and &gt or。なので A and not B or C = (A and (not B)) or C。 論理演算子andとorは短絡評価。if A and B and C において BがFalseであればCは評価されない。 最後に評価された A and B が全体の評価結果となる。 比較は連鎖可能。if a < b == c と書くと、a < b と b == c の2つが評価される。 a > 1 and b > 3 を 1 < a < 3 と書ける。 式の中での代入は:=演算子を使わないとできない。 # 式の中での代入は:= >>> if a := 100 == 100 : ... print(\"hoge\") ... hoge # C風の書き方はNG >>> if a = 100 == 100 : File \"\", line 1 if a = 100 == 100 : ^ SyntaxError: invalid syntax シーケンスの比較 同じシーケンス型同士を比較が出来てしまう。 前から順に再帰的に要素を比較する。ある時点で要素が異なっていればその比較結果が最終結果。 最後まで要素が同じであれば、シーケンスは同じ判定になる。 片方が短い場合、短い方が小となる。 文字の比較はUnicodeコードポイント番号の比較が行われる。 異なる型の比較の場合、オブジェクトがその比較をサポートしている限り行われる。 比較をサポートしていない場合エラー。 >>> (1,2,3) >> (1,2,3) >> (1,2) >> \'a\' < 'b' >> \'c\' < 'b' >> 10 >> 1 == \"1\" False # 整数と文字列の > はサポートされていないためエラー >>> 1 > \"1\" Traceback (most recent call last): File \"\", line 1, in TypeError: \'>\' not supported between instances of \'int\' and \'str\' モジュール 呼び出し元のシンボル表を汚さないimport hoge.pyというファイルに関数fugaを用意しモジュールhogeをインポートする。 関数fuga()の完全な名称はhoge.fuga。hogeはモジュール名,fugaはモジュール内の関数名。 モジュールはimport元とは異なるローカルなシンボル表を持つ。 importによってモジュール内のシンボルが呼び出し元のシンボル表を汚すことはない。 ~/i/pytest cat hoge.py 26.7s  土 4/30 14:40:15 2022 def fuga(v): print(v) ~/i/pytest python Python 3.9.11 (main, Apr 11 2022, 01:59:37) [Clang 10.0.1 (clang-1001.0.46.4)] on darwin Type \"help\", \"copyright\", \"credits\" or \"license\" for more information. >>> >>> import hoge >>> hoge.fuga(123) 123 >>> foo = hoge.fuga >>> foo(321) 321 モジュール内のシンボルを呼び出し元のシンボル表に直接取り込む とはいえ、モジュール名を修飾しなければならないのはあまりに遠すぎる。 モジュールではなくモジュール内のシンボルを直接呼び出し元に取り込むことができる。 以下の通りhogeモジュール内の関数fugaを呼び出し元のシンボル表に直接ロードし呼び出している。 なお、この場合モジュール自体は呼び出し元のシンボル表に取り込めない。 呼び出し元に同名のシンボルがある場合、上書きされる。 >>> def fuga(v): ... print(v**2) ... >>> fuga(3) 9 >>> from hoge import fuga >>> fuga(3) 3 より楽をしたいのであればimport * を使うとモジュール内のアンダースコア(_)で始まるシンボル以外の全てを読み込むことができる。 ただ、シンボル名を指定しないで呼び出し元のシンボル表を上書きするのはあまりに乱暴なので、通常推奨されない。 >>> from hoge import * >>> fuga(300) 300 モジュール内のシンボルをインポートする際に、呼び出し元のシンボルを上書きしないために、 別名をつけてインポートすることができる。 >>> from hoge import fuga as foo >>> foo(3) 3 モジュールはimportされた最初の1回だけ評価される。 関数であれトップレベルに書いたコードであれ最初の評価時に1回実行される。 ロード済みのモジュールを変更する場合インタープリタの再ロードが必要となる場合がある。 または明示的にimportlib.reload()を使ってモジュールをリロードする。 >>> import importlib >>> importlib.reload(hoge) モジュールから他のモジュールをimportすることはできる。 慣例ではimport文はモジュールの先頭で記述すべきだが先頭でなくても許容される。 モジュールをスクリプトとして実行可能にする pythonコマンドの引数としてモジュールを渡すと、モジュール内において__name__が__main__となる。 これを利用して、pythonコマンドの引数として実行された場合にのみ動くコードを付与できる。 まぁ、モジュール単体でスクリプトからデバッグする時なんかに使うんだろう。 # hoge.py def fuga(v): print(v) if __name__ == \"__main__\": import sys fuga(int(sys.argv[1])) # モジュールのインポート時はifブロック内は評価されない >>> from hoge import fuga >>> # pythonコマンドの引数として実行した場合にifブロック内が評価 ~/i/pytest python hoge.py 3 1100ms  水 5/ 4 21:30:09 2022 3 モジュール検索パス 指定したモジュールを探す順序。同名のモジュールが複数ある場合には優先してインポートされる。 例えば hoge という名前をモジュール名として指定した場合、hoge.py を探し出す。 ビルトインモジュール内。無ければ以下 sys.path変数に格納されるディレクトリリスト。初期値は以下。 入力スクリプトがあるディレクトリ、カレントディレクトリ/li> 環境変数 PYTHONPATH インストールごとのデフォルト? やたら曖昧で文書を読むのが嫌になるような書かれ方をしている。合っているのか?解釈してみる。 sys.pathはappend()等により変更できる。sys.pathの初期値は直感と合うように構成されている。 基本的にはプロジェクトディレクトリにモジュールを配置する訳で、標準ライブラリよりも先に ユーザ定義モジュールが読まれるように探してもらいたい。 ユーザ定義モジュールが無い場合に標準ライブラリを探して欲しい訳だから、 標準ライブラリはsys.pathの後の方に配置する。 標準ライブラリと同じ順位の位置にユーザ定義モジュールを置くと「置き換え」の扱いとなる。 この「置き換え」について事故が起こらないような仕組みがあり後述する。 コンパイル済みPythonファイル モジュールの読み込みを高速化する目的で、 Pythonはモジュールファイルをプラットフォーム非依存の形式でキャッシュする。 あくまでも読み込みが高速化されるだけで、読み込まれたコードの実行が速くなる訳ではない。 キャッシュ場所は__pycache__ディレクトリ。 キャッシュヒット判定はモジュールファイルの最終更新日時で行われる。 つまり新しいモジュールファイルがあればヒットせずソースが読まれる。 モジュールのソースを削除しキャッシュだけを配置すると、 常にキャッシュが読まれる。この仕組みにより「ソース無し配布」が可能になる。 スクリプトから読み込む場合、常にキャッシュは使われない。 パッケージ 直感的には名前空間の定義。異なる名前空間のモジュール同士、シンボル名の衝突を避けられる。 公式リファレンスは以下。インポートシステム 多くの処理系で、名前空間を解決するために結構泥臭い実装になっている部分。 以下のディレクトリ階層と__init__.pyにより、dir1、dir1_1、dir1_2パッケージを定義する。 tree . 水 5/ 4 22:32:48 2022 . └── dir1 ├── __init__.py ├── dir1_1 │   ├── __init__.py │   ├── p1.py │   ├── p2.py │   └── p3.py └── dir1_2 ├── __init__.py ├── q1.py ├── q2.py └── q3.py dir1パッケージの下にdir1_1、dir1_2パッケージがある。dir1_1パッケージの下にp1,p2,p3モジュールがある。 p1,p2,p3はモジュールであり、実際には各モジュール内に関数やクラスなどのimportすべきシンボルがある。 例えばp1の中にhoge_p1()という関数があるとして、以下でhoge_p1をimportできる。 なお、dir1直下の__init__.pyには\"__init__.py dir1\"、 dir1_1直下の__init__.pyには\"__init__.py dir1_1\"という文字列をprint()している。 # dir1.dir1_1パッケージのp1モジュールをインポートしhoge_p1()を実行 >>> import dir1.dir1_1.p1 __init__.py dir1 __init__.py dir1_1 >>> p1.hoge_p1() This is p1. 読み込みシーケンスとしては、まず dir1直下の__init__.py内のコードが実行され dir1名前空間の初期化が終わる。 次にdir1_1直下の__init__.py内のコードが実行され、dir1_1名前空間の初期化が終わる。 __init__.pyを置くことで初めてdir1,dir1_1が名前空間であることが定義される。 ワイルドカードimport dir1.dir1_1の下にある p1,p2,p3...を呼び出すために dir1.dir1_1.p1 のようにモジュール名(p1)までを 指定しないといけないのであれば、p1,p2,p3それぞれを個別にimportしないといけなくなる。 またもしp4が追加された場合、 呼び出し元にp4のimportを追加しないといけなくなるかもしれない。 dir1.dir1_1をimportするだけでp1,p2,p3を呼び出せることを期待してしまう。 それを実現するために__init__.pyを使うことができる。 ワイルドカード(*)を使ったimportを行う際、__init__.pyに対象のモジュールを__all__に 定義しておかないと、ワイルドカード(*)importでは何もimportされない。 例えば、dir1_1直下の__init__.pyで__all__としてp1とp2を指定しp3を指定しない場合、 p1,p2はimportされるがp3はimportされない。このように明示しないと*によるimportは出来ない。 # dir1/dir1_1/__init__.pyの記述 __all__ = [\"p1\",\"p2\"] # *を使ったimportと実行 >>> from dir1.dir1_1 import * __init__.py dir1 __init__.py dir1_1 >>> p1.hoge_p1() This is p1. >>> p1.hoge_p3() Traceback (most recent call last): File \"\", line 1, in AttributeError: module \'dir1.dir1_1.p1\' has no attribute \'hoge_p3\' また、別のやり方として、__init__.pyにモジュールのimportを書いておくやり方をしている人がいた。 ディレクトリと対応するパッケージをimpoortすることで同時に配下のモジュールからシンボルをimportする。 この例だと__all__を設定した方が良さそうだが、__init__.pyの動作を理解の助けになる。 # dir1/dir1_1/__init__.pyを以下の通りとする from .p1 import hoge_p1 from .p2 import hoge_p2 print(\"__init__.py dir1_1\") # ワイルドカードimport >>> from dir1.dir1_1 import * __init__.py dir1 __init__.py dir1_1 >>> p1.hoge_p1() This is p1. >>> p2.hoge_p2() This is p2. 何やら歴史的な経緯があるようで、かなり分かりづらい仕様となっている。 「名前空間パッケージ」と「普通のパッケージ」のようなカオスな世界が広がっている。 python3.3以降、ディレクトリ内に__init__.pyを置かなくても、ディレクトリ階層を名前空間として 認識してくれるような振る舞いになっている。ただ、この振る舞いは名前空間パッケージの一部でしかなく、 無条件に「python3.3以降は__init__.pyは不要である」ということではない。 PEP 420: Implicit Namespace Packages Native support for package directories that don’t require __init__.py marker files and can automatically span multiple path segments (inspired by various third party approaches to namespace packages, as described in PEP 420) 入出力 文字列のフォーマット 他言語にある変数内展開と近いのはf-string。接頭辞fをつけた文字列の内部にブラケットで括った 式を記述すると、そのブラケット内の変数が文字列に展開される。 式の後ろにフォーマット指定子を指定することで細かい表現ができる。 >>> year = 2020 >>> event = \'hoge\' >>> f\'Results of the {year} {event}\' \'Results of the 2022 hoge\' >>> import math >>> f\'πの値はおよそ{math.pi:.3f}である。\' \'πの値はおよそ3.142である。\' >>> table = {\'hoge\':100,\'fuga\':200,\'foo\':300} >>> for key,value in table.items(): ... print(f\'{key:10} ==> {value:10d}\') ... hoge ==> 100 fuga ==> 200 foo ==> 300 stringモジュール内にあるTmeplateクラスにも近い機能がある。 SQLのプレースホルダリプレイスメントのような使い方で文字列をフォーマットできる。 >>> from string import Template >>> hoge = 100 >>> fuga = 200 >>> s = Template(\'hoge is ${hoge}, fuga is ${fuga}\') >>> print(s.substitute(hoge=hoge,fuga=fuga)) hoge is 100, fuga is 200 str.format()により、文字列の中にプレースホルダを配置し、渡した変数でリプレースする。 プレースホルダ内に位置情報を含めない場合、format()に渡した値が左から順番にリプレースされる。 位置引数やキーワード引数とすることもできる。その場合format()に渡す値の順序に囚われない。 他言語で良くやるコレクションを渡して文字列に展開する方法が書かれている。 # プレースホルダ空文字. フォーマット指定子. >>> yes_votes = 42_572_654 >>> no_votes = 43_132_495 >>> percentage = yes_votes / (yes_votes + no_votes) >>> \'{:-9} YES votes {:2.2%}\'.format(yes_votes, percentage) \' 42572654 YES votes 49.67%\' # 位置引数 >>> f\'This is {0}, That is {2}, This was {1}, That was {4}\'.format(1,2,3,4) \'This is 0, That is 2, This was 1, That was 4\' # キーワード引数 >>> aaa = 300 >>> bbb = 400 >>> \'This is {aaa}, that is {bbb}.\'.format(aaa=aaa,bbb=bbb) \'This is 300, that is 400.\' # dictを渡す >>> table = {\'hoge\': 1, \'fuga\':2, \'foo\': 3} >>> \'hoge is {0[hoge]:d}, fuga is {0[fuga]:d}, foo is {0[foo]:d}\'.format(table) \'hoge is 1, fuga is 2, foo is 3\' # **表記でdictを渡す(可変長引数) >>> \'hoge is {hoge:d}, fuga is {fuga:d}, foo is {foo:d}\'.format(**table) \'hoge is 1, fuga is 2, foo is 3\' 単純に加算演算子+を使って文字列を結合して自力でフォーマットできる。 その際、オブジェクトを文字列に型変換する必要がありstr()を使う。 >>> s2 = \'String 1 is \' + str(hoge) + \',String 2 is \' + str(fuga) >>> s2 \'String 1 is 100,String 2 is 200\' 右寄せはrjust()、左寄せはljust()、中央寄せはcenter()。指定した幅の中で文字列を寄せる。 指定した幅よりも値が長い場合切り詰めない。切り詰める場合、スライスで部分文字列を取得。 print()に複数の値を与えると、各値の間に空白が1つ挿入される。 print()はデフォルトで末尾が改行となるが、キーワード引数でendとして空文字を 渡すことで末尾を空文字に書き換えられる。 # 右寄せ >>> for x in range(1,11): ... print(repr(x).rjust(2), repr(x*x).rjust(3), end=\' \') ... print(repr(x*x*x).rjust(4)) ... 1 1 1 2 4 8 3 9 27 4 16 64 5 25 125 6 36 216 7 49 343 8 64 512 9 81 729 10 100 1000 ゼロ埋めはzfill()。右寄せして左側にゼロを埋める。 >>> for x in range(1,11): ... print(repr(x).zfill(5)) ... 00001 00002 00003 00004 00005 00006 00007 00008 00009 00010 C言語のprintf()風の文字列補完 正直最初からこれを使っておけば良い気がするが、printf()のような文字列補完ができる。 >>> \'This is %d, That is %d, This was %d, That was %d\' % (1,2,3,4) \'This is 1, That is 2, This was 3, That was 4\' ファイルの読み書き C言語のfopen()を単純化したようなインターフェースが備わっている。 モードは\'r\'が読み取り専用、\'w\'が書き込み専用、追記なら\'a\',読み書き両用なら\'r+\'。 省略時には\'r\'。それぞれモード文字の末尾に\'b\'を付与することでバイナリ対応可。 開いたファイルはclose()により必ず閉じる必要があり、try-finallyのパターンで対応する。 withを利用することでclose()を省略しつつclose()のコールを保証できる。 withはGCによりリソースを破棄する。実際の破棄はGCのタイミング次第。 # try-finally >>> def open_hoge(): ... try: ... fh = open(\'hoge.txt\', \'r\') ... read_data = f.read() ... finally: ... fh.close() ... >>> open_hoge() # with >>> def open_hoge2(): ... with open(\'hoge.txt\',\'r\') as f: ... read_data = f.read() ... >>> open_hoge2() >>> read(SIZE)によりファイルからデータを読み取る。テキストモードの場合、単位は[文字]。 テキストモードの場合UNICODEでもASCIIでも指定した文字だけ取得してくれる。 バイナリモードの場合、単位は[バイト]。 SIZEのデフォルトは-1が指定されていて、ファイル内の全てを読み取る。 省略するとSIZE=-1が使われる。 >>> with open(\'hoge.txt\',\'r\') as f: ... v = f.read(1) ... print(v) ... h テキストファイルから各行にアクセスする、というのが良くある使い方。 readline()はファイルから改行コード単位に1行読み込む。 ファイルオブジェクトが開かれている限り,コールにより次の行を読み進める。 最終行を読み取った後、readlineは空文字を返すようになる。 >>> fh = open(\'hoge.txt\',\'r\') >>> fh.readline() \'hogehogen\' >>> fh.readline() \'fugafugan\' >>> fh.readline() \'foofoon\' >>> fh.readline() \'\' ファイルオブジェクトにループをかけると省メモリで全行を読み取れる。 >>> with open(\'hoge.txt\') as f: ... for line in f: ... print(line,end=\'\') ... hogehoge fugafuga foofoo そして readlines(),list()により各行をシーケンスで取得できる。 >>> with open(\'hoge.txt\') as f: ... ls = f.readlines() ... print(ls) ... [\'hogehogen\', \'fugafugan\', \'foofoon\'] >>> with open(\'hoge.txt\') as f: ... l = list(f) ... print(l) ... [\'hogehogen\', \'fugafugan\', \'foofoon\'] write()によりファイルに書き込める。 非文字列を書き込む場合はstr()などにより先に文字列化する必要がある。 >>> with open(\'fuga.txt\',\'w\') as f: ... f.write(\'This is testn\') ... 13 #書き込んだキャラクタの数。 >>> with open(\'fuga.txt\') as f: ... print(f.readline()) ... This is test # シーケンスを文字列化して書き込む >>> with open(\'fuga.txt\',\'w\') as f: ... ary = [1,2,3,4,5] ... f.write(str(ary)) ... 15 >>> with open(\'fuga.txt\') as f: ... l = f.readline() ... print(l) ... [1, 2, 3, 4, 5] 構造があるデータをjsonで保存 dumps()により構造化データをJSONにシリアライズできる。 dumps()とwrite()を組み合わせるかdump()を使うことでJSONをファイルに書き込める。 # dictをJSONにシリアライズ >>> ary = { \'hoge\':100, \'fuga\':200, \'foo\':300 } >>> json.dumps(ary) \'{\"hoge\": 100, \"fuga\": 200, \"foo\": 300}\' # 一度にdictをシリアライズしてファイルに書き込む >>> dict = {\'hoge\':100, \'fuga\':200, \'foo\':300} >>> with open(\'fuga.txt\',\'w\') as f: ... json.dump(dict,f) ... >>> with open(\'fuga.txt\') as f: ... print(f.readlines()) ... [\'{\"hoge\": 100, \"fuga\": 200, \"foo\": 300}\'] # JSONをでシリアライズ >>> js = json.dumps(dict) >>> js \'{\"hoge\": 100, \"fuga\": 200, \"foo\": 300}\' >>> jjs = json.loads(js) >>> jjs {\'hoge\': 100, \'fuga\': 200, \'foo\': 300} # ファイル内のJSONをdictにデシリアライズ >>> with open(\'fuga.txt\') as f: ... v = json.load(f) ... print(v) ... {\'hoge\': 100, \'fuga\': 200, \'foo\': 300} 続く...

default eye-catch image.

Dartでバイナリ配布可能なCLIツールを作る

エンジニアのスキルセットは基本が重要!、とか考えていると永遠にHelloWorldしてしまう。 そこそこ長い間同じことを考えることで深い洞察ができるようになる効果はあると思うが、 それとは別に趣味とか知的好奇心とか、興味ドリブンでやってみたいという何かは永遠に満たされない。 Dart-langに慣れるために今欲しいツールをDart-langで書いてみる。 なんでDart-langなのか、とか細かいことは気にしない。 [arst_toc tag=\"h4\"] インストール Flutter 1.21からFlutter-SDKに完全なDart-SDKが含まれる. FlutterをやるならFlutterを入れた方が良い. Dart-langは別でインストールできる. 軽いのでFlutterをやらないならこちらが良い. $ brew tap dart-lang/dart $ brew install dart $ dart --version Dart SDK version: 2.16.0 (stable) (Mon Jan 31 15:28:59 2022 +0100) on \"macos_x64\" 空プロジェクトを作る Dart-langのパッケージマネージャはpub. dart-langをインストールするとパスが通り使えるようになる. 空プロジェクトの足場を作るパッケージを使う. ちなみに足場はScaffold. 舞台裏はStagehand. $ pub global activate stagehand $ mkdir dart-cli-sample $ stagehand console-full 出来上がった雛形の構成は以下. よくある構成なので説明は省略. . ├── CHANGELOG.md ├── README.md ├── analysis_options.yaml ├── bin │   └── dart_cli_sample.dart ├── lib │   └── dart_cli_sample.dart ├── pubspec.lock ├── pubspec.yaml └── test └── dart_cli_sample_test.dart Hello World 雛形のエントリポイントは bin/dart_cli_sample.dart にある main(). 別途、lib/dart_cli_sample.dart にあるコードをimportしている. CLIの雛形だからargumentsを引数にとる. Listで渡される. まぁ普通. import \'package:dart_cli_sample/dart_cli_sample.dart\' as dart_cli_sample; void main(List arguments) { print(\'Hello world: ${dart_cli_sample.calculate()}!\'); } で、lib/dart_cli_sample.dart はどうなっているかというと以下みたいな感じ. int型を返すcalculate()という関数が定義されていて6*7の計算結果を返す. int calculate() { return 6 * 7; } インタラクティブに実行するには、dartコマンドにエントリポイントを渡す. 6*7=42がちゃんと出力された. $ dart bin/dart_cli_sample.dart Hello world: 42! バイナリ生成と実行 これやりたいためにDart-langを選んでみた. Macでバイナリ生成する場合Mac用のバイナリしか作れないといったように、 残念ながらクロスプラットフォーム非対応. CIを構築して各プラットフォーム用に実行しないといけない. $dart compile --help 544ms  日 2/ 6 02:19:51 2022 Compile Dart to various formats. Usage: dart compile [arguments] -h, --help Print this usage information. Available subcommands: aot-snapshot Compile Dart to an AOT snapshot. exe Compile Dart to a self-contained executable. jit-snapshot Compile Dart to a JIT snapshot. js Compile Dart to JavaScript. kernel Compile Dart to a kernel snapshot. Run \"dart help\" to see global options. $ dart compile exe bin/dart_cli_sample.dart -o bin/out1 12.9s  日 2/ 6 02:18:30 2022 Info: Compiling with sound null safety Generated: /Users/ikuty/ikuty/dart-cli-sample/bin/out1 $ ./bin/out1 Hello world: 42! exeオプションでself-contained、つまりDartランタイムが無い環境で実行可能ファイルを作成できる. コンパイル方式としてAOT(Ahead Of Time)、JIT(Just In Time)を選べるという充実ぶり. バイナリのサイズは, self-containedの場合, 5,033,856 bytes(約5MB) だった. $ dart compile aot-snapshot bin/dart_cli_sample.dart -o bin/out2 日 2/ 6 02:47:32 2022 Info: Compiling with sound null safety Generated: /Users/ikuty/ikuty/dart-cli-sample/bin/out2 $ dartaotruntime bin/out2 日 2/ 6 02:47:52 2022 Hello world: 42! aot-snapshotオプションにより, プラットフォーム用の共有ライブラリとアプリケーションコードを分けられる. self-containedと同様にAOTはMacOS,Windows,Linuxそれぞれのプラットフォームが提供される. dartaotruntimeというコマンドにより実行する. バイナリのサイズは 905,072 (約900KB)だった. $ dart compile jit-snapshot bin/dart_cli_sample.dart -o bin/out3 日 2/ 6 02:53:02 2022 Compiling bin/dart_cli_sample.dart to jit-snapshot file bin/out3. Info: Compiling with sound null safety Hello world: 42! $ dart run bin/out3 1005ms  日 2/ 6 02:53:23 2022 Hello world: 42! jit-snapshotオプションにより,JIT実行可能なバイナリを出力できる. プラットフォーム固有のDart中間コードを生成する. dart compile時に1度実行されて処理結果が表示される. ソースコードをparseした結果を事前に準備し,JIT実行時に再利用することで処理速度を上げる. ちょっと詳しくは不明だがAOTよりも高速に処理できる可能性がある. バイナリサイズは 4,824,016bytes. (約4.8MB)だった. $ dart compile kernel bin/dart_cli_sample.dart -o bin/out4 日 2/ 6 03:05:54 2022 Compiling bin/dart_cli_sample.dart to kernel file bin/out4. Info: Compiling with sound null safety $ dart run bin/out4 699ms  日 2/ 6 03:06:02 2022 Hello world: 42! kernelオプションにより,プラットフォーム非依存のKernelASTを生成する. 出力されたバイナリのサイズは1056 bytes (約1KB)だった.ソースコードのパスが含まれており, おそらくソースコードを同時に配布する必要がある. AOTより遅い. まとめ Dart-langのHelloWorldコードを作成し各種コンパイルオプションを試した. Go-langのそれとは異なりクロスプラットフォームのバイナリを生成できないが, 複数のコンパイルオプションが用意されていて,様々なパターンの運用に対応できそう.

default eye-catch image.

flattenでcollectionを平坦化する

Laravelで多次元配列を1次元化するflatten()が便利だった. 連想配列の値のみを収集して1次元配列にしてくれる。 $ ./vendor/bin/sail artisan tinker Psy Shell v0.11.1 (PHP 8.1.2 — cli) by Justin Hileman >>> $collection = collect([ \'hoge\' => [1, 2, 3], ... \'fuga\' => \'aiueo\', ... \'foo\' => 1, ... \'bar\' => null ... ]); => IlluminateSupportCollection {#3529 all: [ \"hoge\" => [ 1, 2, 3, ], \"fuga\" => \"aiueo\", \"foo\" => 1, \"bar\" => null, ], } >>> $collection->flatten(); => IlluminateSupportCollection {#3525 all: [ 1, 2, 3, \"aiueo\", 1, null, ], }

default eye-catch image.

Laravel8 Jetstreamを導入した状態でsocialiteによるSNS認証を両立させる

Laravel8が大きく変わっていたので前回の記事で再入門した。 sailコマンドでコンテナの外からartisanコマンドを叩けて便利。 [clink url=\"https://ikuty.com/2021/05/16/laravel8-sail/\"] Laravel5,6あたりでSocialiteパッケージによりSNS認証を簡単に実装することができた. Laravel8+JetstreamにSocialiteを導入してSNS認証してみた. Jetstreamをインストールし,Jetstreamのrouteがある状態でSocialiteが機能するようにした. JetstreamのAuthはlaravel/uiのようにお手軽にrouteを変更できない様子. 今回はそれには触れず, 最低限の修正でJetstreamとSocialiteを両立させてみる. [arst_toc tag=\"h4\"] Jetstream導入 sailコマンド経由でインストールしていく。 composer, artisanだけでなく, npmもsailで実行できる. # jetstreamをインストールする $ ./vendor/bin/sail composer require laravel/jetstream # livewireをインストールする migrationファイルを作成する $ ./vendor/bin/sail artisan jetstream:install livewire # 作成したmigrationを実行する $ ./vendor/bin/sail artisan migrate # npm install , npm run dev $ ./vendor/bin/sail npm install $ ./vendor/bin/sail npm run dev migrationで作られたテーブル達を確認する. sailからmysqlを叩くことはできそうだが、さらに-eオプションでSQLを続けられなかった。 sail mysqlでいつものmysql clientに繋がる. sailはあくまでもユーザインターフェースなのでこれで良いか. $ ./vendor/bin/sail mysql mysql> show tables; +------------------------+ | Tables_in_example_app | +------------------------+ | failed_jobs | | migrations | | password_resets | | personal_access_tokens | | sessions | | users | +------------------------+ http://localhostを叩くと、認証機能が追加されていることを確認できる。 registerから登録してログインすると認証後URL (./dashboard) にredirectされる. profileに進むとまぁ普通に使いそうな機能が既にインプリメントされていることがわかる. routeの確認 Jetstreamをインストールした直後にJetstreamにより作られたrouteを確認してみる. いやー.. too much過ぎだろう... $ ./vendor/bin/sail artisan route:list +--------+----------+----------------------------------+---------------------------------+---------------------------------------------------------------------------------+-----------------------------------------------------------+ | Domain | Method | URI | Name | Action | Middleware | +--------+----------+----------------------------------+---------------------------------+---------------------------------------------------------------------------------+-----------------------------------------------------------+ | | GET|HEAD | / | | Closure | web | | | GET|HEAD | api/user | | Closure | api | | | | | | | AppHttpMiddlewareAuthenticate:sanctum | | | GET|HEAD | dashboard | dashboard | Closure | web | | | | | | | AppHttpMiddlewareAuthenticate:sanctum | | | | | | | IlluminateAuthMiddlewareEnsureEmailIsVerified | | | GET|HEAD | forgot-password | password.request | LaravelFortifyHttpControllersPasswordResetLinkController@create | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | POST | forgot-password | password.email | LaravelFortifyHttpControllersPasswordResetLinkController@store | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | GET|HEAD | livewire/livewire.js | | LivewireControllersLivewireJavaScriptAssets@source | | | | GET|HEAD | livewire/livewire.js.map | | LivewireControllersLivewireJavaScriptAssets@maps | | | | POST | livewire/message/{name} | livewire.message | LivewireControllersHttpConnectionHandler | web | | | GET|HEAD | livewire/preview-file/{filename} | livewire.preview-file | LivewireControllersFilePreviewHandler@handle | web | | | POST | livewire/upload-file | livewire.upload-file | LivewireControllersFileUploadHandler@handle | web | | | | | | | IlluminateRoutingMiddlewareThrottleRequests:60,1 | | | GET|HEAD | login | login | LaravelFortifyHttpControllersAuthenticatedSessionController@create | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | POST | login | | LaravelFortifyHttpControllersAuthenticatedSessionController@store | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | | | | | IlluminateRoutingMiddlewareThrottleRequests:login | | | POST | logout | logout | LaravelFortifyHttpControllersAuthenticatedSessionController@destroy | web | | | GET|HEAD | register | register | LaravelFortifyHttpControllersRegisteredUserController@create | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | POST | register | | LaravelFortifyHttpControllersRegisteredUserController@store | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | POST | reset-password | password.update | LaravelFortifyHttpControllersNewPasswordController@store | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | GET|HEAD | reset-password/{token} | password.reset | LaravelFortifyHttpControllersNewPasswordController@create | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | GET|HEAD | sanctum/csrf-cookie | | LaravelSanctumHttpControllersCsrfCookieController@show | web | | | GET|HEAD | two-factor-challenge | two-factor.login | LaravelFortifyHttpControllersTwoFactorAuthenticatedSessionController@create | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | POST | two-factor-challenge | | LaravelFortifyHttpControllersTwoFactorAuthenticatedSessionController@store | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | | | | | IlluminateRoutingMiddlewareThrottleRequests:two-factor | | | GET|HEAD | user/confirm-password | password.confirm | LaravelFortifyHttpControllersConfirmablePasswordController@show | web | | | | | | | AppHttpMiddlewareAuthenticate | | | POST | user/confirm-password | | LaravelFortifyHttpControllersConfirmablePasswordController@store | web | | | | | | | AppHttpMiddlewareAuthenticate | | | GET|HEAD | user/confirmed-password-status | password.confirmation | LaravelFortifyHttpControllersConfirmedPasswordStatusController@show | web | | | | | | | AppHttpMiddlewareAuthenticate | | | PUT | user/password | user-password.update | LaravelFortifyHttpControllersPasswordController@update | web | | | | | | | AppHttpMiddlewareAuthenticate | | | GET|HEAD | user/profile | profile.show | LaravelJetstreamHttpControllersLivewireUserProfileController@show | web | | | | | | | AppHttpMiddlewareAuthenticate | | | | | | | IlluminateAuthMiddlewareEnsureEmailIsVerified | | | PUT | user/profile-information | user-profile-information.update | LaravelFortifyHttpControllersProfileInformationController@update | web | | | | | | | AppHttpMiddlewareAuthenticate | | | POST | user/two-factor-authentication | two-factor.enable | LaravelFortifyHttpControllersTwoFactorAuthenticationController@store | web | | | | | | | AppHttpMiddlewareAuthenticate | | | | | | | IlluminateAuthMiddlewareRequirePassword | | | DELETE | user/two-factor-authentication | two-factor.disable | LaravelFortifyHttpControllersTwoFactorAuthenticationController@destroy | web | | | | | | | AppHttpMiddlewareAuthenticate | | | | | | | IlluminateAuthMiddlewareRequirePassword | | | GET|HEAD | user/two-factor-qr-code | two-factor.qr-code | LaravelFortifyHttpControllersTwoFactorQrCodeController@show | web | | | | | | | AppHttpMiddlewareAuthenticate | | | | | | | IlluminateAuthMiddlewareRequirePassword | | | GET|HEAD | user/two-factor-recovery-codes | two-factor.recovery-codes | LaravelFortifyHttpControllersRecoveryCodeController@index | web | | | | | | | AppHttpMiddlewareAuthenticate | | | | | | | IlluminateAuthMiddlewareRequirePassword | | | POST | user/two-factor-recovery-codes | | LaravelFortifyHttpControllersRecoveryCodeController@store | web | | | | | | | AppHttpMiddlewareAuthenticate | | | | | | | IlluminateAuthMiddlewareRequirePassword | +--------+----------+----------------------------------+---------------------------------+---------------------------------------------------------------------------------+-----------------------------------------------------------+ Socialite導入 Laravel5とか6あたりではSocialiteパッケージを導入することでSNS認証を簡単に作れた. Laravel8+Jetstreamでも同じように作れるのか試してみた. 以下の記事を参考にさせていただきました. 【Laravel】JetstreamでSNS認証(ソーシャルログイン) # Socialite インストール ./vendor/bin/sail composer require laravel/socialite # google用provider インストール ./vendor/bin/sail composer require socialiteproviders/google OAuth idとsecret を取得しておく. (id,secretの発行にはこちらを参考にさせていただきました.) Callback redirect先のURLとして http://localhost/login/google/callback を登録する. Socialite実装 .envにOAuth認証id,secret,redirectURLを書く. .env自体はhostから編集すれば良い. GOOGLE_KEY=\"*****-*******.apps.googleusercontent.com\" GOOGLE_SECRET=\"****-****\" GOOGLE_REDIRECT_URI=\"http://localhost/login/google/callback\" config/servicesに以下の設定を追加する. \'google\' => [ \'client_id\' => env(\'GOOGLE_KEY\'), \'client_secret\' => env(\'GOOGLE_SECRET\'), \'redirect\' => env(\'GOOGLE_REDIRECT_URI\'), ], Routeを追加する. Laravel7までとLaravel8ではRouteの書き方が異なる. Laravel7までは app/Providers/RouteServiceProvider.php に名前空間が定義されているため, Routeに書くコントローラの名前空間を書かなくても自動的に解決してくれた. 例えば, LoginController::class と書くと, 自動的にApp/Http/Controllers/LoginController::class と解釈された. Laravel8では, 名前空間を省略できなくなった. Route::prefix(\'login/{provider}\')->where([\'provider\'=> \'google\'])->group(function(){ Route::get(\'/\',[AppHttpControllersAuthLoginController::class, \'redirectToProvider\'])->name(\'sns_login.redirect\'); Route::get(\'/callback/\',[AppHttpControllersAuthLoginController::class, \'handleProviderCallback\'])->name(\'sns_login.callback\'); }); Socialite Providerを config/app.php のproviders に追加する /* * Socialite Providerをconfig/app.php の providers に追加する */ \'providers\' => [ ... SocialiteProvidersManagerServiceProvider::class, ... ], app/Providers/EventServiceProvider.php を以下の通り変更する. <?php namespace AppProviders; use IlluminateAuthEventsRegistered; use IlluminateAuthListenersSendEmailVerificationNotification; use IlluminateFoundationSupportProvidersEventServiceProvider as ServiceProvider; use IlluminateSupportFacadesEvent; use SocialiteProvidersManagerSocialiteWasCalled; //追加 class EventServiceProvider extends ServiceProvider { /** * The event listener mappings for the application. * * @var array */ protected $listen = [ Registered::class => [ SendEmailVerificationNotification::class, ], // 追加 SocialiteProvidersManagerSocialiteWasCalled::class => [ \'SocialiteProviders\\Google\\GoogleExtendSocialite@handle\', ], ]; /** * Register any events for your application. * * @return void */ public function boot() { // } } SNS認証によるログインを担うコントローラを自力で作成する. $ ./vendor/bin/sail artisan make:controller Auth\\LoginController Controller created successfully. 作成したコントローラの中身は以下の通り. <?php namespace AppHttpControllersAuth; use AppHttpControllersController; use AppModelsUser; use IlluminateHttpRequest; use LaravelSocialiteFacadesSocialite; use IlluminateSupportFacadesHash; use IlluminateSupportStr; class LoginController extends Controller { // メディア側へのリダイレクト public function redirectToProvider(Request $request) { $provider = $request->provider; return Socialite::driver($provider)->redirect(); } // メディア側から返されるユーザー情報 public function handleProviderCallback(Request $request) { $provider = $request->provider; $sns_user = Socialite::driver($provider)->user(); $sns_email = $sns_user->getEmail(); $sns_name = $sns_user->getName(); // 登録済ならログイン。未登録ならアカウント登録してログイン if(!is_null($sns_email)) { $user = User::firstOrCreate( // Userモデルに、レコードがあれば取得、なければ保存 [ \'email\' => $sns_email ], [ \'email\' => $sns_email, \'name\' => $sns_name, \'password\' => Hash::make(Str::random()) ]); auth()->login($user); session()->flash(\'oauth_login\', $provider.\'でログインしました。\'); return redirect(\'/\'); } return \'情報が取得できませんでした。\'; } } viewを作成する. ファイル名は app/View/auth/login.blade.php. Routeで書いた sns_login_redirect ページに遷移するリンクがあるだけ. <div> <a href=\"{{ route(\'sns_login.redirect\', \'google\') }}\">Google </div> Welcomeページのログインを修正 普通は何らかの画面が既にあってそこにSocialiteを組み込むと思うが, 今回は何もないので, とりあえずWelcomeページのログインをSocialite用に書き換えてみる. Jetstreamのrouteを変えようとしたが闇が深そうなので見なかったことにする. ちょっとJetstreamは出来が良くないのかなー.. デフォルトのWelcomeページのログインは, Jetstreamが生成する /login に合わせて作られてある. このままだと, Jetstreamが作った認証機構が動く. 例えば以下のように変更するとWelcomeページのログインをSocialiteのものに差し替えることができる. route(\'login\')をroute(\'sns_login.redirect\',\'google\')に変更した. また, registerは不要なので, registerへの遷移リンクを削除した. <body class=\"antialiased\"> <div class=\"relative flex items-top justify-center min-h-screen bg-gray-100 dark:bg-gray-900 sm:items-center py-4 sm:pt-0\"> @if (Route::has(\'sns_login.redirect\')) <div class=\"hidden fixed top-0 right-0 px-6 py-4 sm:block\"> @auth <a href=\"{{ url(\'/dashboard\') }}\" class=\"text-sm text-gray-700 underline\">Dashboard @else <a href=\"{{ route(\'sns_login.redirect\',\'google\') }}\" class=\"text-sm text-gray-700 underline\">Log in @endauth </div> @endif ... 動作確認 未ログインの状態で http://localhost を開くと, Welcome画面が表示され, Login への遷移リンクが表示される. Loginを押下すると, Googleのログイン画面に遷移する. アカウントを選択すると, http://localhost/login/google/callback にredirectがかかる. もし当サイトにアカウントがなければ,アカウントを作成する. アカウントがあれば,そのユーザでログインする. 晴れて, Googleアカウントと同じメールアドレスを持つユーザでログインした状態でダッシュボード(./dashbaord)が開く.

default eye-catch image.

Laravel8 sailで環境構築

とにかく進歩が早いLaravel。 セマンティックバージョニングになった6あたりから結構な速度で機能を乗せて来た感がある. 付いていくのがなかなか大変というのはある. 開けた口に無理やり食べ物を押し込んでくるような強引さの中にセンスの良さを感じ取れるので、 ちょっと付いて行ってみることにする. [arst_toc tag=\"h4\"] Laravel sail Laravel公式が用意するDocker開発環境を操作する軽量なコマンドラインインターフェース. ポイントは、コンテナの外部からコンテナ内のLaravelに対してコマンドを実行できる点. dockerコマンドをラップし、コンテナの内部で実行した結果を応答する仕組みとなっている. フルスタックフレームワークであるLaravelらしく何でも内包してしまう. composerやartisanコマンド実行のために、わざわざdockerコマンドを叩くのは辛い. sailが無いとdockerコマンドを叩きまくるか、コンテナに入って作業する必要がある. sailを使うことで、コンテナの中に入らず外からかsailコマンドを実行できる. こんな風にするとdockerの上位に来る仕組みを作れるのか、と結構感動. sailでプロジェクトを作る 既存のプロジェクトにsailを導入するパターンと、新規にプロジェクトを作成するパターンの2通りがある. 今回は新規にプロジェクトを作成していく. https://laravel.build/example-app というURLはShellScriptのコードを返す. withの後ろにインストールしたいミドエウウェアを指定する. 今回はmysqlだけ. カンマ区切りで複数指定可. $ mkdir -p ~/hoge && cd ~/hoge $ curl -s \"https://laravel.build/example-app?with=mysql\" | bash $ cd example-app && ./vendor/bin/sail up ちなみに、https://laravel.build/example-appは以下のShellScriptを返す. そのShellScriptは何をやっているかというと. laravelsail/php80-composerというイメージからコンテナを起動する. laravel newコマンドでプロジェクトを作成する. artisan sail:installコマンドを実行する. ディレクトリのOwnerを変更する. (パスワードが要求される) docker info > /dev/null 2>&1 # Ensure that Docker is running... if [ $? -ne 0 ]; then echo \"Docker is not running.\" exit 1 fi docker run --rm -v $(pwd):/opt -w /opt laravelsail/php80-composer:latest bash -c \"laravel new example-app && cd example-app && php ./artisan sail:install --with=mysql\" cd example-app CYAN=\'33[0;36m\' LIGHT_CYAN=\'33[1;36m\' WHITE=\'33[1;37m\' NC=\'33[0m\' echo \"\" if sudo -n true 2>/dev/null; then sudo chown -R $USER: . echo -e \"${WHITE}Get started with:${NC} cd example-app && ./vendor/bin/sail up\" else echo -e \"${WHITE}Please provide your password so we can make some final adjustments to your application\'s permissions.${NC}\" echo \"\" sudo chown -R $USER: . echo \"\" echo -e \"${WHITE}Thank you! We hope you build something incredible. Dive in with:${NC} cd example-app && ./vendor/bin/sail up\" fi sailでコンテナを立ち上げる 要はdocker-compose upをラップしたsail upコマンドを叩く. PHPのbundlerであるcomposerの仕様上, vendor 以下にモジュールがインストールされる. sailコマンドも ./vendor/bin/ に入っている. そこで ./vendor/bin/sail up を実行する. $ cd example-app $ ./vendor/bin/sail up dockerそのものなので, Ctrl+Cで落ちる. もちろん、./vendor/bin/sail up -d によりバックグラウンドで立ち上がる. $ ./vendor/bin/sail up -d ブラウザからhttp://localhostを開く あっさり開けた. ちなみに Dockerfile内で /usr/local/bin/start-containerを実行している. start-container内ではsupervisordによりLaravelのビルトインサーバをデーモン化している. #!/usr/bin/env bash if [ ! -z \"$WWWUSER\" ]; then usermod -u $WWWUSER sail fi if [ ! -d /.composer ]; then mkdir /.composer fi chmod -R ugo+rw /.composer if [ $# -gt 0 ];then exec gosu $WWWUSER \"$@\" else /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf fi supervisord.confは以下の通り. [supervisord] nodaemon=true user=root logfile=/var/log/supervisor/supervisord.log pidfile=/var/run/supervisord.pid [program:php] command=/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80 user=sail environment=LARAVEL_SAIL=\"1\" stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 sailでLaravelのバージョンを確認してみる 試しにコンテナの外からsailコマンドでartisan --versionを実行してみる. まるでコンテナの外からartisanコマンドを打っているような感覚. 良いと思う. $ ./vendor/bin/sail artisan --version Laravel Framework 8.41.0