概要
このアプリは未経験から就職を目指すプログラミングシリーズで制作したものです。
過去のシリーズ
未経験からweb系エンジニアになるための独学履歴~初めてのポートフォリオ作成記録 製作記録編~
初心者がDjangoによる6週間でチームビルディングからプロダクト公開までやるプロジェクトに参加した話
はじめに
今回のアプリを作るにあたっての根底には2020年5月~6月末まで参加させていただいたDjangoチーム開発プロジェクトで得られた体験や知見が元になっております。
主催者であるdigisaku710さんを始めプロジェクトメンバーの方々のお力あってのことです、本当にありがとうございます。
また、デプロイに際して窮地に陥っていて突然のお願いにも関わらずデプロイの構成を手直ししてくださったGtca様、またReduxについてご教授頂いた世界の歪み様にも重ねて御礼申し上げます。
その他アドバイスやリリース前に試しに触ってチェックしてくれたフォロワーの方々にもここでお礼を申し上げさせていただきます、助かりました。
以下名前を挙げさせていただいた、お三方のリンクです。
Gtca様のみTwitterではなく、Qiitaの記事のページにリンクを貼らせていただきます。
プログラミングを始めてから思っていること、モチベーションについて
個人的な興味関心・モチベーション
個人開発をしてちょっとしたサービスも提供できるようになりたい。
というのが根底にあります。
これはフリーになって稼ぎたいという理由ではなく、エンジニアを目指した動機の中にWeb・モバイル問わず自分でふと考えたちょっとしたものを作って公開して共有したり、例えば台本をアップロードして、声の吹込みを依頼できるアプリ・サービスを作って公開したりというちょっとした役に立ちそうなアプリやサービスを作れるのはかっこいいという至極単純なものが含まれているからです。
やっていきたい技術
なので技術的には
React、React Native、PHP(Laravel)、Python(Django)、Firebase、AWSを使った開発ができるようになりたいと思って学習を続けてきました。
今回、本記事に書いたようなReact+DRF+HerokuでTodoアプリを作ったのでそこから発展させるために開発中に掘り下げたい、あるいは掘り下げて行く必要があると感じたのは以下のスキルです。
- React Hooks(一旦バックエンドではなく、既存APIを利用してデータのやり取りを簡素にして、代わりに今回できなかったレンダリングの最適化の部分を掘り下げたい)
- Redux Hooks(React Hooksとともにもっと適切に使えるような設計をしたい)
- Django(基本的な部分でいいので、機械学習の方面を少し見たい、使える要素があったりやれることが増えるかもしれない)
- DRF(クラスベースViewとSerializerのカスタマイズ、詳しくは本記事最後の振り返りの項目にて)
- Firebase(TwitterAPIを使ったアプリを作れるようにというのとあまりにもフロント+バックエンドの認証が個人でやるには難しくリスキーであると今回感じたので、外部に任せたい)
- AWS(デプロイ先、Herokuだとちょっととなることも今後増えるだろうという判断)
他の言語や技術もこれらで開発ができるようになったら挑戦したいと思っています。
Laravelは冒頭で紹介したようにそれで作ったことはありますが、それきりなのでもう一度やるとすると軽く学び直しになったりしますが……。
特にSwiftに関してはSwift UIのカンファレンスに感銘を受けてエンジニアへの志望を強くしたのでMacBook Pro early 2011 以来買い時を見失っているMacを買い直したら挑戦したいと思っています。
もちろん、これは個人的な希望の話であるため採用された際にやる技術については別途採用先に従って学習も進めていく所存です。
それだけの根気と行動力はあると、略歴とこれまでのアウトプットや成果物を見て頂ければ思っていただけると信じています。
今の自分には実務のノウハウもなく、テストもデバックもインフラの知識もないのでいきなり開発案件を担当するのでなくそのあたりからのスタートでもあるということも承知しています。
ただここに関してはできればDockerとAWSのスキルが磨けるところであればとても幸いです。
Web系の受託・自社開発会社に就職したい
昨今、アプリやサービスは制作側とユーザーの方との距離が近く、ユーザーの方からの反応やレスポンスを日々フィードバックしながら開発を続けていくことが肝要なのだと感じているので、それに繋がるようにまずはWebエンジニアとしてBtoCやCtoC系のWebサービスやWebアプリケーションの開発等に携わり業務経験を積んで行きたいと強く思っています。
中期的なキャリアとしては前述の通りiOSアプリ制作に興味があるのでモバイルアプリ・サービスのエンジニアになりたいと思っていますが、まずはWebエンジニアとしてノウハウと経験を積んでいきたいと考えています。
また、将来的にはもし可能なのであれば自分のように未経験からプログラミングを始める人のための教材やコースを作ることに携わりたいなと思っています。
(学生・社会人向け問わず、個人サロンとかで利益を得るとかではなく私自身右も左も分からないままやってきたという経緯があるのでそういう人を少しでも減らしたいと思っているからです)
以上、簡単にではありますがご興味を持っていただけたら以下から今回作成したものに関してのレポートになりますのでご覧になっていただけると嬉しいです。
作ったもの
あえて他人に公開・共有するというのがコンセプトのTodoアプリです。
名前は某フェザー級日本タイトルマッチにおけるテーマから頂いています。
使った技術
環境
-
Pipenv 仮想環境
-
Heroku デプロイ先
-
PostgreSQL Herokuからの指定
-
Gunicorn Herokuからの指定
-
Windows10home Proにしたい
フロント
- React
- React-Bootstrap(Material-UIの代用)
バックエンド(サーバーサイド)
- Django(3.1、PythonはHerokuの指定のうち3.8.6を使用)
- Django-Rest-framework
その他使ったライブラリ抜粋等は以下の設計ファイルからご覧ください。
今回使っていて非常に助かったのはRedux ToolkitとReact Hook Formでした。
いずれもう少し掘り下げて記事を書くつもりでいます。
なぜDjangoとReactを選んだのか?
この記事冒頭にリンクを貼りましたが、5~6月末にかけてオンラインでDjangoでチーム開発をするという企画に参加しました。
そこで
- その際に得た体験や知見を生かして何かを作りたい。
- 当時は約1ヶ月PythonとDjangoを勉強しただけだったのでもう少しDjangoを掘り下げたい。
- この2点に加えてDjangoでの成果はチームでの成果のみなので、自分だけでDjangoを使って何かを作りたい。
以上3点を思い立ったことが要因でした。
企画をやるにあたってDjangoに触れた結果、日本語の情報量としてはPHPやLaravelと比べて遥かに劣り、ライブラリもあまり整備されたものが少なく、Django自体も色々な意味で省略されたフレームワークで、一見しただけではわからずドキュメントや海外のフォーラム・記事を探し、にらめっこしなければなりませんでしたが、それはそれとして一度基本の工程さえ押さえれば、Laravelと比べてわかりやすく開発できるなというのを魅力に感じたことと、AI開発もできるということでもしかしたらディープなものは無理でも修めていけばちょっとしたことくらいならできるのでは……? というロマンを感じていたのも大きいです。
それに加えて企画終了後の反省会において
「最近はフロントの知識(TSやReactなどのフレームワーク)も多少の理解はないと辛いと聞いたのですが、JSが苦手な私には少しハードルが高いんですよね……」
という旨を相談したところ、とりあえず何でもいいので触ってみるのが1番いいよというアドバイスを頂いたので、興味があったReactも使ってみようと相成りました。
Reactを選んだ理由としては
- 将来的にはモバイルアプリの開発もやりたいと考えていたので元々React Nativeに興味があるのでそれに連なるかなと思った。
- VueやAnglerと比べると近年盛り上がっているスキルなので情報はありそうかなと思った。
という2点です。
最初の理由が大きいです、今はまずはWeb系のサービスをととっかかりにPHP・Laravel、そして今はDjangoをやっていますが、Swift UIに憧れて今ここまで勉強を続けられて来ているので、将来的にはモバイルアプリの開発もできるようになりたい身としては、当然Androidアプリの開発に繋がるReact Nativeは将来的には押さえたいと思っていてそれならばReactも……と常々考えていました。
ちなみに、この頃はJSはAjaxとHTMLやCSSタグをイベントで書き換えるといった用途でしか触れていなかったのでReact他のフレームワークも、こうカッコよくページを動かしてくれるものなのだなというざっくりとしたイメージしかもってなかったのは恥ずかしいところです。
ということで今回の記事の最後の方にもあるようなチュートリアルや学習をして今回の制作に挑みました。
なぜTodoアプリなのか
これは単純明快でTodoアプリはタスクの登録、削除、一覧、更新とCRUDをやるのに1番理にかなっていると感じたからです。
また、それさえできれば例えばフレンド登録やグループ登録などの類似した機能も同時に実装できるので応用が効きやすいだろうという思惑もありました。
しかも今回は最終的にDRFとReactを扱うことになり、両方とも私にとっては完全に初見の技術であるといういうことと、同時にこれも初めてである自分で曲がりなりにも要件を定義し、それに合わせて設計をして実装するという工程をきちんと踏んでいくにあたって、独創性のあるものよりも車輪の再発明をするほうが挫折する確率も少なく、情報も集まるだろうと判断したからです。
技術的なアピールとしては弱くなりますが、これらの理由によりやり遂げることがそもそも大事であると判断しました。
要件定義と設計
これは今回のアプリを制作するにあたって一番最初に定義したものです。
詳細は以下のリンクから見て頂きたいと思います。
続いて設計書です。
こちらはデプロイにあたってまとめ直してあります。
理由としては今回こうやって作成するのは初めてのことなので当初設計したものはかなりアバウトであったのと、後学のためにきちんと整理したものを残しておくためです。
そしてご覧になられた方はわかると思いますが、この定義書で書いたことの中には実現できなかったことがあり、実装時にオミットまたは仕様を変更したところがあります。
なので、それらを含めてここでは少し補足をして行きたいと思います。
Todoアプリに付加価値を加えたいと思ったこと
これについてはまず単純にTodoアプリを作るだけでは面白くない(本音: アピールにならないだろうなぁ)ということと、単なるTodoアプリは世にごまんとチュートリアルの過程であるわけで、それじゃ自分でわざわざ要件定義して……なんてことをする意味もないだろうと思ったからです。
ですが、私はアプリ開発の経験も浅く、当然現場を経験しているわけでもないのでそのあたりのアイディアにも乏しいのでどうしたものかなと悩みました。
その結果、自分のタスクを他人に公開していくという方向性で考えてそうなると非公開にする機能が必要になるのではと考えたのですが、今の自分の状態では非公開の範囲(全体なのかユーザーごとなのか、はたまたタスク単位なのか……etc)を設定するコードを書くのにかなり難儀しそうだなと考え、それでは逆転の発想で
敢えて他人に見られることを前提にすればいいのでは?
ということを思い至り、冒頭にもあるように敢えて他人に公開・共有するというコンセプトで作ろうと思い至り定義と設計を進めていくことにしました。
仕様変更について
当初の設計から変わったことについて幾つか説明させていただきます。
- レーティング機能によりToDoに優先順位をつけたり、達成したToDoについて他者から評価を貰える。
↓
レーティングは実装したが、あえて用途はこちらで制限せず、ユーザーに進捗管理やタスクに対する評価等判断を任せることにしました。
後者の部分は所謂いいねですが、達成したTodoに限らず遂行中のものについてもいいねをつけられるようにしました。
これは人がなにかするにあたって1番モチベーションを挫きやすいのが誰にも興味を持ってもらえないことに気づいてしまうことだと私は考えるからです。
誰かが注目してくれているというだけでやる気は出るものですし、緊張感も出ると考えています。
- タイマーつきのToDoをクリアすると、アンロックされるToDoを設定できるようにして、タスクに対しての報酬を設定することができる。
↓
こちらは実装はしたかったのですが、当初の想定より作業が難航し処理を考える時間がなかったことと、今の自分にはそれ故に手余りになると考えてオミットしました。
目玉にしたかった機能でもあるのでかなり悔しいです。
- Todoの発信
↓
当初はソーシャルログインを実装し、それに伴ってTwitter APIを使いタスクリストのURLをツイートしたり、タスクの設定と同時にツイートもするというよく見かける機能をつけたかったのですが、DjangoとDRFとでライブラリとの兼ね合いとReadOnlyにしたCookieトークンをDjangoにキャッチさせる手段がわからず、どうしてもソーシャルログインと認証との橋渡しがうまくいかず、自力でやるにもリクエストトークンは送れたものの、キャッチしたアクセストークンをDRFに渡す処理がどうしても書けずにそれは断念しました。
余談ですが、誇張なしにまる二日ほぼ飲まず食わずでやって進捗が0だったのでこれは相当心にきました……
アドバイスやググり漁ってみたところFire baseを使うのが手っ取り早いので自分の中でまた一つマストな技術が増えてしまったなという感想とともに大いに反省しなければならない工程でした。
ただ、これでは外部に公開するというコンセプトが丸潰れになってしまうので、Topページに検索フォームを作りそこにユーザー名を入れると問答無用でそのユーザーのタスクリストに飛べるようにし、さらに同じくTopページに来るたびにランダムで5人のユーザーのタスクリストへの遷移ボタンを加えるということでどうにか最低限、コンセプトは守ることができた……と思います。
- グループ機能
↓
当初はパスワード制にして、パスワードを知らないとグループに入れないという仕様にしてグループでのタスクを設定できる……といったことを考えていたのですが、実装中にとログイン・ログインユーザーのチェックという認証に加えて、さらに別の認証を追加するのは今の自分には難度が高く、これ以上時間を割くのは難しいと考えたのでオミットし、どちらかというとTwitterのフォローやリストの機能のような形で実装することになりました。
- タイマー機能
↓
前述の通り、アンロックタイマーについてはオミット、タイマー終了に際してセットしたタスクの編集ページへの遷移ボタンを表示し評価への導線とする形で実装しました。
実装中、設定したタスクとは別にタスクを指定できるようにすることでタイマー終了後に指定したタスクに遷移し新たにタイマーをセットすることができる、タスククエストのような機能を追加しようかと考えたがやはり作業時間を考えて断念。
チャート図、ER図
リポジトリにあるのは当初書いたものでこちらはデプロイ後にまとめ直したものです。
M2Mフィールドは今回初めて使ったので色々苦労することになりました。
以後に記載します。
使い方・機能
各機能については流れを動画にしてあります。
各機能についての意図ややりたいこと、また実装後の簡易的な自己評価は機能設計書の実装したい機能及び自己講評の項目にあります。
よろしければこれらもご覧ください。
Topページの機能
-
自分のタスクやグループリストに飛ぶ
-
ユーザー名で検索して問答無用でそのユーザーのタスクリストを閲覧することができます。
-
Topページにはランダムで5人のユーザーへのタスクリストの遷移ボタンが現れる。
こちらはUser.objects.order_by('?')[:5]
で実装しました。
会員登録からタスク追加・削除・編集
通常のTodoアプリとしての機能です。
タスクリストのレイアウトについてはReact-BootstrapのToastを使ってみました。
タスクリストでは
- タスク名
- タスク詳細から備考欄の内容
- いいね数
- 追加日
が表示されるようにしています。
Toastに収めるのに必要十分かなという量でまとめました。
また、今回タスク詳細画面は他人からは閲覧できない状態にしてあります。
ここはどうするか迷いましたが、タスクリストで得られる情報と違いがあるのは達成日とレーティングくらいなので省きました。
これはいいねがレーティングのような数値による評価でなく、「Good Job」したかどうかのような使われ方をしていると思っているからです。
達成日に関しては、以下の完了したタスクフィルタリングで終わったタスクは確認できますし、レーティングは自己評価なのでいいねの指標にするには薄いかなと判断しました。
ただし、機能が増えて必要になればこの通りではないです。
レーティングの機能に関しては先の通りです。
フィルタリング
タスクにはカテゴリーを設定できるのでそれでフィルタリングするか、タスクが未完か否かでもフィルタリングできます。
ここは当初、どう実装するかどうかかなり難儀しました。
React側だけでのTodoアプリや既存のAPIと組み合わせで考えるならばなんとでも情報は転がっているのですが、Djangoとなると全然なかったのでどうしたものかと考えて見ました。
なのでまずフィルタリングとカテゴリーの機能で実装しないといけないものは何かと考えるところからはじめました。
カテゴリー
- カテゴリーを追加する
- カテゴリーを削除する
フィルタリング
- 特定の条件を設定して情報を抽出する
- 抽出した情報をもとに戻す
するとカテゴリーに関してはタスク編集でタスクのカテゴリーの変更欄を作らないといけないのでそこに統合しようとなり、問題はフィルタリングでAPIから引っ張ってきた情報を弄るのか、リクエストの段階でフィルタリングをかけるのかの2択なのだなということがわかりました。
結果として、私は前者でやることにしました。
タスクを引っ張ってくるためのフロント側のコードがこちらです。
折りたたみ
// タスクリスト取得
try {
const response = await axios.get(get_task_readonly_listUrl);
// ペジネーション関係の情報をstateに格納
get_pageNationNext(response.data.next);
get_pageNationPrevious(response.data.previous);
get_pageNationLastNumber(response.data.total_pages);
get_pageNationCurrent(response.data.current_page);
get_contentsAllCount(response.data.count);
// ペジネーションの関係でr.dataのリザルトプロパティから期待するdataを返してもらう
const responseMap = response.data.results.map((obj) => {
return obj;
});
const TaskList = _.mapKeys(responseMap, "id");
getTaskList(TaskList);
setAllTasks(TaskList);
} catch (error) {
createAlert({
message: "タスクリストの取得に失敗しました",
type: "danger",
});
} finally {
stopProgress();
}
};
これをReduxのstateに格納し、それをObject.value
で整形し、.map(() =>)
でリスト化するという方法でタスクリストを描画しています。
フィルタリングはそのObject.value
で整形したものを利用して以下のように書いてみました。
折りたたみ
// 取得してきたタスクリストを整形、こちらをフィルタリングなどの加工に使用する。
let saveTaskList = Object.values(tasks);
// 実際にフィルタリングなしのタスクリスト一覧の描画に使う変数、フィルタリングはこれと上記を入れ替える形で実装している。
let taskList = Object.values(all_tasks);
// CategoryでのFilter処理
const task_category_filter = (category_name) => {
// フィルタリングしているかの判断フラグを初期化(Falseにする)
Unfiltered();
// フィルタリングしているかの判断フラグをTrueに
Apply_Category_filter();
// stateを初期化
resetTasks();
const category_item = category_name;
// フィルタリング
const filtered_tasks = saveTaskList.filter(
(task) => task.category === category_item
);
// stateにセット
setAllTasks(filtered_tasks);
};
// is_CompletedがTrueのタスクをFilterする
const task_is_Completed_filter = () => {
Unfiltered();
Apply_is_Completed_filter();
resetTasks();
const filtered_tasks = saveTaskList.filter(
(task) => task.is_Completed === true
);
setAllTasks(filtered_tasks);
};
// is_CompletedがFalseのタスクをFilterする
const task_is_unCompleted_filter = () => {
Apply_is_Completed_filter();
resetTasks();
const filtered_tasks = saveTaskList.filter(
(task) => task.is_Completed === false
);
setAllTasks(filtered_tasks);
};
// Filterリセット
const task_filter_reset = () => {
Unfiltered();
resetTasks();
setAllTasks(saveTaskList);
};
スマートではないと感じますがこれでフィルタリングは実装できました。
ただし、これだとフィルタリングはカテゴリーとタスク状態とで併用できないのが問題です。
書いていた当時は必死になりすぎてて切羽詰まって考えが狭かったですが、それ自体は.filter
を2回やればいいだけなのでは? と今ふと思い当たってしまいました、ううむ。
他人のタスクリスト・グループ
誰かのタスクリストも見れます。
いわゆるいいねをタスクに押すことができ、他人が作ったグループリストに遷移してメンバーになることができます。
ここではいいね機能のためにM2Mフィールドを使った処理を書くことが必要になりました。
そもそも設計の段階でいいねってどういうテーブル設計になるんだ……? と思っていたらTwitterで中間テーブル作ってM2Mにするといいよというアドバイスを頂けたのでそう設計したのですが、いざこれを操作するとなるとどうしたものかとかなり難儀をすることになりました。
というのも
class Reaction(models.Model):
user = models.ForeignKey(CustomUser, blank=True, null=True, on_delete=models.CASCADE)
task = models.ForeignKey("Todo", blank=True, null=True, on_delete=models.CASCADE)
timestamp = models.DateTimeField(auto_now_add=True)
これがいいねの情報が保管されているテーブルなのですが、いいね機能は
- ボタンを押すといいねする
- もう一度ボタンを押すといいねが解除される
- いいねされた数はいいねしたユーザーの数で決定する
以上3点から成り立つので、上記のように誰が・どのタスクにいいねをしたという情報でDBに保存しないといけないのですがどうやって中間テーブルにアクセスするのかというのが問題になりました。
情報を集めてみるとこういう時にはどうやら中間テーブルへのアクセスではなく、関数ベースのViewを使ってリレーション先であるTodoモデルを操作するということが見えてきたので以下のように処理を書きました。
折りたたみ
フロント側
// いいね
const reactionPost = async (task_id) => {
const id = task_id;
const data = {
id: id,
// 'action': action
};
const response = await axios.post(postReactionUrl, data);
pullTaskList();
};
API側
# いいねを管理するView、中間モデルを使うのでモデルに依存しないAPIViewを使う。今回は関数ベースのそれ。
@api_view(['POST'])
@permission_classes([
permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly])
def reaction_view(request, *args, **kwargs):
"""
id is required.
Action Option are: Like, Unlike
"""
serializer = ReactionSerializer(data=request.data)
pagination_class = ReactionPagination
User = request.user
if serializer.is_valid(raise_exception=True):
data = serializer.validated_data
task_id = data.get("id")
# 該当タスク抽出
queryset = Todo.objects.filter(id=task_id)
# クエリセットの実行結果でタスクが取得できなかった場合
if not queryset.exists():
return Response({}, status=404)
# 取得してきたものをインスタンス化
obj = queryset.first()
# すでにいいね済みだった場合、いいねを取り消す
if User in obj.reaction_obj.all():
obj.reaction_obj.remove(request.user)
like_sum = obj.reaction_obj.count()
return Response(like_sum, status=200)
# いいね処理
else:
obj.reaction_obj.add(request.user)
like_sum = obj.reaction_obj.count()
return Response(like_sum, status=200)
return Response({"message": "Action Success"}, status=200)
フロント側からタスクのIDをAxiosでPOSTし、それを元にタスクを抽出します。
抽出したタスクからいいねにあたるフィールド(reaction_obj)にリクエストユーザーの情報があるかどうかで処理を切り替えるという処理になります。
同じようにM2Mを使っているグループ機能のうち、他人のグループへの参加・離脱もこれで実装できました。
折りたたみ
@api_view(['POST'])
@permission_classes([
permissions.IsAuthenticatedOrReadOnly])
def groupJoin_view(request, *args, **kwargs):
"""
id is required.
"""
serializer = UserGroupJoin_or_ReaveRequestSerializer(data=request.data)
# pagination_class = ReactionPagination
User = request.user
if serializer.is_valid(raise_exception=True):
data = serializer.validated_data
group_id = data.get("id")
# 該当グループ抽出
queryset = UserGroup.objects.filter(id=group_id)
# クエリセットの実行結果でタスクが取得できなかった場合
if not queryset.exists():
return Response({}, status=404)
# 取得してきたものをインスタンス化
obj = queryset.first()
# すでに追加済みだった場合、エラー
if User in obj.members.all():
return Response({}, status=404)
# 追加処理
else:
obj.members.add(request.user)
return Response("Request Success", status=200)
@api_view(['PATCH'])
@permission_classes([
permissions.IsAuthenticatedOrReadOnly])
def groupLeave_view(request, *args, **kwargs):
"""
id is required.
"""
serializer = UserGroupJoin_or_ReaveRequestSerializer(data=request.data)
# pagination_class = ReactionPagination
User = request.user
if serializer.is_valid(raise_exception=True):
data = serializer.validated_data
group_id = data.get("id")
# action = data.get("action")
# 該当タスク抽出
queryset = UserGroup.objects.filter(id=group_id)
# クエリセットの実行結果でタスクが取得できなかった場合
if not queryset.exists():
return Response({}, status=404)
# 取得してきたものをインスタンス化
obj = queryset.first()
# 削除処理
if User in obj.members.all():
obj.members.remove(request.user)
return Response("Request Success", status=200)
# ユーザーが存在しなかった場合エラー
else:
return Response({}, status=404)
またこの処理が書けたことによってタスクの状態を変化させる処理も実装することができました、以下の通りになります。
折りたたみ
# タスクの編集・削除のためのView
class TodoDetailAPIView(RetrieveUpdateDestroyAPIView):
queryset = Todo.objects.all()
serializer_class = TodoSerializer
permission_classes = [
permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
# パラメータ取得
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# PATCHのリクエストが来たときに以下の処理を追加する
def patch(self, request, *args, **kwargs):
data = request.data
# 取得したパラメーターからタスクのpkを抽出
task_id = self.kwargs['pk']
# リクエストからタスクの完了・未完了フラグを取得
is_Completed = data.get("is_Completed")
# タスクをフィルタリング
queryset = Todo.objects.filter(id=task_id)
# インスタンス化
obj = queryset.first()
# Falseの場合はclose_datetime(タスクの完了日)をnullにする
if(is_Completed == False):
obj.close_datetime = None
obj.save()
return self.partial_update(request, *args, **kwargs)
# Trueなら完了日を登録
else:
obj.close_datetime = datetime.datetime.now()
obj.save()
return self.partial_update(request, *args, **kwargs)
グループ機能
Twitterでいうフォローやリスト機能に近い機能になります。
メンバーのタスクリストへも遷移できます。
こちらでもM2Mフィールドを使った処理があります、自分のグループにフォームから受け取ったユーザーネームに該当するユーザーを追加するか、削除するかという処理です
以下の通りになります。
折りたたみ
@api_view(['POST'])
@permission_classes([
permissions.IsAuthenticatedOrReadOnly])
def memberAdd_view(request, *args, **kwargs):
"""
id and username is required.
"""
serializer = MemberRequestSerializer(data=request.data)
# pagination_class = ReactionPagination
if serializer.is_valid(raise_exception=True):
data = serializer.validated_data
group_id = data.get("id")
member_name = data.get("username")
# 該当グループ抽出
queryset = UserGroup.objects.filter(id=group_id)
# クエリセットの実行結果でタスクが取得できなかった場合
if not queryset.exists():
return Response("該当するグループがありません", status=404)
# 該当ユーザー抽出
queryset2 = CustomUser.objects.filter(username=member_name)
# クエリセットの実行結果でタスクが取得できなかった場合
if not queryset2.exists():
return Response(data, status=404)
# 取得してきたものをインスタンス化
obj = queryset.first()
Member = queryset2.first()
# すでに追加済みだった場合、エラー
if Member in obj.members.all():
return Response({}, status=404)
# 追加処理
else:
obj.members.add(Member)
return Response("Request Success", status=200)
@api_view(['PATCH'])
@permission_classes([
permissions.IsAuthenticatedOrReadOnly])
def memberDelete_view(request, *args, **kwargs):
"""
id and username is required.
"""
serializer = MemberRequestSerializer(data=request.data)
# pagination_class = ReactionPagination
if serializer.is_valid(raise_exception=True):
data = serializer.validated_data
group_id = data.get("id")
member_name = data.get("username")
# 該当グループ抽出
queryset = UserGroup.objects.filter(id=group_id)
# クエリセットの実行結果でタスクが取得できなかった場合
if not queryset.exists():
return Response("該当するグループがありません", status=404)
# 該当ユーザー抽出
queryset2 = User.objects.filter(username=member_name)
# クエリセットの実行結果でタスクが取得できなかった場合
if not queryset2.exists():
return Response(data, status=404)
# 取得してきたものをインスタンス化
obj = queryset.first()
Member = queryset2.first()
# すでに追加済みだった場合、エラー
if Member in obj.members.all():
obj.members.remove(Member)
return Response("Request Success", status=200)
# Memberが存在しない場合、エラー
else:
return Response("このグループにこのユーザーは存在しません", status=404)
またグループ詳細、つまりメンバーのリストの表示にも難儀しました。
以下がグループを表すテーブルとメンバーを表す中間テーブルです。
折りたたみ
class UserGroup(models.Model):
members = models.ManyToManyField(
CustomUser, through="UserGroupRelation", blank=True)
group_name = models.CharField(max_length=255, blank=True, null=True)
owner = models.ForeignKey(
CustomUser, verbose_name="ユーザー", related_name="GroupOwner", blank=True, null=True, on_delete=models.CASCADE)
detail = models.CharField(
max_length=60, blank=True, verbose_name="Group_Detail")
class Meta:
db_table = "UserGroup"
verbose_name = _("UserGroup")
verbose_name_plural = _("グループ")
def __str__(self):
return self.group_name
class UserGroupRelation(models.Model):
customuser_obj = models.ForeignKey(
CustomUser, verbose_name="ユーザー", blank=True, on_delete=models.CASCADE)
UserGroup_obj = models.ForeignKey(
UserGroup, verbose_name="グループ", blank=True, on_delete=models.CASCADE)
joined_date = models.DateField(default=datetime.now)
detail = models.CharField(
max_length=64, blank=True, verbose_name="What`s Group")
class Meta:
db_table = "UserGroupRelation"
verbose_name = _("UserGroupRelation")
verbose_name_plural = _("グループ詳細")
ご覧の通りメンバーの情報はM2Mで保存されますが、じゃあこれを取得して表示するにはどうすればいいのかという問題に行き当たりました。
先程のいいねの場合はreaction_objの数をカウントしたものを返してもらうことで解決しましたが今回はそうはいきません。
どうSerializerに落とし込むかと調べてみると下記の記事で紹介されていた方法で解決しました。
DjangoRestFrameworkで中間テーブルをネストした形のJsonで返す
コード
# M2MのMembersフィールドを抽出したい
class MemberSerializer(serializers.Serializer):
username = serializers.ReadOnlyField(
source="customuser_obj.username")
class Meta:
model = UserGroupRelation
fields = ['username']
# グループのシリアライザ
class UserGroupSerializer(serializers.HyperlinkedModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='UserGroup_detail', format='html')
owner = serializers.ReadOnlyField(source='owner.username')
members = MemberSerializer(source='usergrouprelation_set', many=True)
class Meta:
model = UserGroup
fields = ['url', 'id', 'members', 'group_name', 'owner']
自分が参加しているグループ
自分が参加しているグループを見れます。
ユーザー情報
ユーザー名・パスワード・メールアドレスの変更及び退会処理ができます。
デプロイ
ここで詰みそうになりました。
個人的な事情でデプロイ先はHerokuしかなかったのですが、情報がさっぱりなくて冒頭で書いたように同じようにDRFとReactでアプリを作ってHerokuに上げたという記事を書かれていたGtca様にダメ元でお願いして事なきを得ました。
結論からいうとDjangoのindex.htmlにReactを読み込ませるといったような形でデプロイをすることになりました。
以下ファイルです。
折りたたみ
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.6.2/css/bulma.min.css">
<title>Mix up Our Todo</title>
</head>
<body>
<!-- ここにReactが入る -->
<div id="root">
<!-- React -->
</div>
</body>
<!-- Reactでbuildしたものを読み込む -->
{% load static %}
<script src="{% static "frontend/main.js" %}"></script>
</html>
上記は404のエラーページも同様の記述にしてあります。
from django.urls import re_path ,path
from . import views
# Reactのルーティングをそのままこちらに持ってきて紐付けている。
# re_pathの部分はReactにおいて:idや:usernameを使っている部分の表現がパスコンバーターではできないのでこちらを使っています。
urlpatterns = [
path('', views.index, name='index_page'),
path('login/', views.index, name='other_page'),
path('signup/', views.index, name='other_page'),
path('logout/', views.index, name='other_page'),
path('todo/top/', views.index, name='other_page'),
path('todo/list/', views.index, name='other_page'),
re_path(r'^todo/list/[^/]+/$', views.index, name='other_page'),
re_path(r'^todo/delete/[0-9]+/$', views.index, name='other_page'),
re_path(r'^todo/edit/[0-9]+/$', views.index, name='other_page'),
re_path(r'^todo/timer/[0-9]+/$', views.index, name='other_page'),
path('user_info/', views.index, name='other_page'),
path('password_change/', views.index, name='other_page'),
path('unsubscribe/', views.index, name='other_page'),
path('user_group/top/', views.index, name='other_page'),
re_path(r'^user_group/edit/[0-9]+/$', views.index, name='other_page'),
re_path(r'^user_group/delete/[0-9]+/$', views.index, name='other_page'),
re_path(r'^user_group/[0-9]+/members/$', views.index, name='other_page'),
path('user_group/joined/', views.index, name='other_page'),
re_path(r'^user_group/list/[^/]+/$', views.index, name='other_page'),
]
上記はReactで直にURLを叩いた場合にReact側のルーティングに飛ぶようにするための設定です。
React側は以下のようになっています。
import React from "react";
import { Switch, Route, Redirect } from "react-router-dom";
// カスタムルーティング
import PrivateRoute from "./Route/PrivateRoute";
import LoginRoute from "./Route/LoginRoute";
import LogoutRoute from "./Route/LogoutRoute";
// ランディング
import TopPage from "./UserComponents/TopPage";
// ユーザーに関わるルーティング
import User from "./UserComponents/UserPage";
import ChangePassword from "./UserComponents/ChangePassword";
import Unsubscribe from "./UserComponents/Unsubscribe";
import Login from "./UserComponents/LoginFormContainer";
import Logout from "./UserComponents/LogoutForm";
import Register from "./UserComponents/RegisterFormLayout";
// グループに関わるルーティング
import Group from "./GroupComponents/Group";
import GroupJoined from "./GroupComponents/GroupJoined";
import GroupEdit from "./GroupComponents/GroupEdit";
import Group_Public from "./GroupComponents/GroupPublic";
import Group_Detail_Public from "./GroupComponents/GroupDetail_Readonly";
// Todoに関わるルーティング
import Todo from "./TodoComponents/todo";
import Todo_Public from "./TodoComponents/todo_Public";
import TodoDelete from "./TodoComponents/TodoDelete";
import TodoEdit from "./TodoComponents/TodoEdit";
import TaskTimer from "./TodoComponents/TaskTimer";
// 404 error
import NoMatch from "./UserComponents/Nomatch.js"
const MainContent = () => (
<Switch>
<Route path="/" exact>
<TopPage />
</Route>
<LoginRoute path="/login" component={Login} />
<LoginRoute path="/signup" component={Register} />
<LogoutRoute path="/logout" component={Logout} />
<PrivateRoute path="/todo/top" component={Todo} />
<PrivateRoute path="/todo/list/:username" component={Todo_Public} />
<PrivateRoute path="/todo/delete/:id" component={TodoDelete} />
<PrivateRoute path="/todo/edit/:id" component={TodoEdit} />
<PrivateRoute path="/todo/timer/:id" component={TaskTimer} />
<PrivateRoute path="/user_info" component={User} />
<PrivateRoute path="/password_change" component={ChangePassword} />
<PrivateRoute path="/unsubscribe" component={Unsubscribe} />
<PrivateRoute path="/user_group/top" component={Group} />
<PrivateRoute path="/user_group/joined" component={GroupJoined} />
<PrivateRoute path="/user_group/edit/:id" component={GroupEdit} />
<PrivateRoute path="/user_group/list/:username" component={Group_Public} />
<PrivateRoute
path="/user_group/:id/members"
component={Group_Detail_Public}
/>
<Route component={NoMatch}></Route>
{/* <Redirect to="/" /> */}
</Switch>
);
export default MainContent;
反省・制作を振り返って
設計の甘さと難しさ
実は設計したときはReactの前提知識がチュートリアル程度だったのですが、私が実際に何か作業しないとものが頭に入らないのでそのまま進めたのですが、結果として
-
React Hooksを使うのかRedux Hooksを使うのか
-
それらに伴うコンポーネントの構成をどうするのか
↓ 例えばフォームであるならばフォームのパーツをどこまで分割して組み立てるのか
-
上記2点を考えながらもuseCallback、useEffect、useMemoなどを使った描画の最適化を図るか
といった3点がReactの肝になってくるのではないか? ということを把握できていない状態で作業を始めてしまいました。
よって全体的に見てあまりスマートではない設計になったこととuseEffectを中心とした、再描画の最適化がうまく言ってるとは言えないと個人的に見ても思います。
実際にフロントを作ろう! となった瞬間にどうするべきかわからずアドバイスを受けてReact Hookのチュートリアルやり、その上でRedux Toolkitを見つけて海外のフォーラムやドキュメント等々見ながらようやく作業を始められたという経緯もありました。
個人的にはどこまでチュートリアルをやるべきかというのは「実際に自分でなにか作ってみないとわからないこともある」という点から非常に難しい問題です。
Django側は関数Viewで実装した部分などを含めて、リクエストに応じて処理をわけるとかSerializerの処理をわける……といったことをこれからはやっていってもう少しシンプルに書いていきたいというのを感じました。
そのためにはもう少しAPIViewやSerializerについてドキュメントなりソースなりを読み解いていかないといけません。
認証全般、特にソーシャルログインとそれに伴う認証の実装の失敗
過去にもこういう記事を書いているように、認証周りはいつも私にとって難敵なのですが、今回もやはりそうなりました。
今回初めてDRFとReactというフロントとサーバーに分けて開発をする(いわゆるSPAの制作)ということをしたというのは先にも書いた通りなのですが、その場合はなんとフロントとサーバー側で認証の橋渡しをしないといけないということに作業中に気づくわけです。
認証なんてフレームワークがいい感じにやってくれるよな~と思っていたのでこれにはだいぶ頭を悩ませました。
どうしたものかと調べたり(使ってる認証ライブラリのリポジトリにフォーラムで議論されてるところまで見ました)、相談した結果
-
トークン認証と今までのようにセッションで認証するという2パターンある
-
どちらにもリスクはあるが、SPAでの認証として広く紹介されているトークンをLocal Strageに入れるやり方は一番よろしくないらしい
-
Set-Cookie, httpOnly:true, secure:trueにしたトークンとCSRFトークンを送りつけるのがベターそう……
という結論に至り、実際そこまで設定したのですがDjangoでどうしても上記のトークン(開発中はsecure:false)を受け付けてくれず、手詰まりになってしまったので結局はSessionで認証をすることになりました(JWTトークンはライブラリの仕様で発行はされている)
また個人的にはトークンとセッション認証の併用もやりたかったのですが、上記の通りトークン周りで問題が解決できなかったのでお蔵入りとなりました……本当に認証は難しい。
また仕様変更についての項目でも書いたようにソーシャルログインでも失敗しています。
このあたりの問題はFirebaseに認証を丸投げしてしまえばいいという知見をTwitterでのアドバイス及び調べていた中でも得られたので、習得しようかなと強く思いました。
開発環境から本番環境に移した際のチューニング
仮想環境化ではそこまででもなかったのですが、本番環境で動かすとレスポンスの悪さを感じ、実際に試しに触ってもらった方にも指摘を受けました。
これはおそらくHerokuで全部動かしていることに加えて、やはりReactでbuildしたものが重すぎるのが原因だろうと踏んでいるので前述の最適化ができていないというのは大きいなということを感じました。
テストとデバック
ノウハウがないので未だにできていないところ……早くここまでできるようになりたいです。
コンテンツに動きが足りない
今回はReactに慣れていないのもありますが、Material-UIを使えなかったことに加えてコンテンツに動きを出す(例えばドラックでタスクを並び替えたりすることができるとか)ということをできなかったのはかなり痛いなと感じました。
前者はスピナーを使うのに自分で手作りしないといけませんでしたし、後者に関してはデプロイした! よかったぁとなった瞬間にTL上に今回私が作ったものの上位互換のようなものを作りました! というようなツイートが来てがっくし来たのもあってすごく悔しいです……
最後に
以上が今回作ったものについてのまとめになります。
上記の挙げただけでも課題は山程あり、コード見れば粗だらけ……となりますが、それでも企画で得たノウハウを少しでも活かして力押しは多々ありますが、何度も恐縮ですが、本当にDRFとReactでの開発については情報がなく、海外のフォーラム・記事・はてはライブラリのソースやリポジトリのフォーラムまで探して情報を集めていって、なんとか自分が設計したものに対して一つずつ実装し、形にはできたところだけは自分を褒めていいのかなと思いました。
今回で扱ったことについてはいくつか掘り下げないとなということも沢山あるので、その際はまたアウトプット記事を書けたらなと思います。
お暇なときで結構ですのでちらっとでも触っていただけると嬉しいです。
あと、プロフィール各所にあるように現在エンジニアとして就職を目指しています。
今回に限らず、この1年色々やって記事にアウトプットもして、0から勉強して企画に参加してみたりと自走力と意欲に関しては自信を持ってアピールできると思います。
もし、興味を持たれた方がいらっしゃいましたらTwitter等々からご連絡頂けるととても嬉しいです。
このアプリを作るにあたって事前にやったこと
資料集
参考資料
HookとRedux ToolkitでReact Reduxに入門する
404 page not found using Django + react-router
データベースを使う Django を Heroku にデプロイ
Django React アプリケーションの URL をマッピングする
React+axios+Material UIでスピナーとメッセージを表示する
axios、async/awaitを使ったHTTPリクエスト(Web APIを実行)
[Django REST Framework] Serializer の 使い方 をまとめてみた
【useState/ReactRedux】Reactにおける状態管理
DjangoのページをReactで作る - Webpack4
JavaScript axiosをasync、awaitとtry、catch、finallyで制御する
SPAのログイン認証のベストプラクティスがわからなかったのでわりと網羅的に研究してみた〜JWT or Session どっち?〜