20211203のMySQLに関する記事は10件です。

一番スマートなNode.js+TypeScript+MySQLの使い方

はじめに 割と久々に Node.js で MySQL に繋ぐプログラムを書いてみたのですが、以前と比べて色々パワーアップしていたので、それらを組み合わせて「現時点のベストプラクティスはこんなんかな?」という感じの検討をしてみました。 多分これが一番スマートだと思います・・・という結論に達したのですが、どうだろうか Node.js はまだ触り始めたばかりなので、ご意見を頂けると幸いです ユーティリティ実装 この辺は好みが分かれるところかもしれませんが、DBアクセス用のユーティリティを作ります。 デザインパターン的にコレがベストかは少し自信がありませんが、Node.js だと流行りのパッケージはちょくちょく変わる(ex: mysql が mysql2 になったり、redisが ioredis になったり...etc)ので、自前コードで一枚噛ませて(ラップして)おいた方が良いと思います。(モノによりけりかもしれませんがインフラストラクチャ関連は特に) 関連パッケージ npm install --save dotenv npm install --save mysql2 npm install --save-dev @types/mysql DBユーティリティ実装 db.ts import mysql from 'mysql2' require('dotenv').config() class DatabaseUtility { private queryFormat: any constructor() { this.queryFormat = (query: string, values: Array<string>) => { if (!values) return query return query.replace(/\:(\w+)/g, (txt, key) => { return values.hasOwnProperty(key) ? mysql.escape(values[key]) : txt }) } } private connect(callback: (dbc: mysql.Connection) => Promise<any>): Promise<any> { return new Promise((resolve, reject) => { const dbc = mysql.createConnection({ host: process.env.RDB_HOST, user: process.env.RDB_USER, password: process.env.RDB_PASSWORD, database: process.env.RDB_NAME }) dbc.connect((error) => { if (error) { reject(error) } else { dbc.config.queryFormat = this.queryFormat callback(dbc) .then(result => resolve(result)) .catch(error => reject(error)) .finally(() => dbc.end()) } }) }) } private sendQuery(dbc: mysql.Connection, query: string, option?: any): Promise<any> { return new Promise((resolve, reject) => { dbc.query(query, option, (error, results) => { if (error) { reject(new Error(`SQL error: $query`)) } else { resolve(results) } }) }) } private async sendQueries(dbc: mysql.Connection, queries: Array<{ query: string, option?: any }>) { for (var i = 0; i < queries.length; i++) { await this.sendQuery(dbc, queries[i].query, queries[i].option) } } query(query: string, option?: any): Promise<any> { return this.connect((dbc: mysql.Connection) => this.sendQuery(dbc, query, option)) } queries(queries: Array<{ query: string, option?: any }>): Promise<any> { return this.connect((dbc: mysql.Connection) => this.sendQueries(dbc, queries)) } } const db = new DatabaseUtility() export default db 上記のコードは Public Domain としておきます。 要点解説 使い方: DB とアクセスするモジュールで import db from './path/to/db' をして db.query async/await の対応処理は db.query で実装 なので、mysql2/promise ではなく mysql2 をそのまま使用 依存パッケージは少なければ少ないほど良いかなと コネクション管理とか面倒なので上位レイヤーには意識させない db.query の都度 connect ~ end している db.queries で 1 回の connect ~ end で複数クエリ実行できるようにしている 当初 ConnectionPool を使って db.query だけで良いかと思ったが、AWS Aurora での使用を想定するとフェイルオーバー時に事故りそうだったので都度接続方式にした dbc.query のエラーケースの大半は開発時の SQL の構文エラーと考えられる(RDBMSとの通信エラーなどはconnect時に起きる)ので reject(new Error(`SQL error: ${query}`)) としている(別途エラーを記録した方が良いかもしれないが) テスト 準備 .env ファイル RDB_HOST="localhost" RDB_USER="root" RDB_PASSWORD="password" RDB_NAME="test1" SQL CREATE DATABASE test1; CREATE TABLE test1.table1 ( id BIGINT AUTO_INCREMENT, name TEXT, value INT, KEY(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; コード test.ts import db from './db' (async () => { const item1 = { name: "hoge", value: 1234 } const item2 = { name: "hige", value: 5678 } const item3 = { name: "huga", value: 90 } const sql = "insert into table1 (name, value) values (:name, :value)" await db.query(sql, item1) await db.query(sql, item2) await db.query(sql, item3) return await db.query("select * from table1") })().then((result) => { console.dir(result) }).catch((error) => { console.error(error) }) 実行結果 正常にクエリ select * from table1 の実行結果が表示されました。 % npx ts-node test [ { id: 1, name: 'hoge', value: 1234 }, { id: 2, name: 'hige', value: 5678 }, { id: 3, name: 'huga', value: 90 } ] まとめて実行したい場合 ConnectionPool を使っておけばこれの存在意義はなかったのですが、AWS Aurora でのユースケースを想定して都度 Connect する方式に変更しているので、複数クエリをまとめて実行したい場合、以下のようにすれば高速になります。 await db.queries([ { query: sql, option: item1 }, { query: sql, option: item2 }, { query: sql, option: item3 }, ]) まとめ async/await いいですね
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

beegoとgormでチャットapiを作成する

flutterのチュートリアル?codelabs(https://codelabs.developers.google.com/codelabs/flutter) でチャットアプリの作成をやっている中で、チャットのメッセージ情報を腹持ちしているのをapiから取得したいと思いbeegoでサクッとapiを作成しました。 コード(https://github.com/fu-yuta/go_friendly_chat/tree/create_chat_api) 環境 go: v1.17 beego: v1.12.1 gorm: v1.9.16 mysql: v8.0.27 for macos11.6 フォルダ構成 ├── conf │   └── app.conf ├── controllers │   ├── chat.go │   ├── requests │   │   └── chat.go │   └── responses │   └── chat.go ├── go.mod ├── main.go ├── models │   └── chat.go └── routers   └── router.go bee api {アプリ名}で作成されるテンプレートの構成を使用しています。 DB操作 DBへの接続 接続情報をconf/app.confに記述する。 db_driver_name = mysql db_name = chat_db db_user_name = root db_user_password = root db_host = tcp(127.0.0.1:3306) model/chat.goでDBに接続(初期化)する。 model/chat.go import ( "errors" "fmt" "go_friendly_chat/controllers/requests" "log" "github.com/astaxie/beego" "github.com/jinzhu/gorm" _ "github.com/jinzhu/gorm/dialects/mysql" ) var db *gorm.DB func init() { db = setupDB() } func setupDB() *gorm.DB { DBDriverName := beego.AppConfig.String("db_driver_name") DBName := beego.AppConfig.String("db_name") DBUserName := beego.AppConfig.String("db_user_name") DBUserPassword := beego.AppConfig.String("db_user_password") DBHost := beego.AppConfig.String("db_host") connectionInfo := fmt.Sprintf("%s:%s@%s/%s", DBUserName, DBUserPassword, DBHost, DBName) db, err := gorm.Open(DBDriverName, connectionInfo) if err != nil { log.Println(err.Error()) } return db } DBへの操作 models/chat.goにDBへの各操作を記述する chatデータの定義 今回は、ユーザーの名前とメッセージを持つデータを定義する。 models/chat.go type Chat struct { Id uint `gorm:"primary_key"` UserName string `gorm:"size:255"` Message string `gorm:"size:255"` } Id uint gorm:"primary_key"で定義した値は、データの保存の際などにgormがユニークでいい感じに連番(1からスタート)を振ってくれるようでした。 レコードの取得 全てのデータを検索する時はFindにchatデータのスライスを送る。 models/chat.go func GetAllChats() []Chat { var allChats []Chat // SELECT * FROM chat db.Find(&allChats) return allChats } 単一データの検索はFirstにchatデータとidの値を送る。 見つからなかった場合はidが0となる。 models/chat.go func GetChat(id string) (*Chat, error) { var chat Chat // SELECT * FROM chat WHERE id = 10;(入力したidが10の場合) db.First(&chat, id) if chat.Id == 0 { log.Println("not found chat error") return nil, errors.New("not found chat error") } return &chat, nil } レコードの保存 NewRecordでchatデータをDBに保存するレコードに変換して、Createで保存する models/chat.go func AddChat(c Chat) (*Chat, error) { if !db.NewRecord(c) { log.Println("NewRecord error") return nil, errors.New("NewRecord error") } // INSERT INTO `chat` (`id`,`user_name`,`message`) VALUES ("Id", "UserName", "Message") err := db.Create(&c).Error if err != nil { log.Println("Create error") return nil, err } return &c, nil } レコードの更新 更新するデータをFirstで取得してから、Saveで値を更新する。 models/chat.go func UpdateChat(id string, updateChat requests.UpdateChat) (*Chat, error) { var chat Chat db.First(&chat, id) if chat.Id == 0 { log.Println("not found chat error") return nil, errors.New("not found chat error") } chat.Message = updateChat.Message // UPDATE chat SET user_name=UserName, message=Message WHERE id=Id; err := db.Save(&chat).Error if err != nil { log.Println("Update error") return nil, err } return &chat, nil } レコードの削除 更新するデータをFirstで取得してから、Deleteで値を削除する。 models/chat.go func DeleteChat(id string) (*Chat, error) { var chat Chat db.First(&chat, id) if chat.Id == 0 { log.Println("not found chat error") return nil, errors.New("not found chat error") } // DELETE from chat where id = Id; err := db.Delete(&chat).Error if err != nil { log.Println("Delete error") return nil, err } return &chat, nil } Controllerの追加 apiのリクエストに合わせて、先程のmodel/chat.goのメソッドをそれぞれ呼び出す。 controllers/chat.go package controllers import ( "encoding/json" "go_friendly_chat/controllers/requests" "go_friendly_chat/controllers/responses" "go_friendly_chat/models" "log" "github.com/astaxie/beego" ) type ChatController struct { beego.Controller } // @Title CreateChatMessage // @Description create message // @Param body body requests.Chat true "body for user content" // @Success 200 {object} responses.Chat // @Failure 403 : requests.Chat is empty // @Failure 500 internal server error // @router / [post] func (c *ChatController) Post() { var chat requests.Chat err := json.Unmarshal(c.Ctx.Input.RequestBody, &chat) if err != nil { log.Println("Chat Post json.Unmarshal error") c.Ctx.Output.SetStatus(403) c.ServeJSON() } newChat, err := models.AddChat(models.Chat{ Id: 0, UserName: chat.UserName, Message: chat.Message, }) if err != nil { log.Println("AddChat error") c.Ctx.Output.SetStatus(500) c.ServeJSON() } res := responses.Chat{ Id: int(newChat.Id), UserName: newChat.UserName, Message: newChat.Message, } c.Data["json"] = res c.ServeJSON() } // @Title GetAll // @Description get all Chats // @Success 200 {object} responses.Chats // @router / [get] func (c *ChatController) GetAll() { chats := models.GetAllChats() var res responses.Chats for _, chat := range chats { res.Chats = append(res.Chats, responses.Chat{ Id: int(chat.Id), UserName: chat.UserName, Message: chat.Message, }) } c.Data["json"] = res c.ServeJSON() } // @Title Get // @Description get chat by id // @Param id path string true // @Success 200 {object} responses.Chat // @Failure 403 :id is empty // @Failure 404 :chat is not found // @router /:id [get] func (c *ChatController) Get() { id := c.GetString(":id") if id != "" { chat, err := models.GetChat(id) if err != nil { log.Println("chat is not found") c.Ctx.Output.SetStatus(404) c.ServeJSON() } else { res := responses.Chat{ Id: int(chat.Id), UserName: chat.UserName, Message: chat.Message, } c.Data["json"] = res } c.ServeJSON() } else { log.Println("id is empty error") c.Ctx.Output.SetStatus(403) c.ServeJSON() } } // @Title Update // @Description update the chat // @Param id path string true // @Param body body requests.UpdateChat true // @Success 200 {object} responses.Chat // @Failure 403 :id is not int // @Failure 500 internal server error // @router /:id [put] func (c *ChatController) Put() { id := c.GetString(":id") if id != "" { var req requests.UpdateChat json.Unmarshal(c.Ctx.Input.RequestBody, &req) updateChat, err := models.UpdateChat(id, req) if err != nil { c.Ctx.Output.SetStatus(500) c.ServeJSON() } res := responses.Chat{ Id: int(updateChat.Id), UserName: updateChat.UserName, Message: updateChat.Message, } c.Data["json"] = res c.ServeJSON() } else { log.Println("id is empty error") c.Ctx.Output.SetStatus(403) c.ServeJSON() } } // @Title Delete // @Description delete the chat // @Param id path string true // @Success 200 {object} responses.Chat // @Failure 403 id is empty // @Failure 500 internal server error // @router /:id [delete] func (c *ChatController) Delete() { id := c.GetString(":id") if id != "" { deleteChat, err := models.DeleteChat(id) if err != nil { log.Println("Delete error") c.Ctx.Output.SetStatus(500) c.ServeJSON() } res := responses.Chat{ Id: int(deleteChat.Id), UserName: deleteChat.UserName, Message: deleteChat.Message, } c.Data["json"] = res c.ServeJSON() } else { log.Println("id is empty error") c.Ctx.Output.SetStatus(403) c.ServeJSON() } } リクエスト/レスポンス用のデータを定義する。 リクエスト用 controllers/requests/chat.go package requests type Chat struct { UserName string `json:"user_name"` Message string `json:"message"` } type UpdateChat struct { Message string `json:"message"` } レスポンス用 controllers/responses/chat.go package responses type Chat struct { Id int `json:"id"` UserName string `json:"user_name"` Message string `json:"message"` } type Chats struct { Chats []Chat `json:"chats"` } ルーティングの設定 Controllerへのルーティングをrouters/router.goに追加する routers/router.go package routers import ( "go_friendly_chat/controllers" "github.com/astaxie/beego" ) func init() { ns := beego.NewNamespace("/v1", beego.NSNamespace("/chat", beego.NSInclude( &controllers.ChatController{}, ), ), ) beego.AddNamespace(ns) } データベースの準備 下記を作成する。 データベース: chat_db テーブル: chats COLUMNS +-----------+--------------+------+-----+---------+----------------+ | Field | Type | Null | Key | Default | Extra | +-----------+--------------+------+-----+---------+----------------+ | id | int unsigned | NO | PRI | NULL | auto_increment | | user_name | varchar(255) | YES | | NULL | | | message | varchar(255) | YES | | NULL | | +-----------+--------------+------+-----+---------+----------------+ gormのマイグレーションで自動で作成することも可能 先程のmodels/chat.goで定義した、Chatデータを元にテーブルを作成してくれる。 db.AutoMigrate(&models.Chat{}) 今回はサーバーを起動する前に、毎回マイグレーションするようにしている。(マイグレーション処理は分離したほうが良い気がしますが…) main.go package main import ( "fmt" "go_friendly_chat/models" _ "go_friendly_chat/routers" "log" "github.com/astaxie/beego" "github.com/jinzhu/gorm" ) func main() { db := setupDB() db.AutoMigrate(&models.Chat{}) if beego.BConfig.RunMode == "dev" { beego.BConfig.WebConfig.DirectoryIndex = true beego.BConfig.WebConfig.StaticDir["/swagger"] = "swagger" } beego.Run() } func setupDB() *gorm.DB { dbDriverName := beego.AppConfig.String("db_driver_name") dbName := beego.AppConfig.String("db_name") dbUserName := beego.AppConfig.String("db_user_name") dbUserPassword := beego.AppConfig.String("db_user_password") dbHost := beego.AppConfig.String("db_host") connectTemplate := "%s:%s@%s/%s" connect := fmt.Sprintf(connectTemplate, dbUserName, dbUserPassword, dbHost, dbName) db, err := gorm.Open(dbDriverName, connect) if err != nil { log.Println(err.Error()) } return db apiの確認 サーバーを起動して bee run -downdoc=true -gendoc=true swaggerから確認できる.。 http://127.0.0.1:8080/swagger/ おわりに bee api {アプリ名}でapiのテンプレートを作成してくれて、起動したらswaggerからすぐに動作確認できるのでサクッとapi開発できました。 今回はDBへの基本的なCRUD操作しかやらなかったですが、より複雑な処理は公式ドキュメントを参照してください。 (https://gorm.io/ja_JP/docs/index.html)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

億単位のレコード数のテーブルを含むDBをAWS Database Migration Serviceを使って移行した際のTIPS

本記事は、サムザップ Advent Calendar 2021 の12/10の記事です。 はじめに この記事では、表題の通り億単位のレコード数のテーブルを含むDBをAWSのDMS(Database Migration Service)を使ってデータ移行する検証をしていた際に、時間が掛かり過ぎることが分かったので、それを解消するためにやったことを記載したいと思います。 普段はあまり使うことのないサービスだと思いますが、お引越し(オンプレ->クラウドやクラウドA->クラウドB)するときだけでなく、運用が長くなり、DBサーバーを統廃合するようなシチュエーションでも役に立つサービスだと思います。 弊社ではスマートフォン向けのゲームを数々運用してきましたが、運用が長くなり、ユーザーデータは増え続ける一方、アクセス数がピーク程ではななくなってきたサービス等において、DBサーバーを統廃合する際に重宝しています。 普段使わないだけに、いざやろうと思うと色々分からないことや躓きポイントがあったり、今回のように想定していた時間内にデータ移行が終わらない!!となって、困ることもあると思います。 少しでもそんな方々のお役に立てれば嬉しいです。 要約 データ移行時間を短縮するためにやったこと、その他各種Tipsを記載 タスクを分割する 特定のテーブルを別タスクに切り出す 更にそのタスクをfilterを使って分割する 用語 本記事では、基本的にDMSを利用したことがある方、利用しようとしてある程度ドキュメントを読んでいる方を対象としていますが、一応基本的なワードだけ簡単に説明します。 DMS データベースを AWS に迅速かつ安全に移行するためのサービス 引用元:https://docs.aws.amazon.com/ja_jp/dms/latest/userguide/Welcome.html タスク データ移行の単位であり、それらの処理が行うもの・場所 AnAWSDatabase Migration Service (AWS DMS) タスクは、すべての処理が行われる場所です。ログ記録要件、制御テーブルデータ、エラー処理など、移行と特別な処理に使用するテーブル(またはビュー)とスキーマを指定します。 引用元:https://docs.aws.amazon.com/ja_jp/dms/latest/userguide/CHAP_Tasks.html テーブルマッピング ソースフィルター ミッション 今回のミッションは、水平分割用に用意されたAuroraクラスターをメンテナンス時間10時間以内に半分にすること。 メンテナンス中にはその他作業もあるため、データ移行に当てられる時間は正味4時間くらい。 環境 Aurora MySQL エンジンバージョン:5.7.mysql_aurora.2系 経緯 とあるプロジェクトにおいて、DBサーバーの統廃合が遡上に登り、事前に実行時間を計測したところ、約6時間掛かることが分かりました。 メンテナンス時間を考慮すると、4時間以内に抑えたかったため、再度DBサーバー及びレプリケーションインスタンスをスケールアップし、4時間程度で完了できることを確認しました。 その後しばらくして運用中の各種施策のスケジュールを加味し、メンテナンス日時が確定し、改めてデータ移行を実施してみると… 6時間経っても終わらない!!状態になっていました。。 ということで、急ぎデータ移行時間を短縮する必要に迫られました。 確認したこと 多くはAWS様のドキュメントにまとまっており、そちらを参考にさせていただきました。 移行タスクの実行が遅くなる最も一般的な原因は、AWS DMSレプリケーションインスタンス。インスタンスのリソースが実行中のタスクのために十分であることを確認するには、レプリケーションのインスタンスの CPU、メモリ、スワップファイル、および IOPS の使用率をチェックします。 しかしいずれのリソースを確認しても特に問題があるように見えませんでした。 実施したこと そこで社内の他プロジェクトの方に相談し、どうやらタスクを分割すると速くなるという助言をもらいました。 元々は1DBスキーマ:1タスクという基本的な設定でしたが、これをテーブル単位に分割し、テーブルAはタスク1、テーブルBはタスク2…のような形(テーブルマッピング)にしました。 更にタスクのテーブル統計を確認したところ、レコード数が多い、もしくはデータ量の大きい一部のテーブルが突出して時間が掛かっていることが分かったため、ソースフィルターを使って1000万レコードずつ別タスクに分割しました。 1DBスキーマ1タスクのルール 例1: Testスキーマ配下の全テーブルを対象とする場合 { "rules": [ { "rule-type": "selection", "rule-id": "1", "rule-name": "1", "object-locator": { "schema-name": "Test", "table-name": "%" }, "rule-action": "include" } ] } テーブル毎にタスクを分割したルール 例2-1: Testスキーマ配下のtestテーブルのみを対象とする場合 { "rules": [ { "rule-type": "selection", "rule-id": "1", "rule-name": "1", "object-locator": { "schema-name": "Test", "table-name": "test" }, "rule-action": "include" } ] } 例2-2: Testスキーマ配下のtestテーブル以外のテーブルを対象とする場合 { "rules": [ { "rule-type": "selection", "rule-id": "1", "rule-name": "1", "object-locator": { "schema-name": "Test", "table-name": "%" }, "rule-action": "include" }, { "rule-type": "selection", "rule-id": "1", "rule-name": "1", "object-locator": { "schema-name": "Test", "table-name": "test" }, "rule-action": "exclude" } ] } ソースフィルターで更にテーブルを分割した図 例3: Testスキーマ配下のtestテーブル、かつid列の値が1〜10,000,000のレコードを対象とする場合 { "rules": [ { "rule-type": "selection", "rule-id": "1", "rule-name": "1", "object-locator": { "schema-name": "Test", "table-name": "test" }, "rule-action": "include", "filters": [{ "filter-type": "source", "column-name": "id", "filter-conditions": [{ "filter-operator": "between", "value": "", "start-value": "1", "end-value": "10000000" }] }] } ] } ※例えば3億レコードあれば、start-valueとend-valueを変えて、30タスクの定義が必要 結果、データ移行自体は約2時間半程度で完了できるようになりました。 と、ここまでが今回の対応の主な内容となっています。 以下ここに到達するまでに試行錯誤した際のTIPSを記載します。 その他のTIPSや試したこと(試そうと思ったこと) timezoneに気をつける mysqlのtimestamp型は、システム変数time_zoneの影響を受けるため、ソースエンドポイント側のextra connection attributesにserverTimezone=Asia/Tokyo;を設定しないと、データ移行した際に-9時間でデータが生成されてしまいました。 ※逆にターゲットエンドポイント側に設定しても良いかも!? ワイルドカード タスクのルールの定義内のobject-locator内のtable-nameは、状況によってワイルドカード(%)が使えたり、使えなかったりするので注意。 rule-typeがselectionで、かつrule-actionがincludeもしくはexcludeの場合は、ワイルドカードが使えると記載があり、実際使えるのですが、filters(ソースフィルタ)を指定した場合は使えないので注意。 タスクの起動数について タスクを多数に分割した結果、一度にタスクを起動すると、そもそもタスクが起動できず、失敗になるケースもありました。 再実行で正常に実行できましたが、エラー処理タスクの設定によっても挙動が変わると思いますので、同時起動するタスク数を調整した方が良いと思います。 同様にリソースを限界まで有効に使おうと、インスタンスタイプを調整したのですが、ある程度余裕がないと、途中でタスクが進捗しなくなり、最終的に失敗するケースがありました。 インスタンスタイプを変更する場合は、各種メトリクスや各DBのprocessの実行状況を確認し、ある程度余裕をもったインスタンスを用意することをオススメします。 (検証してから、本番までの間にもデータ量が増えると思いますし) MaxFullLoadSubTasks DMSには、上記のように手動でタスクを分割しなくてもタスク設定のFullLoadSettings内にMaxFullLoadSubTasksという設定があり、こちらを増やせば速度が上がりそうと思いましたが、レプリケーションインスタンスのコア数の2倍に設定しても、同じに設定しても変わらなかったので、コア数に合わせました。 まとめ AWS様のドキュメントにある通り、CPU、メモリ、スワップファイル、およびIOPSの使用率に問題がなく、スケールアップしてもデータ移行時間が短縮できない場合は、こちらで紹介したようにタスクを分割し、並列実行数を上げると劇的に速くなる場合があることが分かりました。 同じようにデータ移行の時間が長くて困っている方の一助となれば幸いです。 明日は @KoniroIris さんの記事です。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ファミレスのメニューもどき管理画面を作ってみた

下記サンプルサイトは同じDBから書き出したメニュー注文ページです。 ▶︎サンプルサイト 下記の記事の続きです ▶︎ファミレスのメニューシステムもどき作ってみた やりたいこと DBの連携、入力、変更、削除をブラウザで操作 画像アップは別フォルダへ DB一覧書き出し バリデーションで同じ名前と画像はアップさせない 作成ファイル index.php(ログイン画面) logout.php(ログアウト画面) control_top.php(トップページと追加入力) change.php(変更UPDATE) change_check.php(UPDATEのバリデーション) delete.php(削除) db_join.php(インクルード用、DBの接続や共通で使う関数と配列を格納) select_cat.php(インクルード用、formのselectパーツ) select_mt.php(インクルード用、formのselectパーツ) 管理画面のUI jsで各操作をタブで切り替え 変更と削除はID検索で各ページにsubmitで移行。 ※管理画面はDBにアクセスしてデータの変更が出来てしまう為、セキュリティーの心配もあるので一般公開は控えています。 画像は最初DBに入れるつもりでしたが、XAMPでMySQLに入れようとしたらサイズの上限に引っかかりUP不可。 なのでUP専用のディレクトリを作りデータはそっちへ、画像の名前だけDBに保存しておくことに。 数と名前を合わせておかないと後々厄介なので、入れ替え、削除のあった時は同じく連動して合わせていく仕様に設計しました。 DBのカラムだけ配列として取得したい 1列の値だけ取り出す方法が分かりませんでした。 半日かけて調べてみたところarray_column()という関数を使えば出来るのでは? という方法に辿り着く。 control_top.php //商品名だけ配列化 $itemArray = array_column($data, 'item'); ///////省略///// //エラー対処 if(!check_word($item,25)){ $err['item'] = '空文字か入力値が超えています'; } elseif (in_array($item,$itemArray,true)) { //DBに同じ名前を入れさせない $err['item']= '同じ名の商品があります'; } else { $err['item']= ''; } 同じ商品名を被らせたくなかったので、DBのカラムに同じ商品名があるかチェック。 array_column(全配列の入った変数, ‘カラム名’)で商品名だけ配列化してif文で振り分け。 同じだったら省く仕組みです。 IDのカラムも配列に まずIDのカラム値を配列にします。 $id_Array = array_column($data, 'ID'); if(in_array($id,$id_Array))でDBから取り出したIDと入力されたIDを照合します。 change.php //inputから持ってきたname属性値 $id = html_escape($_POST['stockid']); //idを全角もOKにする $id = mb_convert_kana($id, 'n', 'UTF-8'); //DBより一覧表書き出し ID照合用 $sql_list = 'SELECT * FROM menulist'; $stmt = $dbh->prepare($sql_list); $stmt->execute(); $data =array(); $count = $stmt->rowCount();//レコード数取得 //FETCH_ASSOCで配列として書き出して代入 while($row = $stmt->fetch(PDO::FETCH_ASSOC)){ $data[] = $row; } //IDのカラムだけ配列にする $id_Array = array_column($data, 'ID'); //DBとの検索IDを照合する、DBのID以外は全て弾くので空文字やテキストのバリデーションにもなる if(in_array($id,$id_Array,true)){ //DBよりID書き出し $sql_id = "SELECT * FROM menulist WHERE id = :id"; $stmt_id = $dbh->prepare($sql_id); $stmt_id->bindParam( ':id', $id, PDO::PARAM_INT); $stmt_id->execute(); //配列にする if($stmt_id) { $data = $stmt_id->fetch(PDO::FETCH_ASSOC); } $item = $data['item']; $select_cat = $data['category']; $select_mt = $data['material']; $plice = number_format($data['plice']); $change_img = $data['image']; $submit_btn = '<input class="toBtn" type="submit" value="変更">'; } else { $item = ''; $select_cat = ''; $select_mt = ''; $plice = ''; $change_img = ''; $submit_btn = '<p class="errComent">※ID欄が空かDBにないIDです。一覧に戻って選び直してください。</p>'; } DBのID以外は全て弾くので空文字や数字以外のテキストのバリデーションにもなりました。 change.php change_check.php change_check.phpではエラーがあったらDBに入れず、入力し直しへ。 エラーがなっかたら更新して完了を表示させます。 画像の入れ替え操作は苦労しました。 変更がある場合と無い場合を想定してファイルアップの有無を0で判断し、0でなかったら新しい画像をアップし古い画像は削除。 古い画像のままだったら、古い画像を削除して同じものを入れ直します。 なのでID検索時に古い画像のファイル名も取得して運んでいきます。 change_check.php if(isset($_FILES['stockimg'])){ $image = $_FILES['stockimg']; $image['name'] = html_escape($image['name']); //英小文字に変換 $image['name'] = strtolower($image['name']); //ファイル名と拡張子を切り分けて.を除去、ピリオドを重複させない為 $image_parts = pathinfo($image['name']); $extension = 'jpg'; $image_name = $image_parts['filename']; $image_name = str_replace('.','',$image_name); if($image['size'] > 1000000){ $err['imgsize'] = '画像が1MBを超えています'; } elseif(file_exists('./img_up/'.$image_name.'.'.$extension) === TRUE) { //file_exists関数でディレクトリ内を調べて、同じファイル名があった場合はアップさせない $err['imgsize'] = '同名のファイルがあります。違うファイル名にしてください。'; } else { $err['imgsize'] = ''; } } ///////省略///// //エラーが無かったら更新へ if($err['item']== '' && $err['imgsize'] == '' && $err['plice']== ''){ //ファイルアップがあった時の対処、0じゃなっかたらディレクトリにアップ、ここは苦労した if((int)$image_name !== 0){ move_uploaded_file($image['tmp_name'],'./img_up/'.$image_name.'.'.$extension); //画像が変更されたら古い画像はフォルダより削除 unlink('./img_up/'.$old_img); //DBに持っていくファイル名 $image = $image_name.'.'.$extension; //見本表示 $prev_img = '<p class="center">変更画像<br><img class="thumb12" src="'.$img_path.$image_name.'.'.$extension.'" alt=""></p>'; } else { //DBに持っていくファイル名を置かないとDBからファイル名が消えてしまう $image = $old_img; $prev_img = '<p>変更画像無し</p>'; } ファイルアップは前にプチ・クラウドストレージを作ったので、ベースはそこから引用してきました。 ▶︎プチ・クラウドストレージ作ってみた delete.phpもchange_check.phpとほぼ同じ作りです。 管理画面は注文ページより、ほぼ思うように作れました。 期間は2週間+α、かかりました。 作り終えて振り返り 調べていくとDB接続の書き方も人によりマチマチで「この方法知らない…」ということも多々遭遇。 関数、条件の立て方も同様で、長いと頭がクラクラしてきます。 システム構築の実務経験がないので、使う人の行動を想像しながら作りましたが 実務があったら、もっと配慮ができる設計が出来たのかなとも思っています。 以下サンプルコードです より実用的なコードに近づけるようご意見いただけると嬉しいです。 サンプルコード ・index.php(ログイン画面) ・logout.php(ログアウト画面) ・db_join.php(インクルード用、DBの接続や共通で使う関数と配列を格納) ・select_cat.php(インクルード用、formのselectパーツ) ・select_mt.php(インクルード用、formのselectパーツ) 上記5個のファイルは省略します。 control_top.php <?php //ログインしていないとアクセスさせない session_start(); session_regenerate_id(true); if(isset($_SESSION['login']) === false){ header('Location: index.php'); exit(); } try{ include_once(dirname(__FILE__).'/db_join.php'); //DBより一覧表書き出し $sql_list = 'SELECT * FROM menulist order by ID ASC'; $stmt = $dbh->prepare($sql_list); $stmt->execute(); $data =array(); $count = $stmt->rowCount();//レコード数取得 while($row = $stmt->fetch(PDO::FETCH_ASSOC)){ $data[] = $row;//FETCH_ASSOCで配列として書き出して代入 } //DBのカラムに同じ商品名があるかチェック //商品名だけ配列化 $itemArray = array_column($data, 'item'); $item = ''; $category=''; $material = ''; $plice = ''; $err = ['item'=>'','cat'=>'','mat'=>'','plice'=>'','imgsize'=>'']; $dberr = ''; if($_SERVER['REQUEST_METHOD'] === 'POST'){ $item = html_escape($_POST['stock']); $category = html_escape($_POST['category']); $material = html_escape($_POST['material']); $plice = html_escape($_POST['stockplice']); $plice = mb_convert_kana($plice, 'n', 'UTF-8'); $image = $_FILES['stockimg']; $image['name'] = html_escape($image['name']); //英小文字に変換 $image['name'] = strtolower($image['name']); $plice = strtolower($plice); //ファイル名と拡張子を切り分けて.を除去、ピリオドを重複させない為 $image_parts = pathinfo($image['name']); //$extension = $image_parts['extension']; $extension = 'jpg'; $image_name = $image_parts['filename']; $image_name = str_replace('.','',$image_name); //base64でencode追加 $image_name = base64_encode($image_name); //エラー対処 if(!check_word($item,25)){ $err['item'] = '空文字か入力値が超えています'; } elseif (in_array($item,$itemArray,true)) { //DBに同じ名前を入れさせない $err['item']= '同じ名の商品があります'; } else { $err['item']= ''; } if($category === ''){ $err['cat'] = '選択してください'; } else { $err['cat']= ''; } if($material === ''){ $err['mat'] = '選択してください'; } else { $err['mat']= ''; } if(!check_word($plice,8)){ $err['plice'] = '空文字か入力値が超えています'; } else { $err['plice']= ''; } if($image['size'] > 1000000 && $image['size'] == 0){ $err['imgsize'] = '画像が選択されていないかサイズが1MBを超えています'; } elseif(file_exists('./img_up/'.$image_name.'.'.$extension) === TRUE) { //file_exists関数でディレクトリ内を調べて、同じファイル名があった場合はアップさせない $err['imgsize'] = '同名のファイルがあります。違うファイル名にしてください。'; } else { $err['imgsize'] = ''; } //empty($err)ではダメで下の書き方でtrueになった if($err['item']== '' && $err['cat']== '' && $err['mat']== '' && $err['imgsize'] == '' && $err['plice']== ''){ //デコードして画像アップ $image_name = base64_decode($image_name); move_uploaded_file($image['tmp_name'],'./img_up/'.$image_name.'.'.$extension); $dberr = '<img class="thumb" src="'.$img_path.$image_name.'.'.$extension.'" alt="">商品「'.$item.'」は正常にUPされました'; //DBに入力データを入れる $sql = "INSERT INTO menulist(item,image,category,material,plice) VALUE(:item,:image,:category,:material,:plice)"; $stmt_in = $dbh->prepare($sql); $stmt_in->bindValue(':item',$item,PDO::PARAM_STR); //DBには画像ファイル名のみUP $stmt_in->bindValue(':image',$image_name.'.'.$extension,PDO::PARAM_STR); $stmt_in->bindValue(':category',$category,PDO::PARAM_STR); $stmt_in->bindValue(':material',$material,PDO::PARAM_STR); $stmt_in->bindValue(':plice',$plice,PDO::PARAM_INT); $stmt_in->execute(); } //再読み込みを防ぐ為、同ページだけど飛ばす header('Location:/menulist/control/control_top.php'); } } catch (PDOException $e){ print($e->getMessage()); die(); } ?> <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>DBテスト管理画面</title> <link rel="stylesheet" href="/menulist/common/sanitize.css"> <link rel="stylesheet" href="/menulist/common/style.css"> <link rel="stylesheet" href="/menulist/common/control.css"> <!--ファビコン32x32--> <link rel="shortcut icon" href="/menulist/favicon.ico" type="image/vnd.microsoft.icon"> </head> <body> <div id="wrapper"> <header id="header"> <p id="logout"><a href="logout.php">ログアウト</a></p> <h1 class="topTitle">DBテスト管理画面</h1> <p class="center notice1">入力の場合IDの設定は不要、金額は数値のみで<br>ブラウザの再読み込みはしないでください。エラーになります。</p> <p class="center notice2">IDで商品を検索してください</p> </header> <main id="main"> <ul class="formChange"> <li data-id="insart" class="act">入力</li> <li data-id="update">変更</li> <li data-id="deleteForm">削除</li> </ul> <!-- 追加入力 --> <form class="changeBox" id="insart" method="post" action="" enctype="multipart/form-data"> <div class="formLow"> <div class="stockId"> <p>ID</p> <p class="formInput">-</p> </div> <div class="stock"> <label>商品名</label> <input type="text" name="stock"> <p><?php echo $err['item']; ?></p> </div> <div class="stockCategory"> <label>カテゴリー</label> <select name="category"> <option value="" selected="selected">選択する</option> <!-- selectはインクルード --> <?php include_once(dirname(__FILE__).'/select_cat.php'); ?> </select> <p><?php echo $err['cat']; ?></p> </div> <div class="materialForm"> <label>素材</label> <select name="material"> <option value="" selected="selected">選択する</option> <!-- selectはインクルード --> <?php include_once(dirname(__FILE__).'/select_mt.php'); ?> </select> <p><?php echo $err['mat']; ?></p> </div> <div class="stockplice"> <label>金額</label> <input type="text" name="stockplice"> <p><?php echo $err['plice']; ?></p> </div> </div> <!-- //.formLow --> <div class="center"> <label>商品画像:サイズ横640px縦420px</label><br> <input type="file" name="stockimg"> <p><?php echo $err['imgsize']; ?></p> <?php echo $dberr; ?> </div> <input class="toBtn" type="submit" value="追加"> </form> <!-- //#insartBox --> <!-- 変更入力 --> <!-- 検索 --> <div id="update" class="changeBox"> <form method="post" action="/menulist/control/change.php"> <div class="formLow"> <label class="sarchId">ID</label> <input class="inputId" type="text" name="stockid"> </div> <input class="toBtn" type="submit" value="検索"> </form> </div> <!-- //#updateId --> <!-- 削除 --> <div id="deleteForm" class="changeBox"> <form method="post" action="/menulist/control/delete.php"> <div class="formLow"> <label class="sarchId">ID</label> <input class="inputId" type="text" name="stockid"> </div> <input class="toBtn" type="submit" value="検索"> </form> </div> <!-- //#deleteForm --> <!-- メニュー一覧データ --> <div class="stockBox"> <!-- カテゴリー絞り込みタグ --> <ul class="topNav"> <?php for($i = 0; $i < count($cat_list); $i++): ?> <li id="<?php echo 'cat_select_'.$i; ?>"><?php echo $cat_list[$i]; ?></li> <?php endfor; ?> <li id="all">ALL</li> </ul> <p class="totalStock"><span>全登録数</span><?php echo $count; ?>項目</p> <p id="catShow"></p> <table id="stockList"> <tr> <th class="stocklistId">ID</th><th class="stocklist">商品名</th><th class="stockImg">商品画像</th><th class="stocklisutoCat">カテゴリー</th><th class="stocklistMat">素材</th><th class="stocklistPlice">金額</th> </tr> <!-- foreachの外にforで囲み、カテゴリー数をループ --> <!-- foreachの中に$cat_list[$i]を入れるとエラーになる --> <?php for($i = 0; $i < count($cat_list); $i++): ?> <?php $cat_item = $cat_list[$i]; $cat_select = 'cat_select_'.$i; ?> <?php foreach($data as $row): ?> <?php if($row['category'] == $cat_item): ?> <tr class="<?php echo $cat_select; ?>"> <td class="stocklistId"><?php echo $row['ID']; ?></td> <td class="stocklist"><?php echo $row['item']; ?></td> <td class="stockImg"><img src="<?php echo $img_path.$row['image']; ?>" alt=""></td> <td class="stocklisutoCat"><?php echo $row['category']; ?></td> <td class="stocklistMat"><?php echo $row['material']; ?></td> <td class="stocklistPlice"><?php echo number_format($row['plice']); ?>円</td> </tr> <?php endif; ?> <?php endforeach; ?> <?php endfor; ?> </table> </div> <!-- //.stockBox --> </main> <footer id="footer"> <small>DBテストメニューsystem</small> </footer> </div> <!-- //# wrapper--> <script src="/menulist/common/jquery-3.6.0.min.js"></script> <script src="/menulist/common/control.js"></script> </body> </html> change.php <?php //ログインしていないとアクセスさせない session_start(); session_regenerate_id(true); if(isset($_SESSION['login']) === false){ header('Location: index.php'); exit(); } try { include_once(dirname(__FILE__).'/db_join.php'); $id = html_escape($_POST['stockid']); //idを全角もOKにする $id = mb_convert_kana($id, 'n', 'UTF-8'); //DBより一覧表書き出し ID照合用 $sql_list = 'SELECT * FROM menulist'; $stmt = $dbh->prepare($sql_list); $stmt->execute(); $data =array(); $count = $stmt->rowCount();//レコード数取得 //FETCH_ASSOCで配列として書き出して代入 while($row = $stmt->fetch(PDO::FETCH_ASSOC)){ $data[] = $row; } //IDのカラムだけ配列にする $id_Array = array_column($data, 'ID'); //DBとの検索IDを照合する、DBのID以外は全て弾くので空文字やテキストのバリデーションにもなる if(in_array($id,$id_Array)){ //DBよりID書き出し $sql_id = "SELECT * FROM menulist WHERE id = :id"; $stmt_id = $dbh->prepare($sql_id); $stmt_id->bindParam( ':id', $id, PDO::PARAM_INT); $stmt_id->execute(); //配列にする if($stmt_id) { $data = $stmt_id->fetch(PDO::FETCH_ASSOC); } $item = $data['item']; $select_cat = $data['category']; $select_mt = $data['material']; $plice = number_format($data['plice']); $change_img = $data['image']; $submit_btn = '<input class="toBtn" type="submit" value="変更">'; } else { $item = ''; $select_cat = ''; $select_mt = ''; $plice = ''; $change_img = ''; $submit_btn = '<p class="errComent">※ID欄が空かDBにないIDです。一覧に戻って選び直してください。</p>'; } } catch (PDOException $e){ print($e->getMessage()); die(); } ?> <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>DBテスト管理画面・変更</title> <link rel="stylesheet" href="/menulist/common/sanitize.css"> <link rel="stylesheet" href="/menulist/common/style.css"> <link rel="stylesheet" href="/menulist/common/control.css"> <!--ファビコン32x32--> <link rel="shortcut icon" href="/menulist/favicon.ico" type="image/vnd.microsoft.icon"> </head> <body> <div id="wrapper"> <header id="header"> <h1 class="topTitle">DBテスト管理画面・変更</h1> <p class="center notice1">変更箇所のみ書き換えてください</p> </header> <main id="main"> <!-- 変更 --> <form method="post" action="change_check.php" enctype="multipart/form-data"> <div class="formLow"> <div class="stockId"> <p>ID</p> <p class="formInput"><?php echo $id; ?></p> <input type="hidden" name="stockid" value="<?php echo $id; ?>"> </div> <div class="stock"> <label>商品名</label> <input type="text" name="stock" value="<?php echo $item; ?>"> </div> <div class="stockCategory"> <label>カテゴリー</label> <select name="category" value="<?php echo $select_cat; ?>"> <!-- selectはインクルード --> <?php include_once(dirname(__FILE__).'/select_cat.php'); ?> </select> </div> <div class="materialForm"> <label>素材</label> <select name="material" value="<?php echo $select_mt; ?>"> <!-- selectはインクルード --> <?php include_once(dirname(__FILE__).'/select_mt.php'); ?> </select> </div> <div class="stockplice"> <label>金額</label> <input type="text" name="stockplice" value="<?php echo $plice; ?>"> </div> </div> <!-- //.formLow --> <div class="center"> <label>商品画像:サイズ横640px縦420px<br><span class="text12">※変更のある場合のみUP</span></label><br> <input type="file" name="stockimg"> <p class="center"> <img class="thumb12" src="<?php echo $img_path.$change_img; ?>" alt=""> <!-- 削除のある場合に古い画像も持っていく --> <input type="hidden" name="old_img" value="<?php echo $change_img; ?>"> </p> <p class="text12 center mg0">変更前の画像です</p> </div> <?php echo $submit_btn; ?> </form> <div id="toList"><a href="/menulist/control/control_top.php">管理画面トップへ</a></div> </main> <footer id="footer"> <small>DBテストメニューsystem</small> </footer> </div> <!-- //# wrapper--> <script src="/menulist/common/jquery-3.6.0.min.js"></script> <script src="/menulist/common/control.js"></script> </body> </html> change_check.php <?php //ログインしていないとアクセスさせない session_start(); session_regenerate_id(true); if(isset($_SESSION['login']) === false){ header('Location: index.php'); exit(); } try { include_once(dirname(__FILE__).'/db_join.php'); $err = ['item'=>'','plice'=>'','imgsize'=>'']; $id = html_escape($_POST['stockid']); $item = html_escape($_POST['stock']); $category = html_escape($_POST['category']); $material = html_escape($_POST['material']); $plice = html_escape($_POST['stockplice']); $plice = mb_convert_kana($plice, 'n', 'UTF-8'); $old_img = html_escape($_POST['old_img']); if(isset($_FILES['stockimg'])){ $image = $_FILES['stockimg']; $image['name'] = html_escape($image['name']); //英小文字に変換 $image['name'] = strtolower($image['name']); //ファイル名と拡張子を切り分けて.を除去、ピリオドを重複させない為 $image_parts = pathinfo($image['name']); $extension = 'jpg'; $image_name = $image_parts['filename']; $image_name = str_replace('.','',$image_name); if($image['size'] > 1000000){ $err['imgsize'] = '画像が1MBを超えています'; } elseif(file_exists('./img_up/'.$image_name.'.'.$extension) === TRUE) { //file_exists関数でディレクトリ内を調べて、同じファイル名があった場合はアップさせない $err['imgsize'] = '同名のファイルがあります。違うファイル名にしてください。'; } else { $err['imgsize'] = ''; } } //var_dump(is_number($plice)); //エラー対処 if(!check_word($item,25)){ $err['item'] = '空文字か入力値が超えています'; } else { $err['item']= ''; } if(!check_word($plice,8)){ $err['plice'] = '空文字か入力値が超えています'; } elseif(is_numeric($plice) == FALSE) { //金額が数字じゃなかったらNG $err['plice'] = '数字を入力してください'; //金額変更でNGが出ると下記の変数に値が無くなるので対処 $prev_img = ''; } else { $err['plice']= ''; $plice = number_format($plice); } //エラーが無かったら更新へ if($err['item']== '' && $err['imgsize'] == '' && $err['plice']== ''){ //ファイルアップがあった時の対処、0じゃなっかたらディレクトリにアップ、ここは苦労した if((int)$image_name !== 0){ move_uploaded_file($image['tmp_name'],'./img_up/'.$image_name.'.'.$extension); //画像が変更されたら古い画像はフォルダより削除 unlink('./img_up/'.$old_img); //DBに持っていくファイル名 $image = $image_name.'.'.$extension; //見本表示 $prev_img = '<p class="center">変更画像<br><img class="thumb12" src="'.$img_path.$image_name.'.'.$extension.'" alt=""></p>'; } else { //DBに持っていくファイル名を置かないとDBからファイル名が消えてしまう $image = $old_img; $prev_img = '<p>変更画像無し</p>'; } //指定のidのDBを更新 $sql_up = 'UPDATE menulist SET item=:item,image=:image,category=:category,material=:material,plice=:plice WHERE id = :id'; $stmt_up = $dbh->prepare($sql_up); $stmt_up->bindParam( ':id', $id, PDO::PARAM_INT); $stmt_up->bindValue(':item',$item,PDO::PARAM_STR); $stmt_up->bindValue(':image',$image,PDO::PARAM_STR); $stmt_up->bindValue(':category',$category,PDO::PARAM_STR); $stmt_up->bindValue(':material',$material,PDO::PARAM_STR); $stmt_up->bindValue(':plice',$plice,PDO::PARAM_INT); $stmt_up->execute(); $notice = '以下の内容で更新しました'; $back_btn = '<div id="toList"><a href="/menulist/control/control_top.php">管理画面トップへ</a></div>'; } else { $notice = 'エラーがあります。戻って修正してください。'; //お手軽にhistory.back使いましたが「フォーム再送信の確認」が出る可能性有り //かといってa hrefだと入力値が消える。手間だけどもう一つ入力値を持っていった再入力用ページを作る? $back_btn = '<div id="toList"><a onclick="history.back()">修正する</a></div>'; } } catch (PDOException $e){ print($e->getMessage()); die(); } ?> <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>DBテスト管理画面・変更確認</title> <link rel="stylesheet" href="/menulist/common/sanitize.css"> <link rel="stylesheet" href="/menulist/common/style.css"> <link rel="stylesheet" href="/menulist/common/control.css"> <!--ファビコン32x32--> <link rel="shortcut icon" href="/menulist/favicon.ico" type="image/vnd.microsoft.icon"> </head> <body> <div id="wrapper"> <header id="header"> <h1 class="topTitle">DBテスト管理画面・変更確認</h1> <p class="center notice1"><?php echo $notice; ?></p> </header> <main id="main"> <div class="formLow"> <div class="stockId"> <p>ID</p> <p class="formInput"><?php echo $id; ?></p> </div> <div class="stock"> <label>商品名</label> <p class="formInput"><?php echo $item; ?></p> <p><?php echo $err['item']; ?></p> </div> <div class="stockCategory"> <label>カテゴリー</label> <p class="formInput"><?php echo $category; ?></p> </div> <div class="materialForm"> <label>素材</label> <p class="formInput"><?php echo $material; ?></p> </div> <div class="stockplice"> <label>金額</label> <p class="formInput"><?php echo $plice; ?></p> <p><?php echo $err['plice']; ?></p> </div> </div> <!-- //.formLow --> <div class="center"> <?php echo $prev_img; ?> <p class="center"><?php echo $err['imgsize']; ?></p> </div> <?php echo $back_btn; ?> </main> </div> <!-- //#wrapper --> <footer id="footer"> <small>DBテストメニューsystem</small> </footer> </div> <!-- //# wrapper--> <script src="/menulist/common/jquery-3.6.0.min.js"></script> <script src="/menulist/common/control.js"></script> </body> </html> delete.php <?php //ログインしていないとアクセスさせない session_start(); session_regenerate_id(true); if(isset($_SESSION['login']) === false){ header('Location: index.php'); exit(); } try { include_once(dirname(__FILE__).'/db_join.php'); $id = html_escape($_POST['stockid']); $id = mb_convert_kana($id, 'n', 'UTF-8'); //DBより一覧表書き出し ID照合用 $sql_list = 'SELECT * FROM menulist'; $stmt = $dbh->prepare($sql_list); $stmt->execute(); $data =array(); $count = $stmt->rowCount();//レコード数取得 while($row = $stmt->fetch(PDO::FETCH_ASSOC)){ $data[] = $row;//FETCH_ASSOCで配列として書き出して代入 } //IDのカラムだけ配列にする $id_Array = array_column($data, 'ID'); //DBとの検索IDを照合する if(in_array($id,$id_Array)){ $sql_id = "SELECT * FROM menulist WHERE id = :id"; $stmt_id = $dbh->prepare($sql_id); $stmt_id->bindParam(':id', $id, PDO::PARAM_INT); $stmt_id->execute(); //配列にする if($stmt_id) { $data = $stmt_id->fetch(PDO::FETCH_ASSOC); } $item = $data['item']; $plice = number_format($data['plice']); $select_cat = $data['category']; $select_mt = $data['material']; $change_img = $data['image']; $submit_btn = '<input class="toBtn" type="submit" value="削除">'; } else { $item = ''; $select_cat = ''; $select_mt = ''; $plice = ''; $change_img = ''; $submit_btn = '<p class="errComent">※DBにないIDです。一覧に戻って選び直してください。</p>'; } if($_SERVER['REQUEST_METHOD'] === 'POST'){ //isset置かないとNoticeが出る if(isset($_POST['deleteid'])){ $id = html_escape($_POST['deleteid']); $image = html_escape($_POST['deleteimg']); //DBより削除 $sql_delete = "DELETE FROM menulist WHERE id = :id"; $stmt_delete = $dbh->prepare($sql_delete); $stmt_delete->bindParam( ':id', $id, PDO::PARAM_INT); $stmt_delete->execute(); //画像フォルダからも削除 unlink('./img_up/'.$image); //実行されたらトップに飛ばす header('Location:/menulist/control/control_top.php'); } } } catch (PDOException $e){ print($e->getMessage()); die(); } ?> <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>DBテスト管理画面・変更</title> <link rel="stylesheet" href="/menulist/common/sanitize.css"> <link rel="stylesheet" href="/menulist/common/style.css"> <link rel="stylesheet" href="/menulist/common/control.css"> <!--ファビコン32x32--> <link rel="shortcut icon" href="/menulist/favicon.ico" type="image/vnd.microsoft.icon"> </head> <body> <div id="wrapper"> <header id="header"> <h1 class="topTitle">DBテスト管理画面・削除</h1> <p class="center notice1">以下のメニューを削除します</p> </header> <main id="main"> <!-- 変更 --> <form method="post" action=""> <div class="stockBox"> <table> <tr> <th>ID</th><th>商品名</th><th>商品画像</th><th>カテゴリー</th><th>素材</th><th>金額</th> </tr> <tr> <td class="stocklistId"><?php echo $id; ?><input type="hidden" name="deleteid" value="<?php echo $id; ?>"></td> <td class="stocklist"><?php echo $item; ?></td> <td class="stockImg"><img src="<?php echo $img_path.$change_img; ?>" alt=""><input type="hidden" name="deleteimg" value="<?php echo $change_img; ?>"></td> <td class="stocklisutoCat"><?php echo $select_cat; ?></td> <td class="stocklistMat"><?php echo $select_mt; ?></td> <td class="stocklistPlice"><?php echo $plice ; ?>円</td> </tr> </table> </div> <?php echo $submit_btn; ?> </form> <div id="toList"><a href="/menulist/control/control_top.php">管理画面トップへ</a></div> </main> <footer id="footer"> <small>DBテストメニューsystem</small> </footer> </div> <!-- //# wrapper--> <script src="/menulist/common/jquery-3.6.0.min.js"></script> <script src="/menulist/common/control.js"></script> </body> </html>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Railsで日時を取得したいのに、2000年1月1日ばかり保存されてしまう件について

はじめに 現在、Ruby on Railsにて睡眠を記録するアプリを作成しています。form_withを使用して日時を記録する際に、登録したデータの日付が2000年1月1日としか保存されませんでした。非常に単純な原因でしたので、備忘録として投稿します。 開発環境 DBにはMySQLを使用しています。 Mac OS Big Sur 11.6 VSCode Ruby 3.0.2p107 Rails 6.1.4.1 MySQL 8.0.27 for macos11.6 on arm64 (Homebrew) 原因 migrationファイルでDateTime型とすべきところをTime型にしていた。 MySQLについて調べるべきなのに、Rubyのことを調べていた。 背後要因 日付だけでなく時刻も扱える Date のサブクラスです。 DateTime は deprecated とされているため、 Timeを使うことを推奨します。 Ruby 3.0.0 リファレンスマニュアル class DateTime(要約) RubyのTime型について調べたときに、リファレンスを見て「なるほど、Ruby3.0以降はDateTimeは非推奨なのか。ならばmigrationを作るときもTime型を使えばいいのか!」と安易な考えに至ってしまいました。 やりたかったこと やりたかったことは、「フォームを利用して、就寝した時間と起床した時間をDBに保存できるかを確かめるための試験的な機能を作る」ことでした。 登録画面で就寝および起床時間を選択 登録ボタンを押したらデータをDBに保存 正常に保存できたら睡眠データの一覧ページにリダイレクト このような機能を目指して作成していました。 migration すでに生成してあるUserモデルと関連を持たせています。UserとSleepLogは、1対多の関係になります。 ***_create_sleep_logs.rb class CreateSleepLogs < ActiveRecord::Migration[6.1] def change create_table :sleep_logs do |t| t.references :user, foreign_key: true t.time :sleep_at t.time :wake_at t.timestamps end add_index :sleep_logs, %i[user_id created_at] end end なお、ここで問題の原因となったのがsleep_atカラムとwake_atカラムです。本来はここでdatetime型とすべきところを、time型としてしまいました。 routes ルーティングはresourcesを利用してindex以外のアクションを生成しています。 routes.rb resources :sleep_logs, except: :index これでsleep_logs_pathにPOSTすればsleep_logs_controllerのcreateアクションが利用できるようになりました。 $ rails routes | grep sleep_logs sleep_logs POST /sleep_logs(.:format) sleep_logs#create controller データを正常に保存できれば、ユーザーの個別ページで睡眠データ一覧を表示するようにしています。 sleep_logs_controller.rb class SleepLogsController < ApplicationController def new end def create sleep_log = current_user.sleep_logs.build(sleep_log_params) if sleep_log.save flash.now[:success] = "記録が保存されました" redirect_to @current_user else flash[:invalid] = "エラーが発生しました" render 'new' end end (省略) private def sleep_log_params params.require(:sleep_log).permit(:sleep_at, :wake_at) end end view 登録用フォームのviewです。form_withのtime_selelctを利用して、時間を選択できるようにしています。とりあえず試験的なデータを保存できればいいのでdefaultオプションを設定しています。 new.html.erb <%= form_with(url: sleep_logs_path, scope: :sleep_log, local: true) do |f| %> <%= f.label :sleep_at, "就寝時間" %> <%= f.time_select :sleep_at, default: Time.zone.now - 8.hours %> <%= f.label :wake_at, "起床時間" %> <%= f.time_select :wake_at, default: Time.zone.now %> <%= f.submit "記録する" %> <% end %> 特にcssなどは当てていないため、chrome上ではこのように表示されます。 睡眠データ一覧ページのviewについては省略させていただきます。 実際に登録してみる とりあえず準備が整ったので、睡眠データを保存してみます。 登録前 データが1件もないため、メッセージが表示されています。 登録後 登録したデータ自体のid、関連付けしてあるuserのuser_id、日付、就寝時間、起床時間の順に表示されています。 投稿内容を編集している現在(2021/12/03)の日付を表示したいのですが、01/02となってしまっています。 どんな日付が入っているのか? rails consoleを利用して、睡眠データの中身を確認してみます。sleep_atとwake_atは2000年の1月、timestamp(created_atとupdated_at)は正しく時間が表示されています。 [#<SleepLog:0x000000012ef4dc90 id: 1, user_id: 1, sleep_at: Sun, 02 Jan 2000 07:50:00.000000000 JST +09:00, wake_at: Sat, 01 Jan 2000 15:50:00.000000000 JST +09:00, created_at: Fri, 03 Dec 2021 15:50:41.277532000 JST +09:00, updated_at: Fri, 03 Dec 2021 15:50:41.277532000 JST +09:00>] paramsの内容を調べてみる 原因を探るため、まずは「paramsに正しいデータが保存されているか」を調べてみることにしました。controllerにraiseを追加して、わざと例外を発生させてみます。 sleep_logs_controller.rb def create sleep_log = current_user.sleep_logs.build(sleep_log_params) raise if sleep_log.save flash.now[:success] = "記録が保存されました" redirect_to @current_user else flash[:invalid] = "エラーが発生しました" render 'new' end end paramsの中身は...? このようになっていました。どうやらparams[:sleep_log]内には、sleep_atとwake_atそれぞれの年、月、日、時、分が格納されているようです。年には2021、月には12, 日には3が入っています。paramsには問題なさそうです。 問題の解決 いろいろと検索してみた結果、「DBの型がおかしいのかもしれない」と勘付きました。RubyのTime型では日付も扱うことができますが、MySQLのTime型では時間を取り扱うだけで日付は保存できないようですね… migrationの修正 sleep_atをwake_atをdatetime型に変換し、rails db:migrate:resetを実行します。 ***_create_sleep_logs.rb t.datetime :sleep_at t.datetime :wake_at 再度、登録してみる フォームを利用して再度データを登録してみます。 DateTime型に変更した結果、日付も正しく保存されるようになりました!! 最後に 問題が発生したときは、何が原因かを探る前に「何を取り扱っているのか」を考えるべきだと感じました。今回で言えば、MySQLのTime型とDateTime型の違いについて調べればすぐに判明したのに、Rubyのことばかり調べて数時間を無駄にしてしまったからです。 これからも何度も壁にぶつかると思いますが、がんばって乗り越えて行こうと思います。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

systemd環境でMySQL8のrootパスワードを初期化する方法

Amazon Linux2で、MySQL8を運用していて軽くハマったので備忘録 rootのパスワードが分からなくなってリセットしようとググると、ほとんどの記事が「mysqld_safeで起動しなおして云々」というモノなのだが、systemdを採用しているディストリビューションだと「mysqld_safe」がインストールされないらしい。 そこで、もうちょっと調べて見つけた解決方法。 [root@hostname ~]# systemctl set-environment MYSQLD_OPTS="--skip-grant-tables" [root@hostname ~]# systemctl restart mysqld [root@hostname ~]# mysql -u root これでrootでログイン出来るので、rootのパスワードを削除します。 mysql> use mysql Reading table information for completion of table and column names You can turn off this feature to get a quicker startup with -A Database changed mysql> UPDATE user SET authentication_string=null WHERE User='root'; Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0 mysql> 次に、パスワードの再設定を行いますが、先程の「--skip-grant-tables」が設定されたままだと、パスワードの再設定が許可されていません。 なので、環境変数を削除した後にmysqldを再起動します。 mysql> exit Bye [root@hostname ~]# systemctl unset-environment MYSQLD_OPTS [root@hostname ~]# systemctl restart mysqld [root@hostname ~]# 再起動完了後に、再びmysqldに接続しパスワードを再設定します。 [root@hostname ~]# mysql -u root mysql> ALTER USER 'root'@'localhost' identified BY '設定するパスワード'; Query OK, 0 rows affected (0.00 sec) mysql> パスワード変更コマンドを実行した時に、 Your password does not satisfy the current policy requirements とエラーが出た場合は、パスワードポリシーが厳しいので怒られています。 おそらく「最短8文字、ローマ字小文字、ローマ字大文字、数字、記号、すべて混在」というポリシーになっているかと思います(頑張ってください)。 これで、systemd環境のMySQL8でルートのパスワード変更が出来ると思います。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【ruby on rails】 gem impressionistで追加したPV数カウント機能が動作しない場合がある

0.概要 文章を投稿できるアプリをruby on rails で作り 投稿の詳細画面でpv数をカウントする機能を導入済み しかし、一部の詳細画面ではPV数が追加されない 1. 前提(カウント機能追加までに実施したこと) Gemfileに以下を導入しbundle install gem 'impressionist', '~>1.6.1' さらにターミナルで以下実施 rails g impressionist 以下が作成される db/migrate/〇〇〇〇〇〇〇〇〇〇〇〇〇_create_impressions_table.rb class CreateImpressionsTable < ActiveRecord::Migration[5.2] def self.up create_table :impressions, :force => true do |t| t.string :impressionable_type t.integer :impressionable_id t.integer :user_id t.string :controller_name t.string :action_name t.string :view_name t.string :request_hash t.string :ip_address t.string :session_hash t.text :message t.text :referrer t.text :params t.timestamps end add_index :impressions, [:impressionable_type, :message, :impressionable_id], :name => "impressionable_type_message_index", :unique => false, :length => {:message => 255 } add_index :impressions, [:impressionable_type, :impressionable_id, :request_hash], :name => "poly_request_index", :unique => false add_index :impressions, [:impressionable_type, :impressionable_id, :ip_address], :name => "poly_ip_index", :unique => false add_index :impressions, [:impressionable_type, :impressionable_id, :session_hash], :name => "poly_session_index", :unique => false add_index :impressions, [:controller_name,:action_name,:request_hash], :name => "controlleraction_request_index", :unique => false add_index :impressions, [:controller_name,:action_name,:ip_address], :name => "controlleraction_ip_index", :unique => false add_index :impressions, [:controller_name,:action_name,:session_hash], :name => "controlleraction_session_index", :unique => false add_index :impressions, [:impressionable_type, :impressionable_id, :params], :name => "poly_params_request_index", :unique => false, :length => {:params => 255 } add_index :impressions, :user_id end def self.down drop_table :impressions end end さらにカウントさせる投稿(posts)テーブルに「impressions_count」カラムを追加 db/migrate/〇〇〇〇〇〇〇〇〇〇〇〇〇_add_impressions_count_to_posts.rb class AddImpressionsCountToPosts < ActiveRecord::Migration[5.2] def change add_column :posts, :impressions_count, :integer, default: 0 end end 上記で rails db:migrate さらにカウント対象のpostsモデル、コントローラーに記述を追加する app/models/post.rb class Post < ApplicationRecord is_impressionable counter_cache: true end app/controllers/posts_controller.rb class PostsController < ApplicationController def index # 一覧画面 @posts = Post.all end def show # 詳細画面 @post = Post.find(params[:id]) impressionist(@post, nil, unique: [:session_hash.to_s]) end .... end show画面閲覧時にカウントさせる to_sメソッド記載はSessionIdエラー防止のため 具体的なPV数は以下のようなindex画面で質問の概要と並べる形で表示させる app/views/posts/index.html.erb <% @posts.each do |post| %> (~質問一覧~省略~) <%= post.impressions_count %> (~~省略~) <% end %> 2.PV更新有無 show画面確認→index画面へ戻るとPV数が1追加されてるが 一部更新されないものも存在 以下はpostのshow.html閲覧時にPV数が通常更新されるデータ(投稿id(post.id)が28のもの)を 使用データベース(自分の場合mysql)上で確認した結果である ※「rails g impressionist」でテンプレテーブルを作ったため  使わないカラムが多いが、impressionable_idがpostテーブルのid(今回の場合28)を表す MySQL [アプリ名]> select * from impressions where impressionable_id=28; +-----+---------------------+-------------------+---------+-----------------+-------------+-----------+------------------------------------------------------------------+----------------+----------------------------------+---------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+--------+---------------------+---------------------+ | id | impressionable_type | impressionable_id | user_id | controller_name | action_name | view_name | request_hash | ip_address | session_hash | message | referrer | params | created_at | updated_at | +-----+---------------------+-------------------+---------+-----------------+-------------+-----------+------------------------------------------------------------------+----------------+----------------------------------+---------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+--------+---------------------+---------------------+ | 312 | Post | 28 | NULL | posts | show | NULL | 9238b0b3c610130cbc0b3334e16a4c1eb838330b596d2c6bbed5714cfd52b6a6 | 116.83.115.249 | 248e8c24dc2cdeacfa9832404436eb26 | NULL | https://warerano3594.com/public/posts?utf8=%E2%9C%93&range=PV%E6%95%B0%E3%81%AE%E5%A4%9A%E3%81%84%E9%A0%86%E3%81%AB&commit=%E5%9B%9E%E7%AD%94%E4%B8%A6%E3%81%B9%E6%9B%BF%E3%81%88 | NULL | 2021-12-02 06:30:57 | 2021-12-02 06:30:57 | | 352 | Post | 28 | NULL | posts | show | NULL | b37e953bd673cbbac98bf507882f4637eebd007f42890d41dfdb474ae2921cda | 116.83.115.249 | 54b8e2634cec17f36079eeca7a0a8bd0 | NULL | https://warerano3594.com/public/posts?utf8=%E2%9C%93&range=%E6%8A%95%E7%A8%BFNo%E3%81%8C%E5%8F%A4%E3%81%84%E9%A0%86%E3%81%AB&commit=%E5%9B%9E%E7%AD%94%E4%B8%A6%E3%81%B9%E6%9B%BF%E3%81%88 | NULL | 2021-12-02 07:26:06 | 2021-12-02 07:26:06 | | 449 | Post | 28 | NULL | posts | show | NULL | c3204d34ca373f27dcc7940014c3a8cbcac6ae01317edc14ca2853cc59a89f3a | 116.83.115.249 | 9c84102ac332dd37558a1e68f930055d | NULL | https://warerano3594.com/public/posts?utf8=%E2%9C%93&range=PV%E6%95%B0%E3%81%AE%E5%B0%91%E3%81%AA%E3%81%84%E9%A0%86%E3%81%AB&commit=%E5%9B%9E%E7%AD%94%E4%B8%A6%E3%81%B9%E6%9B%BF%E3%81%88 | NULL | 2021-12-02 08:47:53 | 2021-12-02 08:47:53 | 更新されない投稿内容画面(post/投稿id)の特徴を確認したところ、最新更新日時のレコードでpv数カウントに使用する「session_hash」カラムがnullとなっているものだと分かった MySQL [アプリ名]> select * from impressions where session_hash is null; +-----+---------------------+-------------------+---------+-----------------+-------------+-----------+------------------------------------------------------------------+---------------+--------------+---------+----------+--------+---------------------+---------------------+ | id | impressionable_type | impressionable_id | user_id | controller_name | action_name | view_name | request_hash | ip_address | session_hash | message | referrer | params | created_at | updated_at | +-----+---------------------+-------------------+---------+-----------------+-------------+-----------+------------------------------------------------------------------+---------------+--------------+---------+----------+--------+---------------------+---------------------+ | 478 | Post | 67 | NULL | posts | show | NULL | dfac460ae80d0693781edfe0de46c576ae612735c52907b3de2a85abf890407c | 34.219.180.21 | NULL | NULL | NULL | NULL | 2021-12-02 09:28:20 | 2021-12-02 09:28:20 | | 479 | Post | 69 | NULL | posts | show | NULL | 0f009e99f0ac6885adb6dadd69ef5487cc4c79facfce65e7a5be872ffd240903 | 54.187.149.38 | NULL | NULL | NULL | NULL | 2021-12-02 09:29:04 | 2021-12-02 09:29:04 | | 480 | Post | 74 | NULL | posts | show | NULL | cd99ffbbf1e06c9426dd24c1f14ca775cb400b67fd3ebb6f094370ec1abb4e03 | 34.220.223.62 | NULL | NULL | NULL | NULL | 2021-12-02 09:30:02 | 2021-12-02 09:30:02 | | 484 | Post | 68 | NULL | posts | show | NULL | 582e752304d947291ab514ddbc2bb602b585b7b89d741706c3cd5f67b1fdd573 | 52.26.112.150 | NULL | NULL | NULL | NULL | 2021-12-02 09:32:55 | 2021-12-02 09:32:55 | 3.対処 12/7現在session_hashが何故nullとなってしまうのか根本原因は未だわかっていない (ツイッターから該当画面へのリンクを辿ることで発生することは確認)ため 対処療法となるが以下のように「session_hash」カラムがnullとなっているデータを削除 delete from impressions where session_hash is null; 上記実施後に確認したところ、閲覧数カウントが止まっていたものが、再度増えていくようになった。 4.対処法2 (12/7追加) コントローラファイルから 「unique: [:session_hash.to_s]」を消す方法でもPV数はカウントされるようになる。 作りが単純になるため ぺージをリロードすると何回でも数えられてしまう状態とはなるが PV数がカウントされない事象は解決される app/controllers/posts_controller.rb class PostsController < ApplicationController def index # 一覧画面 @posts = Post.all end def show # 詳細画面 @post = Post.find(params[:id]) impressionist(@post, nil) # ←unique: [:session_hash.to_s] 削除   end .... end
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

研修で色んなことやった

初めに 内定先研修?(バイト)で内定先で夏休みから少しづつ働いてきたのでどんなことやったのーだとかこれ大変だったなぁとかを話す会 目次 C#以外の言語を触った感想 今は何してんの? 大変だった話、苦労したこと ゲーム会社で少し働いた感想 C#以外の言語を触った感想 とりあえずやった言語が PHP.MySQL.Jsva.Python.Linux この5言語を研修で触りました。 PHPで困ったこと変数名に $をつけないといけないところ Javaで困ったこと何も定義されてないときはnullではなくundefinedだというところ MySQL.Linuxはほとんど学校で習った基礎的な内容 Pythonは簡単に書きすぎて他の言語書くときに困った(セミコロンつけないとか、ifとかforに{}いらないので付け忘れたりとか) 今は何してんの? 細かくは言えないですが、負荷テストの処理書いてたりしてます LocustっていうPythonでコードを書いてそのコードに〇〇人って人数を流したらどれくらい負荷がかかっているか見れるやつ LaravelはPHPのワークフレーム、一番人気らしい 左がLocust 右がLaravel 大変だった話、苦労したこと 大変だったのがわからないことがわからないです。 具体的に言うとUnityだと〇〇っていう処理作る時にある程度知識があるからわからなくても簡単に調べれるけれど 全く知識のない言語で作り方がわからない場合どうやって作ればいいかわからない。どうやって質問すればいいかわからないというなにがわかっていて何がわからないのかがわからない。ワカランワカラン(´;ω;`)ってなってました このワカランワカラン状態で質問をしても何が聞きたいのかわからないという相手もワカランワカランという最悪の状況が生まれます。 その時にマネージャーに教えてもらったのがエンジニアとして痛感した「良い質問」と「悪い質問」という記事 https://hara-chan.com/recommend/se-good-bad-question この記事を見てからは抽象的な質問をすることはなくなりました。万歳(/・ω・)/ ゲーム会社で働いた感想 ゲーム会社は圧倒的自由 作業中音楽聞いててもいいし、お菓子食べてもいいし、飲み物飲んでもいいし。。。 あとチャットがすごいラフというかギャグ挟んだりしながら会話してくたりいい意味で話しやすい環境を作ってくれている わからない所をチャットに書いたらすぐに返信くれるし、チャットじゃ理解できなかったら口頭で教えてくれるし 一番いいのが飲食店のバイトと違って体が疲れない!8時間働いても時間があっという間 CODEの23卒の人へ入社待ってます(#^^#)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

MySQLのEXPLAIN ANALYZEの読み方を勉強したよ

ミライトデザインアドベントカレンダー3日目です。 ミライトデザイン Advent Calendar 2021のカレンダー | Advent Calendar 2021 - Qiita 昨日は@tkek321さんのKeycloakでOIDC認可コードフローをやってみる記事でした。 OIDCを実際に動かしながら勉強できるので、凄く頭に入ってきやすい良記事でした! 本日は、MySQLに関して最近自分が勉強したことを記事にさせていただきます。 概要 MySQL 8.0.18からEXPLAIN ANALYZEが導入されました。 PostgreSQLでは昔からある機能ですが、MySQLに導入されたのはつい最近のことです。 この記事ではEXPLAIN ANALYZEの読み方について、自分が学んだ内容をご紹介できればと思います。 EXPLAIN ANALYZEってそもそも何? その前にEXPLAINって何? EXPLAIN ANALYZEを説明する前に、まずはEXPLAINとは何か軽く説明します。 EXPLAINは、MySQLがクエリをどのように実行するかについての情報を取得するものです。 この情報はよく実行計画と呼ばれます。 使い方は簡単で、実行計画を知りたいクエリの前にEXPLAINを付けて実行するだけです。 例えば、あるSELECT文の実行計画が知りたければ、下記の様にSQLを実行します。 EXPLAIN SELECT * FROM users WHERE user_no='U-00000005'; +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+ | 1 | SIMPLE | users | NULL | const | user_no | user_no | 42 | const | 1 | 100.00 | NULL | +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+ 実行計画を見ることで、 検索が遅くなりそうなクエリになっていないか どのテーブルに対する検索が遅くなっていそうか インデックスが適切に使用されているか などを調べることができます。 EXPLAINについての読み方については、本記事では詳しく触れません。 興味がある方は下記の記事で非常に詳しく解説されています。 自分もこちらの記事で大変勉強させていただいたので、是非一読をおすすめします。 EXPLAIN ANALYZEはEXPLAINと何が違うの? EXPLAINはクエリを実行する前の計画を取得しています。 それに対してEXPLAIN ANALYZEは実際にクエリを実行して各ステップでの実行時間を測定してくれます。 そのため、クエリのどのステップに時間がかかっているかをより正確に掴むことができます。 また、実行計画と実行結果が大きく異なっているかを確認し、テーブルの統計情報がずれていないかを判断する材料にも使えます。 環境 MySQL: 8.0.26 今回は下記の2テーブルを使用して検証しています。 SHOW CREATE TABLE users\G *************************** 1. row *************************** Table: users Create Table: CREATE TABLE `users` ( `id` int NOT NULL AUTO_INCREMENT, `user_no` varchar(10) COLLATE utf8mb4_bin NOT NULL, `name` varchar(20) COLLATE utf8mb4_bin DEFAULT NULL, `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `user_no` (`user_no`) ) ENGINE=InnoDB AUTO_INCREMENT=1048561 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin SHOW CREATE TABLE scores\G *************************** 1. row *************************** Table: scores Create Table: CREATE TABLE `scores` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, `score` int NOT NULL, `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=2031586 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin 検証用のデータとして、usersテーブルには100万件、scoresテーブルには200万件のレコードをINSERTしています。 Lv1. 単一テーブルに対するSELECT(全件取得) まずは検索条件などを指定せず、最もシンプルなSELECT文に対してEXPLAIN ANALYZEを実行してみましょう。 EXPLAIN ANALYZE SELECT * FROM users; +---------------------------------------------------------------------------------------------------------+ | EXPLAIN | +---------------------------------------------------------------------------------------------------------+ | -> Table scan on users (cost=101290.81 rows=995849) (actual time=0.046..775.839 rows=1000000 loops=1) | +---------------------------------------------------------------------------------------------------------+ 結果が取得できたので、これをいくつかのパーツに分解して見ていきましょう。 Table scan on users この部分はusersテーブルをテーブルスキャンで検索したことを表しています。 テーブルスキャンとは、インデックスなどを使用せずにテーブル内の全てのレコードを検索したという意味になります。 非常に遅い検索方法なので、クエリチューニングの必要性が出た場合は改善する有力候補になります。 (cost=101290.81 rows=995849) この部分は推定実行コスト1と戻される行の推定数を表しています。 ここはクエリ実行前の推定値なので、EXPLAINで取得できるものと同じ値になります。 (actual time=0.046..775.839 rows=1000000 loops=1) actual timeは最初の行・全ての行を取得するのに実際にかかった時間(ミリ秒)を表しています。ここで特に重要なのは全ての行を取得するのにかかった時間の方で、実際のコストとみなすことができます。 rowsは実際にクエリで取得した行数となります。この場合は100万件のレコードが取得されたことになります(そのまんま)。 loopsはこのステップのループ数を表しています。この例ではloops=1なのでイメージしづらいかもしれませんので、後ほど説明します。 Lv.2 複数のテーブルをJOINしてSELECT 続いて2つのテーブルをJOINした例を見てみましょう。 usersテーブルとscoresテーブルをJOINしてみます。 EXPLAIN ANALYZE SELECT * FROM users u JOIN scores s ON u.id = s.user_id WHERE u.id < 100; +----------------------------------------------------------------------------------------------------------------------------------------+ | EXPLAIN | +----------------------------------------------------------------------------------------------------------------------------------------+ | -> Nested loop inner join (cost=2399258.64 rows=1994337) (actual time=9.622..598.250 rows=198 loops=1) | | -> Filter: (s.user_id < 100) (cost=205487.94 rows=1994337) (actual time=9.598..597.860 rows=198 loops=1) | | -> Table scan on s (cost=205487.94 rows=1994337) (actual time=9.595..530.336 rows=2000000 loops=1) | | -> Single-row index lookup on u using PRIMARY (id=s.user_id) (cost=1.00 rows=1) (actual time=0.002..0.002 rows=1 loops=198) | +----------------------------------------------------------------------------------------------------------------------------------------+ Oh、、、頭痛くなってきた 大分複雑になりましたが、1つずつ見ていけば理解できるはずです。 まず、先程と異なり結果が4行になっています。 さらによく見ると単に4行並んでいるだけでなく、ネストしたツリー構造になっています。 これはMySQL 8.0.16から追加された、EXPLAIN FORMAT=TREEという表示形式です。 このツリー構造の読み方についてはMySQLの公式ドキュメントでは見つけられませんでした(ご存じの方、誰か教えて下さいm(_ _)m)が、基本的にはOracleやPostgreSQLの実行計画と同じ読み方ができると思います。 基本的にはツリー構造を見て、次のルール2で読んでいきます。 ネストが深い方→浅い方の順に読む 同じ階層にあるものは上から読む まずツリー構造を整理すると下記のような形式になっています。 A ├─ B │ └ C └─ D この場合はC→B→D→Aの順で実行されています。 つまり、先程の結果は下記のような操作が行われていることを示しています。 Table scan on s ...で、scoresテーブルをテーブルスキャンしている Filter: (s.user_id < 100) ...で、1の結果をscores.user_id < 100の条件で絞り込みしている Single-row index lookup on u using PRIMARY ...で、usersテーブルを1行ずつ主キー検索している ここでloops=198となっているので、このステップが198回実行されたことを示しています。ここから、2で取得した行(rows=198)に対して1件ずつ検索が行われたことがわかります。 Nested loop inner join ...で、2, 3の結果をNested Loopして結合している 何となく雰囲気を掴んでいただけたでしょうか。 こちとら、何となくの雰囲気でしか理解しとらんぞい EXPLAIN ANALYZEの結果を見てクエリを改善しよう ここで、クエリの実行速度を改善するためにEXPLAIN ANALYZEの結果を再度見てみましょう。 EXPLAIN ANALYZE SELECT * FROM users u JOIN scores s ON u.id = s.user_id WHERE u.id < 100; +----------------------------------------------------------------------------------------------------------------------------------------+ | EXPLAIN | +----------------------------------------------------------------------------------------------------------------------------------------+ | -> A. Nested loop inner join (cost=2399258.64 rows=1994337) (actual time=9.622..598.250 rows=198 loops=1) | | -> B. Filter: (s.user_id < 100) (cost=205487.94 rows=1994337) (actual time=9.598..597.860 rows=198 loops=1) | | -> C. Table scan on s (cost=205487.94 rows=1994337) (actual time=9.595..530.336 rows=2000000 loops=1) | | -> D. Single-row index lookup on u using PRIMARY (id=s.user_id) (cost=1.00 rows=1) (actual time=0.002..0.002 rows=1 loops=198) | +----------------------------------------------------------------------------------------------------------------------------------------+ ※便宜上、各ステップにA, B, C, Dとラベルを付けました。 更に、実行順はC→B→D→Aだったことも思い出しておきましょう。 さて、まずactual timeを見てみると、A, B, Cが500ミリ秒を超えており、Cの実行時点で既に時間がかかっていることがわかると思います。 また、CにTable scanの文言があり、取得した行(rows)が200万件であることからも、scoresテーブルを全件検索しているのが問題だと予想できます。 、、、犯人はCさん、アンタだよ!! ここで、scores.user_idに外部キー制約をかけていなかったことに気づいたので、追加してみましょう。 ALTER TABLE scores ADD FOREIGN KEY (user_id) REFERENCES users(id); Query OK, 2000000 rows affected (31.23 sec) Records: 2000000 Duplicates: 0 Warnings: 0 SHOW CREATE TABLE scores\G *************************** 1. row *************************** Table: scores Create Table: CREATE TABLE `scores` ( `id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, `game_id` int NOT NULL, `score` int NOT NULL, `created_at` datetime DEFAULT NULL, `updated_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `user_id` (`user_id`), CONSTRAINT `scores_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=2031586 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin 再度EXPLAIN ANALYZEを実行してみましょう。 EXPLAIN ANALYZE SELECT * FROM users u JOIN scores s ON u.id = s.user_id WHERE u.id < 100; +-----------------------------------------------------------------------------------------------------------------------+ | EXPLAIN | +-----------------------------------------------------------------------------------------------------------------------+ | -> Nested loop inner join (cost=103.23 rows=99) (actual time=0.107..6.201 rows=198 loops=1) | | -> Filter: (u.id < 100) (cost=20.94 rows=99) (actual time=0.032..0.273 rows=99 loops=1) | | -> Index range scan on u using PRIMARY (cost=20.94 rows=99) (actual time=0.028..0.237 rows=99 loops=1) | | -> Index lookup on s using user_id (user_id=u.id) (cost=0.73 rows=1) (actual time=0.058..0.059 rows=2 loops=99) | +-----------------------------------------------------------------------------------------------------------------------+ actual timeを見ると、最終行取得まで6ミリ秒しかかかっていないことがわかります。改善前が598ミリ秒だったので、約100倍処理速度が改善したことになります。 なお、改善後の結果を軽く記しておくと Index range scan on u using PRIMARY ...で、usersテーブルを主キーを使用して範囲検索している rows=99と取得している件数が減っていること、actual timeが改善していることに注目しましょう。なお、検索しているテーブルもusersが先になっています。 Filter: (u.id < 100) ...で、1の結果をusers.id < 100の条件で絞り込みしている Index lookup on s using user_id ...で、scoresテーブルを1行ずつインデックス検索している 先程外部キー制約を追加したことにより、scores.user_idにはインデックスが張られている Nested loop inner join ...で、2, 3の結果をNested Loopして結合している となります。 まとめ EXPLAINは実行計画(MySQLがクエリをどの様に実行するかの計画)を見ることができる EXPLAIN ANALYZEは実行計画と、実際にクエリを実行した結果を見ることができる (cost=xxx rows=xxx)の部分は推定実行コストと戻される行の推定数で、これはEXPLAINで取得できるものと同じである (actual time=xxx..xxx rows=xxx loops=xxx)は実際にクエリを実行してかかった時間(ミリ秒)、取得した行数、実行された回数を表している EXPLAIN ANALYZEが複数ステップでネストしている場合、ネストが深いところから順に読んでいく 明日は@polidogさんの記事になります。 PHPについて書いてくれるみたいなので、個人的にもめっちゃ気になっています。 お楽しみに! 参考 MySQLマニュアル MySQL :: MySQL 8.0 リファレンスマニュアル :: 13.8.2 EXPLAIN ステートメント MySQL :: MySQL 8.0 リファレンスマニュアル :: 8.8.2 EXPLAIN 出力フォーマット EXPLAIN ANALYZEについて Using Explain Analyze in MySQL 8 - Percona Database Performance Blog MySQL :: WL#4168: Implement EXPLAIN ANALYZE EXPLAIN FORMAT=TREEの読み方 Georgi Sotirov's Blog: TREE explain format in MySQL 8.0.16 Oracle SQL実行計画の読み方 | コーソルDatabaseエンジニアのBlog PolarDB · 特性分析 · Explain Format Tree 详解 注意点として、costの数字は具体的な時間などを表すものではなく、後に出てくる実行時間(ミリ秒)とは直接比較できません。単に値が大きければコストが大きい(≒遅い、DBに負担がかかる)、値が小さければコストが小さい(≒速い、DBへの負担が少ない)という目安の数字だと思ってください。 ↩ 厳密にはこちらの記事で紹介されている通り、ネストが最も深い順に実行されるとは限らないのですが、ここでは説明を単純化しています。もっと厳密に理解したい方は左記の記事を参考にしてみてください。 ↩
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ファミレスのメニューシステムもどき作ってみた

しばらくぶりにシステム作ってみました。 飲食店のチェーン店でタブレットのメニューシステムを見てた時、「作れそうかも」と思ったこと。 ですがPHPはまだまだ初心者。想像しながらの穴だらけ試作システムです。 DBも初めて連携しました。 やりたいこと - DBからメニューを書出し - メニュー注文機能の作成(追加、変更、削除) - 検索システム ブラウザからDBにメニューを入れる管理システムも作ってみました。 ▶︎ファミレスのメニューもどき管理画面を作ってみた 下記が相関関係図です。 トップページ index.php XDでざっくりデザイン作成後、HTMLとCSSで組み立て。 こんな感じのUIです。 メニューボックスをクリックするとメニュー注文へ。 ▶︎サンプルサイトはこちら 共通のDB接続はパーツ化してインクルード。 DBからSELECTで全データを取得後配列化。 index.php //DB接続のインクルードは省略 //DBより一覧表書き出し $sql_list = 'SELECT * FROM menulist order by ID ASC'; $stmt = $dbh->prepare($sql_list); $stmt->execute(); $data =array(); $count = $stmt->rowCount();//レコード数取得 while($row = $stmt->fetch(PDO::FETCH_ASSOC)){ $data[] = $row; メニュー一覧はカテゴリーごとにまとめたかったので、それぞれforeachでループしました。 カテゴリーの順番も任意で。 メインの後にドリンクがくると何かしっくりいかないし、やはりサラダ、スープ、前菜など順番は大切かなと。 foreachの中にifでカテゴリーごとにループさせるのは分かったのですが、 if($row['category'] == 'メイン') だといちいちカテゴリーごとにブロックを書かないといけないので、面倒い。時間を置いて考えて、for文で試してみました。 $cat_listという配列を手作り。任意の順番で並べたかったので下記のように記述。 $cat_list = ['メイン','サラダ','スープ','サイドメニュー','麺・パスタ','デザート','ドリンク']; それぞれのsectionに同一ページスクロール用のidも付けたかったのでid用の配列も作成。 $cat_listの内容と順番を合わせておきます。 $category_id = ['mainmenu','salad','soup','sideMenu','pasta','dessert','drink']; forの$iはforeachの中で使うとうまく出力しなかったので外側で変数に代入。 $cat_item = $cat_list[$i]; index.php <?php for($i = 0; $i < count($cat_list); $i++): ?> <!-- $iはforeachの中に入れるとちゃんと表示されないので外で変数にした --> <?php $cat_item = $cat_list[$i]; ?> <!-- idを付与 --> <section id="<?php echo $category_id[$i]; ?>"> <h2 class="catTitle"><span>Category</span><?php echo $cat_list[$i]; ?></h2> <ul class="menuBox"> <?php foreach($data as $row): ?> <!-- カテゴリーごとにまとめて書き出し --> <?php if($row['category'] == $cat_item): ?> <li class="selectOrder"> <div class="img"> <img src="<?php echo $img_path.$row['image']; ?>" alt=""> <p class="cart">注文する</p> </div> <div class="details"> <p class="material"><?php echo $row['material']; ?></p> <p class="menuName">商品名<span><?php echo $row['item']; ?></span></p> <dl> <dt>金額</dt> <dd><span class="orderPlic"><?php echo number_format($row['plice']); ?></span>円<span class="smallText">(税込)</span></dd> </dl> </div> </li> <!-- //.selectOrder --> <?php endif; ?> <?php endforeach; ?> </ul> </section> <?php endfor; ?> 思うようにリスト化出来ました。 嬉しい。 注文確認フォーム 注文確認フォームは移行せず、同一ページ内で最初は非表示にしてメニューBOXがクリックされたらformが表示。 選んだアイテムだけが書き出されるようjsで操作。 ここはcount数で悩まされました。 jsで++countを置いているので、初期値を-1に <input id="count" type="hidden" name="count_menu" value="-1"> bace.js //submitで持っていく値の個数と番号の変数 let count; //商品をクリックしたら注文リストへ jQuery('.selectOrder').on('click',function(){ //選んだ商品データを変数に入れておく const orderName = jQuery('.menuName span',this).text(); const orderImg = jQuery('.img img',this).attr('src'); const orderPlice = jQuery('.orderPlic',this).text(); //カウントを取得しておかないとおかしくなる count = jQuery('#count').attr('value'); //count位置重要 ++count; //TOP注文リスト表示、メニュー一覧非表示 jQuery('#orderBox').css('display','block'); jQuery('#header,#main').css('display','none'); //追加・変更ページ jQuery('#orderChangeBox').css('display','block'); jQuery('#headerChange,#mainChange').css('display','none'); //注文の商品名、値段、画像、個数を書き出し、カウント数と合わせておく //tableの中に要素書き出し jQuery('#orderMenu,#changeMenu').append('<tr><th class="orderName">'+orderName+'</th><td class="orderImg"><img src="'+orderImg+'" alt=""></td><td class="orderOneplice">'+orderPlice+'円<span class="smallText">(税込)</span><img src="image/icon_x.svg" alt=""></td><td class="num"><input type="number" name="num'+count+'" min="1" max="50" value="1">個</td><td><p class="deleteMenu">削除<img src="image/icon_x.svg" alt=""></p></td></tr>'); //name属性+カウント数でgetで持っていく値と数を指定 jQuery('#count').before('<input type="hidden" name="title'+count+'" value="'+orderName+'"><input type="hidden" name="plice'+count+'" value="'+orderPlice+'"><input type="hidden" name="img'+count+'" value="'+orderImg+'">'); jQuery('#count').attr('value',count); }); //MENU削除 jQuery(document).on('click', '.deleteMenu', function(){ //デリートボタンを押したtrのみを削除 jQuery(this).parents('tr').remove(); //現在のカウントを取得 count = jQuery('#count').attr('value'); //削除によって揃わなくなった個数と順番の連動を合わせる for(let i=0; i < count; ++i){ jQuery('input[name^="title"]').eq(i).attr('name','title'+i); jQuery('input[name^="plice"]').eq(i).attr('name','plice'+i); jQuery('input[name^="num"]').eq(i).attr('name','num'+i); jQuery('input[name^="img"]').eq(i).attr('name','img'+i); } //1個ずつ持っていく数を減らす --count; jQuery('#count').attr('value',count); //確認リストから商品がなくなったらメニュー一覧を表示 if(count < 0){ jQuery('#orderBox,#orderChangeBox').css('display','none'); jQuery('#header,#main,#headerChange,#mainChange').css('display','block'); } }); jQueryでappendメソッドで後から追加した要素にclickイベントを効かせるようにするには要素に対してではなく、documentに対してイベントを設定しないとダメみたいです。 受け取り先の$_GETでは for($i = 0; $i < $count_menu+1; $i++) こうすると数が合うようになりました。 order.php $count_menu = isset($_GET['count_menu']); //XSS関数作成html_escape() $count_menu = html_escape($_GET['count_menu']); $count_menu = (int)$count_menu; $order_name = []; $one_plice = []; $num = []; $sub_plise = []; $order_img = []; //設定したカウント数でname値を書き出し配列へ入れる for($i = 0; $i < $count_menu+1; $i++){ $order_name[$i] = html_escape($_GET['title'.$i]); $one_plice[$i] = html_escape($_GET['plice'.$i]); $order_img[$i] = html_escape($_GET['img'.$i]); //文字列内にある”,”を""に変換 $one_plice[$i] = str_replace(',', '',$one_plice[$i]); $one_plice[$i] = (int)$one_plice[$i]; $num[$i] = html_escape($_GET['num'.$i]); $num[$i] = (int)$num[$i]; //値段と個数を掛けておく $sub_plise[$i] = $one_plice[$i] * $num[$i]; } 決定的な問題は検索機能だった TOPページの注文表示はjsのclickイベント表示、検索機能はformのsubmitで表示。 いずれも同一ページ内での操作。 clickイベントでアイテムがあった場合、検索すると再読み込みになってデフォルトに戻る。 要はclickで溜まったアイテムは消えて、検索結果から選んだアイテムに上書きされてしまいました。 最初はTOPページの注文表でリストを作り、追加変更が決まったらオーダー画面へ移行だった。 それが、TOPページの注文表で一つメニューを選んだら、オーダー画面に移行。その後追加変更ページという中途半端なフローになってしまいました。 もともとオーダー画面から追加変更ページに戻る設計は考えていました。 合計を見てから、「やっぱり変えたい」というお客さんはいると思うので。 その場合、データはJSONに格納します。ページ以降するたびformでname値を持っていくのはややこしいので。 追加変更ページ ここは検索機能を削除したトップページと同じ作り。 ただ、メニュー一覧は非表示で注文リストが表示された状態。 オーダーから移行してきたら注文リストへ行きたかったので。 検索機能は一番最後に作ったので、最後の最後で苦肉の策でした。 出来れば金額とキーワード検索も試したかったのですが、とてもそれどころじゃなかったです… 以下サンプルコードです。 共通インクルードファイル db_join.php <?php //DBに接続 $dsn = 'mysql:dbname=sample;host=localhost;charset=utf8'; $user ='root'; $password = ''; $dbh = new PDO($dsn,$user,$password); $dbh->setAttribute(PDO::ATTR_ERRMODE,PDO::ERRMODE_EXCEPTION); //DBへ格納した画像パス $img_path = '/menulist/control/img_up/'; //XSS function html_escape($word){ return htmlspecialchars($word,ENT_QUOTES,'UTF-8'); } //エラーチェック function check_word($word,$length){ if(mb_strlen($word) === 0 || mb_strlen($word) > $length){ return FALSE; } else{ return TRUE; } } //書き出し一覧のカテゴリーループに使う配列 $cat_list = ['メイン','サラダ','スープ','サイドメニュー','麺・パスタ','デザート','ドリンク']; //一覧の#セレクタ、同一スクロール用配列 $category_id = ['mainmenu','salad','soup','sideMenu','pasta','dessert','drink']; select_cat.php <?php //カテゴリーoption $cat_item = ['メイン','サラダ','スープ','サイドメニュー','麺・パスタ','デザート','ドリンク']; $selected = ''; ?> <?php for($i=0; $i < count($cat_item); $i++): ?> <?php if(isset($select_cat)){ if($select_cat === $cat_item[$i]){ $selected = 'selected="selected"'; } else { $selected = ''; } } ?> <option value="<?php echo $cat_item[$i]; ?>" <?php echo $selected; ?>><?php echo $cat_item[$i]; ?></option> <?php endfor; ?> ※select_mt.phpは素材のoption書出しインクルードファイルで、作りはselect_cat.phpと同じです。 index.php <?php try{ include_once(dirname(__FILE__).'/control/db_join.php'); //DBより一覧表書き出し $sql_list = 'SELECT * FROM menulist order by ID ASC'; $stmt = $dbh->prepare($sql_list); $stmt->execute(); $data =array(); $count = $stmt->rowCount();//レコード数取得 while($row = $stmt->fetch(PDO::FETCH_ASSOC)){ $data[] = $row;//FETCH_ASSOCで配列として書き出して代入 } //検索フォーム $cat_name = ''; $mt_name = ''; $sarch_plice = ''; $sarch_cat = ' none'; $sarch_mt = ' none'; $sarch_cat_mt = ' none'; $sarch_class = 'none'; if($_SERVER['REQUEST_METHOD'] === 'GET'){ if(isset($_GET['cat'])){ $cat_name = html_escape($_GET['cat']); $sarch_class = ''; } if(isset($_GET['material'])){ $mt_name = html_escape($_GET['material']); $sarch_class = ''; } if($cat_name && $mt_name== ''){ $sarch_cat = ''; $sarch_mt = ' none'; $sarch_cat_mt = ' none'; } elseif($mt_name && $cat_name == '') { $sarch_mt = ''; $sarch_cat = ' none'; $sarch_cat_mt = ' none'; } elseif($cat_name !== '' && $mt_name !== ''){ $sarch_cat_mt = ''; $sarch_mt = ' none'; $sarch_cat = ' none'; } else { $sarch_cat_mt = ' none'; $sarch_mt = ' none'; $sarch_cat = ' none'; } } } catch (PDOException $e){ print($e->getMessage()); die(); } ?> <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>DBテストメインメニュー</title> <link rel="stylesheet" href="/menulist/common/sanitize.css"> <link rel="stylesheet" href="/menulist/common/style.css"> <!--ファビコン32x32--> <link rel="shortcut icon" href="favicon.ico" type="image/vnd.microsoft.icon"> </head> <body> <div id="wrapper"> <header id="header"> <h1 class="topTitle">DBテストメニュー</h1> <p class="center">同一ページスクロール</p> <nav> <!-- 同一ページスクロール --> <ul class="topNav"> <!-- $cat_list配列はdb_join.phpに記述 --> <!-- href=#だと検索の時、挙動が怪しいのでdata属性にした リンクに飛ぶせい?--> <?php for($i = 0; $i < count($cat_list); $i++): ?> <li> <a data-id="<?php echo $category_id[$i]; ?>"><?php echo $cat_list[$i]; ?></a> </li> <?php endfor; ?> </ul> </nav> <!-- 検索ボタン --> <div id="sarch" class="btn"><a href="dummy.html">検索BOX</a></div> <!-- 検索BOX --> <div id="sarchBox"> <h3>AND検索BOX</h3> <form id="andSarch" method="get" action=""> <div id="ones"> <div id="sarchCat" class="ones__div"> <p class="onesText">カテゴリー</p> <select name="cat"> <option value="" selected="selected">選択する</option> <!-- selectはインクルード --> <?php include_once(dirname(__FILE__).'/control/select_cat.php'); ?> </select> </div> <!-- //.ones__div --> <img src="image/icon_plus.svg" alt=""> <div id="sarchMaterial" class="ones__div"> <p class="onesText">素材</p> <select name="material"> <option value="" selected="selected">選択する</option> <!-- selectはインクルード --> <?php include_once(dirname(__FILE__).'/control/select_mt.php'); ?> </select> </div> <!-- //.ones__div --> </div> <!-- //#ones --> <div id="btn"><input type="submit" value="この条件で検索する"></div> </form> <!-- 検索結果 --> <section id="sarchMenu" class="<?php echo $sarch_class; ?>"> <?php include_once(dirname(__FILE__).'/sarch_box.php'); ?> </section> <!-- // #sarchMenu--> </div> <!-- // #sarchBox--> </header> <main id="main"> <!-- $cat_list配列はdb_join.phpに記述 --> <?php for($i = 0; $i < count($cat_list); $i++): ?> <!-- $iはforeachの中に入れるとちゃんと表示されないので外で変数にした --> <?php $cat_item = $cat_list[$i]; ?> <section id="<?php echo $category_id[$i]; ?>"> <h2 class="catTitle"><span>Category</span><?php echo $cat_list[$i]; ?></h2> <ul class="menuBox"> <?php foreach($data as $row): ?> <?php if($row['category'] == $cat_item): ?> <li class="selectOrder"> <div class="img"> <img src="<?php echo $img_path.$row['image']; ?>" alt=""> <p class="cart">注文する</p> </div> <div class="details"> <p class="material"><?php echo $row['material']; ?></p> <p class="menuName">商品名<span><?php echo $row['item']; ?></span></p> <dl> <dt>金額</dt> <dd><span class="orderPlic"><?php echo number_format($row['plice']); ?></span>円<span class="smallText">(税込)</span></dd> </dl> </div> </li> <!-- //.selectOrder --> <?php endif; ?> <?php endforeach; ?> </ul> </section> <?php endfor; ?> </main> <!-- 注文確認フォーム --> <div id="orderBox"> <h2 class="topTitle">注文へ</h2> <p class="center">個数の変更ができます。まずは「注文確認」ボタンを押してください。<br>注文リストで追加変更ができます。<br>キャンセルする場合は削除ボタンを押してください。</p> <form method="get" action="/menulist/order.php"> <table class="orderMenu" id="orderMenu"> <!-- jsより書き出し --> </table> <input id="count" type="hidden" name="count_menu" value="-1"> <!-- countは-1にしないとオーダー画面で数が合わない click時にjsで++countにしている--> <input class="toBtn" type="submit" value="注文確認"> </form> </div> <!-- //#orderBox --> <footer id="footer"> <small>DBテストメニューsystem</small> </footer> </div> <!-- //#wrapper --> <script src="/menulist/common/jquery-3.6.0.min.js"></script> <script src="/menulist/common/base.js"></script> </body> </html> 検索BOXインクルードファイル sarch_box.php <h2 class="catTitle"><span>SARCH</span>検索結果</h2> <p id="sarchCount"></p> <!-- カテゴリーの検索結果 --> <ul class="menuBox<?php echo $sarch_cat; ?>"> <?php foreach($data as $row): ?> <?php if($row['category'] == $cat_name): ?> <li class="selectOrder"> <div class="img"> <img src="<?php echo $img_path.$row['image']; ?>" alt=""> <p class="cart">注文する</p> </div> <div class="details"> <p class="material"><?php echo $row['material']; ?></p> <p class="menuName">商品名<span><?php echo $row['item']; ?></span></p> <dl> <dt>金額</dt> <dd><span class="orderPlic"><?php echo number_format($row['plice']); ?></span>円<span class="smallText">(税込)</span></dd> </dl> </div> </li> <?php endif; ?> <?php endforeach; ?> </ul> <!-- 素材の検索結果 --> <ul class="menuBox<?php echo $sarch_mt; ?>"> <?php foreach($data as $row): ?> <?php if($row['material'] == $mt_name): ?> <li class="selectOrder"> <div class="img"> <img src="<?php echo $img_path.$row['image']; ?>" alt=""> <p class="cart">注文する</p> </div> <div class="details"> <p class="material"><?php echo $row['material']; ?></p> <p class="menuName">商品名<span><?php echo $row['item']; ?></span></p> <dl> <dt>金額</dt> <dd><span class="orderPlic"><?php echo number_format($row['plice']); ?></span>円<span class="smallText">(税込)</span></dd> </dl> </div> </li> <!-- //.selectOrder --> <?php endif; ?> <?php endforeach; ?> </ul> <!-- カテゴリーand素材の検索結果 --> <ul class="menuBox<?php echo $sarch_cat_mt; ?>"> <?php foreach($data as $row): ?> <?php if($row['category'] == $cat_name && $row['material'] == $mt_name): ?> <li class="selectOrder"> <div class="img"> <img src="<?php echo $img_path.$row['image']; ?>" alt=""> <p class="cart">注文する</p> </div> <div class="details"> <p class="material"><?php echo $row['material']; ?></p> <p class="menuName">商品名<span><?php echo $row['item']; ?></span></p> <dl> <dt>金額</dt> <dd><span class="orderPlic"><?php echo number_format($row['plice']); ?></span>円<span class="smallText">(税込)</span></dd> </dl> </div> </li> <!-- //.selectOrder --> <?php endif; ?> <?php endforeach; ?> </ul> base.js jQuery(function(){ jQuery('#orderBox').css('display','none'); //同一ページスクロール jQuery('.topNav a[data-id]').on ('click',function() { let dataID= jQuery(this).attr('data-id'); let position = jQuery('#'+dataID).offset().top-20; // スムーススクロール jQuery('body,html').animate({scrollTop:position}, 500, 'swing'); }); //検索ボタン jQuery('#sarchBox').css('display','block'); jQuery('#sarch a').on('click',function(){ if(jQuery('#sarchBox').css('display') == 'block'){ jQuery('#sarchBox').slideUp('first'); jQuery(this).css('background','#FA9600 url(/menulist/image/icon_plus_wh.svg) 338px center no-repeat'); return false; } else { jQuery('#sarchBox').slideDown('first'); jQuery(this).css('background','#FA9600 url(/menulist/image/icon_minus_wh.svg) 338px center no-repeat'); return false; } }); //検索件数表示 let sarchLi = jQuery('#sarchMenu .menuBox').find('li'); let liNone = jQuery('#sarchMenu ul.none').find('li'); let sarchCount = sarchLi.length - liNone.length; if(sarchCount != 0){ jQuery('#sarchCount').text(sarchCount+'件見つかりました'); } else { jQuery('#sarchCount').text('該当メニューは見つかりませんでした'); } //submitで持っていく値の個数と番号の変数 let count; //商品をクリックしたら注文リストへ jQuery('.selectOrder').on('click',function(){ //選んだ商品データを変数に入れておく const orderName = jQuery('.menuName span',this).text(); const orderImg = jQuery('.img img',this).attr('src'); const orderPlice = jQuery('.orderPlic',this).text(); //カウントを取得しておかないとおかしくなる count = jQuery('#count').attr('value'); //count位置重要 ++count; //注文リスト表示 jQuery('#orderBox').css('display','block'); jQuery('#header,#main').css('display','none'); //変更ページ jQuery('#orderChangeBox').css('display','block'); jQuery('#headerChange,#mainChange').css('display','none'); //注文の商品名、値段、画像、個数を書き出し、カウント数と合わせておく jQuery('#orderMenu,#changeMenu').append('<tr><th class="orderName">'+orderName+'</th><td class="orderImg"><img src="'+orderImg+'" alt=""></td><td class="orderOneplice">'+orderPlice+'円<span class="smallText">(税込)</span><img src="image/icon_x.svg" alt=""></td><td class="num"><input type="number" name="num'+count+'" min="1" max="50" value="1">個</td><td><p class="deleteMenu">削除<img src="image/icon_x.svg" alt=""></p></td></tr>'); //name属性+カウント数でgetで持っていく値と数を指定 jQuery('#count').before('<input type="hidden" name="title'+count+'" value="'+orderName+'"><input type="hidden" name="plice'+count+'" value="'+orderPlice+'"><input type="hidden" name="img'+count+'" value="'+orderImg+'">'); jQuery('#count').attr('value',count); }); //MENU削除 jQuery(document).on('click', '.deleteMenu', function(){ //デリートボタンを押したtrのみを削除 jQuery(this).parents('tr').remove(); //現在のカウントを取得 count = jQuery('#count').attr('value'); //削除によって揃わなくなった個数と順番の連動を合わせる for(let i=0; i < count; ++i){ jQuery('input[name^="title"]').eq(i).attr('name','title'+i); jQuery('input[name^="plice"]').eq(i).attr('name','plice'+i); jQuery('input[name^="num"]').eq(i).attr('name','num'+i); jQuery('input[name^="img"]').eq(i).attr('name','img'+i); } //1個ずつ持っていく数を減らす --count; jQuery('#count').attr('value',count); //確認リストから商品がなくなったら注文リストへ移行 if(count < 0){ jQuery('#orderBox,#orderChangeBox').css('display','none'); jQuery('#header,#main,#headerChange,#mainChange').css('display','block'); } }); //変更メニューページ jQuery('#headerChange,#mainChange').css('display','none'); //追加ボタン jQuery('#toList').on('click',function(){ jQuery('#orderBox').css('display','none'); jQuery('#header,#main').css('display','block'); //変更ページ jQuery('#orderChangeBox').css('display','none'); jQuery('#headerChange,#mainChange').css('display','block'); }); }); 注文確認ページorder.php JSONは空ファイルを用意しておきます。 order.php <?php //XSS function html_escape($word){ return htmlspecialchars($word,ENT_QUOTES,'UTF-8'); } $count_menu = isset($_GET['count_menu']); $count_menu = html_escape($_GET['count_menu']); $count_menu = (int)$count_menu; $order_name = []; $one_plice = []; $num = []; $sub_plise = []; $order_img = []; //設定したカウント数でname値を書き出し配列へ入れる for($i = 0; $i < $count_menu+1; $i++){ $order_name[$i] = html_escape($_GET['title'.$i]); $one_plice[$i] = html_escape($_GET['plice'.$i]); $order_img[$i] = html_escape($_GET['img'.$i]); //文字列内にある”,”を""に変換 $one_plice[$i] = str_replace(',', '',$one_plice[$i]); $one_plice[$i] = (int)$one_plice[$i]; $num[$i] = html_escape($_GET['num'.$i]); $num[$i] = (int)$num[$i]; $sub_plise[$i] = $one_plice[$i] * $num[$i]; } //配列の値の合計を計算 $total_plice = array_sum($sub_plise); //JSONに入れる為、配列へ $order = [$order_name,$num,$one_plice,$order_img]; //JSON形式に変換 $json = json_encode($order,JSON_UNESCAPED_UNICODE); //JSONへ格納 file_put_contents('./order.json',$json); ?> <!DOCTYPE html> <html lang="ja"> <head> <!-- 省略 --> </head> <body> <div id="wrapper"> <header id="header"> <h1 class="topTitle">注文確認</h1> <p class="center">確定ボタンで注文完了です。<br>変更される場合は「追加・変更」で戻ってください。</p> </header> <main id="main"> <form method="get" action="thanks.php"> <table class="orderMenu"> <?php for($i = 0; $i < $count_menu+1; $i++): ?> <tr> <th><?php echo $order_name[$i]; ?><input type="hidden" name="order_name<?php echo $i; ?>" value="<?php echo $order_name[$i]; ?>"></th> <!-- number_format3桁区切り --> <td class="plice"><?php echo number_format($one_plice[$i]); ?>円<span class="smallText">(税込)</span><img src="image/icon_x.svg" alt=""><input type="hidden" name="one_plice<?php echo $i; ?>" value="<?php echo $one_plice[$i]; ?>"></td> <td class="num"><?php echo $num[$i]; ?>個<input type="hidden" name="num<?php echo $i; ?>" value="<?php echo $num[$i]; ?>"></td> <td class="plice"><?php echo number_format($sub_plise[$i]); ?>円<span class="smallText">(税込)</span><input type="hidden" name="sub_plise<?php echo $i; ?>" value="<?php echo $sub_plise[$i]; ?>"></td> </tr> <?php endfor; ?> </table> <div id="total"><span>合計</span><?php echo number_format($total_plice); ?>円<span class="smallText">(税込)</span><input type="hidden" name="total_plice" value="<?php echo $total_plice; ?>"></div> <!-- メニューのクリックは無いのでcount-1の調整はいらない --> <input id="count" type="hidden" name="count_menu" value="<?php echo $count_menu; ?>"> <input class="toBtn" type="submit" value="注文確定する"> </form> <div id="toList"><a href="order_change.php">追加・変更する</a></div> </main> <footer id="footer"> <small>DBテストメニューsystem</small> </footer> </div> <!-- //#wrapper --> </body> </html> order_change.php <?php try{ include_once(dirname(__FILE__).'/control/db_join.php'); //DBより一覧表書き出し $sql_list = 'SELECT * FROM menulist order by ID ASC'; $stmt = $dbh->prepare($sql_list); $stmt->execute(); $data =array(); $count = $stmt->rowCount();//レコード数取得 while($row = $stmt->fetch(PDO::FETCH_ASSOC)){ $data[] = $row;//FETCH_ASSOCで配列として書き出して代入 } //jsonファイルを取得 $json =file_get_contents('./order.json'); $json = mb_convert_encoding($json, 'UTF8', 'ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN'); $json = json_decode($json,true); //jsonの中に入ったアイテム数、タイトルから取得 $count_menu = count($json[0]); //var_dump($json); //PHPではJSON形式のデータをそのまま扱うことができないので配列の形にする必要があり //https://hacknote.jp/archives/42243/ $order_name =array(); $num = array(); $one_plice = array(); $order_img = array(); //foreachだと値を書き出せなかった for($i = 0; $i < $count_menu; $i++){ array_push($order_name,$json[0][$i]); array_push($num,$json[1][$i]); array_push($one_plice,$json[2][$i]); array_push($order_img,$json[3][$i]); } } catch (PDOException $e){ print($e->getMessage()); die(); } ?> <!DOCTYPE html> <html lang="ja"> <head> <!-- 省略 --> </head> <body> <div id="wrapper"> <header id="headerChange"> <h1 class="topTitle">DBテスト変更メニュー</h1> <p class="center">ブラウザの再読み込みボタンは使わないでください。エラーになります。</p> <nav> <ul class="topNav"> <!-- $cat_list配列はdb_join.phpに記述 --> <?php for($i = 0; $i < count($cat_list); $i++): ?> <li> <a data-id="<?php echo $category_id[$i]; ?>"><?php echo $cat_list[$i]; ?></a> </li> <?php endfor; ?> </ul> </nav> </header> <main id="mainChange"> <!-- ここはindex.phpと同じなので省略します --> </main> <div id="orderChangeBox"> <h2 class="topTitle">注文リスト</h2> <p class="center">メニューの追加と個数の変更ができます。追加はメニュー画面へ戻ってください。<br>メニューが決まりましたら注文確定ボタンへ、キャンセルしたいメニューは削除を押してください。</p> <form method="get" action="order.php"> <table class="orderMenu" id="changeMenu"> <?php for($i = 0; $i < $count_menu; $i++): ?> <tr> <th class="orderName"> <?php echo $order_name[$i]; ?> <input type="hidden" name="title<?php echo $i; ?>" value="<?php echo $order_name[$i]; ?>"> </th> <td class="orderImg"> <img src="<?php echo $order_img[$i]; ?>" alt=""> <input type="hidden" name="img<?php echo $i; ?>" value="<?php echo $order_img[$i]; ?>"> </td> <td class="orderOneplice"> <?php echo $one_plice[$i]; ?>円<span class="smallText">(税込)</span><img src="/menulist/image/icon_x.svg" alt=""> <input type="hidden" name="plice<?php echo $i; ?>" value="<?php echo $one_plice[$i]; ?>"> </td> <td class="num"> <input type="number" name="num<?php echo $i; ?>" min="1" max="50" value="<?php echo $num[$i]; ?>">個 </td> <td> <p class="deleteMenu">削除<img src="/menulist/image/icon_x.svg" alt=""></p> </td> </tr> <?php endfor; ?> </table> <input id="count" type="hidden" name="count_menu" value="<?php echo $count_menu-1; ?>"> <div id="toList">メニューを見る</div> <input class="toBtn" type="submit" value="合計を見る"> <div id="toList"><a href="/menulist/">メニューをリセットする</a></div> </form> </div> <!-- //#orderBox --> <footer id="footer"> <small>DBテストメニューsystem</small> </footer> </div> <!-- //#wrapper --> <script src="/menulist/common/jquery-3.6.0.min.js"></script> <script src="/menulist/common/base.js"></script> </body> </html> thanks.phpは特に気になることはなく、データの表示だけなので省略します。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む