2
3

More than 1 year has passed since last update.

WEBでLINE風のチャットサイトを作る-その2

Last updated at Posted at 2019-11-03

はじめに

 前回作成した基本的なインターフェースを拡充してチャットアプリとしての体裁を整えたいと思います。
 今回追加する機能は、ログイン機能とユーザー登録機能、簡易的なセキュリティ対策、スタンプ機能、画像アップロード機能です。
  

環境構築

前回に引き続きお手軽なクラウドサービスを使って環境構築を行います。

Paiza Cloud

paiza表紙.png
Paiza Cloudeにアクセスしてメールアドレスを登録すると、すぐに環境構築ができるようになります。

リンク先
https://paiza.cloud/ja/

サーバー作成

アカウントを作成したらサーバー作成ボタンを押しましょう
まだサーバーはありません.png
新規サーバー作成のポップアップで、Node.jsとMongoDBを選択してください。
サーバー設定.png
数秒間待っているとサーバー環境ができあがります。
ちなみに無料プランの場合は

  • サーバーの最長利用時間は24時間
  • サービスは外部へ公開されない
    逆に言うと練習にはもってこいという事でしょうか。

アプリケーション構築

次に各種インストールを行い、アプリケーションの実行環境を構築します。
まずは画面からターミナルのアイコンをクリックしてください。

s_ターミナル.png

起動したターミナルに下記のコマンドを入れてgitからファイルを展開します。

git clone https://github.com/nstshirotays/chatapp-shot2.git

下記のようにディレクトリが作成されソースが展開されます。
git2.png

つぎにディレクトリを移動し、必要なパッケージを導入します。

cd chatapp-shot2
npm install

実行に必要なモジュールなどがpackage.jsonに従って自動的にインストールされます。

以上で必要な準備が整いましたので、あとはnodejsを起動してアプリを立ち上げます。

npm start

git3.png

エラーがでなければ、左側に緑色のブラウザアイコンが新しく点滅し始めます。
s_ブラウザ3000.png

このアイコンをクリックするとアプリが起動します。

アプリ実行

ログイン画面

まずはログイン画面です。
login.png
初回は誰も登録されていないので、Create an Account を押してユーザー登録画面に移ります。

ユーザー登録画面

NickNameとPassCodeを入れてユーザーを登録しましょう。
NickNameは英文字で4から12文字。PassCodeは数字で6から12文字です。
regisit.png

お好みでFaceIconを変更(png 32kbまで)できます。

ユーザーを登録したら実際にログインしてみましょう。

友達選択画面

list.png
登録されている自分以外の友達が一覧で表示されます。今回もデフォルトでEchoさんが登録されています。友達を選択すると会話画面に遷移します。

会話画面

ベースとなる会話画面です。前回からスタンプと画像アップロード機能が追加されています。
chatapp.png
Echoさんはこちらの会話に相槌を打ってくれるチャットボットです。

スタンプ画面

スタンプボタンを押すと一覧が表示されます。
stamp.png
今回はクリスマススタンプを入れてみました。
お好きなpngを public/files/stampsに入れてください。

アイコンネタ元 speckyboy.com

(ちなみにEchoさんはスタンプをもらうとそのスタンプ名を言ってくれる仕様にしました)

stam2p.png

画像アップロード

今回は画像アップロード機能を加えています。画像はjpegのみで、サイズは1000×1000以下です。
image1.png

残念ですが現時点ではEchoさんは画像の内容を認識できません。次回あたりにチャレンジしたいと思います。


アプリケーション解説

 画面も増えましたので前回のソースをnode.jsのアプリケーションフレームワークであるExpressで再構成しました。
 このためディレクトリ構造は下記のようになっています。

   |-helper       共通機能系
   |-models       データモデル系
   |-public
   |---files
   |-----stamps     スタンプの場所
   |---images
   |---javascripts
   |---stylesheets
   |-routes       get,postで呼ばれるjavascript
   |-views        html

参考にした記事
[Express + Node.jsで基本を理解した次の一歩 - ディレクトリ構成をルーティング・ミドルウェアを理解して考えてみる] (https://qiita.com/nkjm/items/2016e331f74f1b8ab465)

コード解説

それでは今回加えた主なソースを解説していきます。チャット画面については前回とほぼ同様ですので割愛します。

app.js

メインのプログラムです。前回はserver.jsとして実装しました。今回はexpressのアプリケーション自動生成(generator)機能を使ったのでこの生成されたapp.jsに各画面の呼び出しを加えています。

参考サイト 初心者のための Node.jsプログラミング入門

app.js
var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');
var mongoose = require('mongoose');

// ルーティング処理の呼び出し先を追加
var loginRouter    = require('./routes/login');
var registerRouter = require('./routes/register');
var listRouter     = require('./routes/list');
var chatRouter     = require('./routes/chatapp');
var errRouter      = require('./routes/errorpage');
var apiRouter      = require('./routes/api');
var logoutRouter   = require('./routes/logout');

var app = express();

// 変数宣言
var MyID = "";
var MyName ="";
var FrID = "";
var FrName ="";
var botTimer;

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');

// ファイルアップロードに関する拡張
app.use(logger('dev'));
//app.use(express.json());
//app.use(express.urlencoded({ extended: false }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(express.json({ extended: true, limit: '10mb' }));

app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

// ルーティング処理の登録
app.use('/', loginRouter);
app.use('/auth', loginRouter);
app.use('/register', registerRouter);
app.use('/home', listRouter);
app.use('/chat', chatRouter);
app.use('/errorpage', errRouter);
app.use('/api/messages', apiRouter);
app.use('/logout', logoutRouter);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
  next(createError(404));
});

// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get('env') === 'development' ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render('error');
});

module.exports = app;

基本的にはクライアントからのGETやPOSTに対して応答する処理(ルーティング)を登録しています。requireでルーティング処理が書かれたJavascriptを変数に登録し、それをapp.useでクライアントからのURLに紐づけています。

処理全体で利用する変数として自分と相手のID,Nameを変数として宣言しています。また、チャットボットの本体となるタイマー起動処理用の変数もここで宣言しています。本来であれば、別ファイルにしてexportsでオブジェクト風に見せるのがお作法かとも思うのですが、シンプルに直接宣言したほうがわかりやすいかと思ってこっちにしました。

今回はファイルのアップロードがあるためpostデータが大きくなり、そのままでは413エラー(request entity too large)となってしまいます。このための設定としてオプション値を設定しています。

参考にした記事
[Express4でエラー「request entity too large」が発生する] (https://qiita.com/PianoScoreJP/items/3fbcebc43ebf821074d8)

login.js ログイン処理

login.js
var express = require('express');
var router = express.Router();
var { check, validationResult } = require('express-validator');
var sanitize = require('mongo-sanitize');
var db = require('../helper/db');
var User = db.User;
var bcrypt = require('bcryptjs');


var jsonwebtoken  =  require('jsonwebtoken');
const config = require('../config');

//--------------------------------------------------------
// ログイン画面の表示
//--------------------------------------------------------
/* GET home page. */
router.get('/', function(req, res, next) {
	res.clearCookie("auth");
	res.render('login', {error: false, errors:false});
});


//--------------------------------------------------------
// ログイン処理
//--------------------------------------------------------
//ユーザ認証
router.post('/',[check('nickName', 'ニックネームを入力して下さい').not().isEmpty().trim().escape().customSanitizer(value => {
  // MongoDB Operator Injectionを防ぐために、ユーザー提供のデータをサニタイズします
  value = value.replace(/[$.]/g, "");
  return value;
}),
  check('passCode', 'パスコードを入力して下さい').not().isEmpty().trim().escape(),
], (req, res) => {
	
		var errors = validationResult(req);
		//検証エラー
		if (!errors.isEmpty()) {
			res.render('register.ejs',{data: req.body, errors: errors.mapped() });
		}
		
		const  nickName  =  sanitize(req.body.nickName);
		const  passcode  =  sanitize(req.body.passCode);
	
		User.findOne({nickName}, (err, user)=>{
        if (err) return  res.status(500).send(err);
		
        if (!user) return  res.render('login.ejs',  {error: 'ユーザーが見つかりません', errors:false});
		
		//パスコードチェック
        bcrypt.compare(passcode, user.passCode, function(err, result) {
			
        if(!result) return  res.render('login.ejs',  {error: 'パスコードが違います', errors:false});
        //JSON Webトークンを生成する,トークンの有効期限を15分に設定
		const  expiresIn  =  900;
        const  accessToken  =  jsonwebtoken.sign({id : user._id, name :  user.nickName }, config.secret, {
            expiresIn:  expiresIn
        });
		//トークンをクッキーに保存する
		res.cookie('auth',accessToken , { maxAge: 900000, httpOnly: true });
		MyID = user._id;
		MyName = user.nickName;

        res.redirect('/home');
    });
 });
});

module.exports = router;

login.jsは冒頭の宣言部分と、クライアントからのGET処理とPOST処理の3パートで構成されています。

GET処理ではクッキーをクリアして、res.renderでログインフォームをレンダリングしています。それだけです。

POSTの方は実際のログイン処理を実施しています。
router.post( URL、処理1、処理2、処理3・・・)
という感じでポスト後の処理を書いています。
まずはニックネームとパスワードの未入力をチェックしたあと、実際のDBへ接続してユーザーの有無を問い合わせています。DBへの接続についてはいわゆるSQLインジェクションという攻撃への備えが必要です。mongodbはSQLデーターベースではありませんが、やはり検索文字列に特殊なコードを入れると悪意のあるコードが実行されてしまいます。このためニックネームとパスワードについてはサニタイズ処理をしています。これはmongo-sanitizeというパッケージを利用しています。

参考サイト: HACKING NODEJS AND MONGODB

ニックネームとパスワードが一致すると、ユーザーIDとNameをセットしたクッキーが発行されます。これ以降はこのクッキーが認証済みの証となります。
ということで、クッキーが改ざんされても判別できるようにここではJson Web Token という仕様を使ってクッキーを署名付きにします。

参考記事: NodeJS + MongoDB - Simple API for Authentication, Registration and User Management
参考記事: JSON Web Token の効用

login.ejs ログイン画面

login.ejs
<!doctype html>
<html>
	<head>
		<meta charset="utf-8">
	    <meta name="viewport" content="width=device-width, initial-scale=1">
		<title>Login</title>
		<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
	    <link rel="stylesheet" type="text/css" href="/stylesheets/style.css">
	</head>
	<body>
	    <div class="container">
    		<div class="row ">
    		    <div class="col-xs-1 col-sm-2 col-md-3 col-lg-4"></div>
        		<div class="col-xs-10 col-sm-8 col-md-6 col-lg-4">
            		<h1>Chat App</h1>
            		<form method="post" action="auth">
                		<div class="form-group">
                    		<% if (error) { %>
                    		<div class="text-danger" style="width: 90%;margin:5%;"><%= error %></div>
                    		<% } %>
                    		<% if (errors.nickName) { %>
                    		<div class="text-danger" style="width: 90%;margin:5%;"><%= errors.nickName.msg %></div>
                    		<% } %>
                    		<% if (errors.passCode) { %>
                    		<div class="text-danger" style="width: 90%;margin:5%;"><%= errors.passCode.msg %></div>
                    		<% } %>
                    		<input type="text" class="form-control" style="width: 90%;margin:5%;" id="nickName" name="nickName" placeholder="NickName" required>
                    		<input type="password" class="form-control" style="width: 90%;margin:5%;" id="passCode" name="passCode" placeholder="PassCode" required>
                    		<button type="submit" class="btn" style="width: 90%;margin:5%;">Log in</button>
                    		<center><a href="register" >Create an account</a></center>
                		</div>
            		</form>
    		    </div>
    		</div>
    	</div>
	</body>
</html>

 今回は画面をレスポンシブにするためにbootstrapのグリッドシステムを利用しています。
 このシステムは全体を12の列に分け、画面の解像度に応じて利用する列数を変更することで、一定の見栄えを維持するものです。
 LINE風ということで、スマホの縦長画面をイメージしたいので、PC画面やタブレット画面では左右にマージンを置きたいと考えました。本来であればoffset指定でできるはずですが、上手くいかなかったので、空のカラムdivを挟んであります。
 また、スマホなどの高解像度換算表示をさせないために、メタタグとしてwidth=device-widthを指定しています。

参考サイト: Bootstrap3の使い方

register.js ユーザー登録処理

ログイン画面から「Create an account」を選択すると表示されます。

register.js
var express = require('express');
var router = express.Router();
var { check, validationResult } = require('express-validator');
var db = require('../helper/db');
var User = db.User;
const userService = require('../models/user.service');

//--------------------------------------------------------
// ユーザー登録画面の表示
//--------------------------------------------------------
/* GET home page. */
router.get('/', function(req, res, next) {
	res.clearCookie("auth");
	res.render('register', {data: req.body, error: false, errors:false});
});

//--------------------------------------------------------
// ユーザー登録処理
//--------------------------------------------------------
router.post('/', [
  check('nickName', 'Nick name は英文字のみです').not().isEmpty().isAlpha().trim().escape(),
  check('passCode', 'Pass code は数字のみです').not().isEmpty().isAlphanumeric().trim().escape(),
  check('nickName', 'Nick name は4文字以上12文字までです.').not().isEmpty().isLength({min: 4, max: 12}).trim().escape(),
  check('passCode', 'Pass code は6文字以上12文字までです.').not().isEmpty().isLength({min: 6, max: 12}).trim().escape(),
  check('nickName').custom(value => {
	// MongoDB Operator Injectionを防ぐために、ユーザー提供のデータをサニタイズします
	value = value.replace(/[$.]/g, "");
	
	//ニックネームを検証する
    return User.findOne({'nickName': value}).then(user => {
      if (user) {
        return Promise.reject(user['nickName'] + 'さんは登録済みです.');
      }
    });
  })
], (req, res) => {
	
	  var errors = validationResult(req);
	//検証エラー
  if (!errors.isEmpty()) {
     res.render('register.ejs',{data: req.body, errors: errors.mapped() });
  }else{
		//エラーなし, データベースにユーザー情報を保存する	  
		userService.create(req.body);
     
		res.redirect('/');
		
  }
  
});


module.exports = router;

 ここもGET処理は単にユーザー登録画面をレンダリングするだけです。POST処理でユーザー登録をしています。
 この際にexpress-validatorを使って文字種別や文字長の検査をしています。そしてDBを確認して既登録がなければ登録を行います。
 実際の登録はuserService = require('../models/user.service');で指定されたソースで行っています。

共通関数:ユーザー登録処理(user.service.js)

/models/user.service.js
// ユーザー登録操作
var bcrypt = require('bcryptjs');

const db = require('../helper/db');
const User = db.User;
const saltRounds = 10;

module.exports = {
     create
 };

// ユーザーモデルを作成し、データベースに保存する
function create(userParam) {

    const user = new User();
	user.nickName = userParam.nickName;
	// 画像はオプションです。デフォルト画像を使用して選択されていません
	if(userParam.ufile){
		user.userImage=userParam.ufile;

	}
    if (userParam.passCode) {
		// ハッシュパスコードを保存する
        user.passCode = bcrypt.hashSync(userParam.passCode, bcrypt.genSaltSync(saltRounds));
    }
    // save user
    user.save();

}

パスワードはbcryptを使って10多重でハッシュ化しています。

参考記事: BCryptのすすめ
参考サイト: 本当は怖いパスワードの話 (1/4)

register.ejs ユーザー登録画面

register.ejs
<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
	    <meta name="viewport" content="width=device-width, initial-scale=1">
		<title>Register</title>
		<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
	    <link rel="stylesheet" type="text/css" href="/stylesheets/style.css">
   	    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
	</head>
	<body>
		<form method="post" action="register" name="register-form" id="register-form">
        	<div class="form-group">
        	    <div class="container">
            		<div class="row ">
            		    <div class="col-xs-12 col-sm-12 col-md-12 col-lg-12">
                    		<h1>Chat App</h1>
                    	</div>
                	</div>
                	
            		<div class="row">
            		    <div class="                  col-md-2 col-lg-3"></div>
    		            <div class="col-xs-8 col-sm-8 col-md-5 col-lg-4">
                    		<label for="nickName" class="col-form-label">NickName</label>
                    		<input type="text" class="form-control" id="nickName" name ="nickName" placeholder="Nickname"  maxlength="12" required>
                    		<% if (errors.nickName) { %>
                		    <div class="text-danger"><%= errors.nickName.msg %></div>
                    		<% } %>
                    		<label for="passCode" class="col-form-label">PassCode</label>
                    		<input type="password" class="form-control" id="passCode" name="passCode" placeholder="Passcode" maxlength="12" required>
                    		<% if (errors.passCode) { %>
                    		<div class="text-danger"><%= errors.passCode.msg %></div>
                    		<% } %>
                        </div>
                        
    		            <div class="col-xs-4 col-sm-4 col-md-3 col-lg-2">
                    		<label for="FaceIcon" class="col-form-label">FaceIcon</label>
                    		<img src="/images/defaultFace.png" class="image" id="image-frame" height="50pv" width="50pv"/>
                    		<input id="imageFile" type="file" style="visibility:hidden" name="imageFile"/>
                    		<input type="button" style="width: 100%;" value="Change" onclick="$('#imageFile').click();" class="btn" name="imagePath"/>
                        	<input id="b64" name="ufile" type="hidden" value=""/>
                    		<div class="text-danger" id="error"></div>
                        </div>
            		</div>
    
            		<div class="row">
            		    <div class="                    col-md-2 col-lg-3"></div>
            		    <div class="col-xs-12 col-sm-12 col-md-8 col-lg-6">
            		        </br>
                    		<button type="submit" style="width: 100%;" class="btn rgst">Create</button>
                    	</div>
                	</div>
            	</div>
            </div>
		</form>
    </body>
	<script>
   /*$("#imageFile").change(function(){
        readURL(this);
    });
	function readURL(input) {
        if (input.files && input.files[0]) {
            var reader = new FileReader();
            
            reader.onload = function (e) {
			console.log(e.target.result);
                $('#imgsrc').attr('src', e.target.result);
            }
            
            reader.readAsDataURL(input.files[0]);
        }
    }*/
	showImage(true);
	var targetfile = null;
	$("#imageFile").onchange = function(evt){
	$("#error").innerHTML = '';
		showImage(true);
		var files = evt.target.files;
		if(files.length == 0) return;
		targetFile = files[0];
		console.log(targetFile);
		if(!targetFile.type.match(/image/)) {
		$("#error").innerHTML ='Select Image File';
			return;
		}
			
		if(targetFile.size > 35000){
		$("#error").innerHTML ='Image file size should be less than 35KB';
			return;
		}
		var breader = new FileReader();
	    breader.onload = readPNGFile;
		breader.readAsBinaryString(targetFile);
	}
	
	function readPNGFile(evt) {
		var bin = evt.target.result;
		var sig = String.fromCharCode( 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a);
		var head = bin.substr(0, 8);
		if(sig != head) {
			$("#error").innerHTML ="Image file type should be PNG";
			return;
		}
		showImage(true);
		var width = getBinValue(bin, 8 + 0x08, 4);
		var height = getBinValue(bin, 8 + 0x0c, 4);
		var depth = bin.charCodeAt(8 + 0x10);
		
		/*$("#info").innerHTML =
			"width: " + width + "px<br>" +
			"height: " + height + "px<br>" +
			"depth: " + depth + "bit";*/
		var reader = new FileReader();
		reader.onload = function(e) {
		console.log(reader);
			$("#image-frame").src = reader.result;
			$("#b64").value=reader.result;
		}
		reader.readAsDataURL(targetFile)
	}
	
	function getBinValue(bin, i, size) {
		var v = 0;
		for(var j= 0; j < size; j++){
			var b = bin.charCodeAt(i + j);
			v = (v << 8) + b;
		}
		return v;
	}
	
	function showImage(b) {
		var val = b ? "block" : "none";
		//$("#upbtn").style.display = val;
		console.log("val",val);
		$("#image-frame").style.display = val;
		//$("#info").style.display = val;
	}
	
	function $(id) {
		return document.querySelector(id);
	}
</script>
</html>

ここではpngファイルの選択と表示を行っています。

参考サイト: HTML5のFile APIでローカルファイル情報取得してやんよ!!!

list.js 友達リスト処理

list.js
var express = require('express');
var router = express.Router();
var db = require('../helper/db');
var User = db.User;
const verifyToken = require('../helper/VerifyToken');

//--------------------------------------------------------
// 友達リスト画面の出力
//--------------------------------------------------------
router.get('/',verifyToken, function(req, res, next) {
	var users = [];
	// ログインしたユーザーを除くすべての登録ユーザーをデータベースから取得し、ユーザー名とプロファイル画像のjsonオブジェクトを作成します
	User.find({ nickName: {$ne: req.name}}).stream().on('data', function(doc) {
	  var base64Data;
		
	  if(doc.userImage !== undefined){
		  
		base64Data = doc.userImage.replace(/^data:image\/png;base64,/, "")
		
	  }
		users.push({id : doc._id, nickName : doc.nickName, userImage: base64Data });
		
	 }).on('error', function(err){
		res.send(err);
	})
	.on('end', function(){
	
    res.render('list.ejs', {listUsers : users });

  });
        
});


module.exports = router;

データベースから自分以外の友達を検索し、その一覧を引数としてリスト画面のレンダリング処理を呼び出しています。

ちなみに、冒頭のrouter.get('/',verifyToken, function(req, res, next) { に書かれている verifyTokenが前述のJson Web Tokenの検証処理です。

共通関数:JSON Web Tokenの処理(VerifyToken.js)

/helper/VerifyToken.js
// Json Web Tokenのチェック処理
var jwt = require('jsonwebtoken');
const config = require('../config');
var db = require('../helper/db');
var Waste = db.Waste;


function verifyToken(req, res, next) {
    var token = req.cookies.auth;
    if (!token) {
        return res.status(403).render('errorpage.ejs', { error: '15分間無操作のためログアウトしました', errors: false });
    }

    // 破棄済みトークンを検索
	Waste.findOne({'cookie': token}).then(data => {
        if (data) {
            return res.status(403).render('errorpage.ejs', { error: '不正なトークンです', errors: false });
		}
	});
	
    jwt.verify(token, config.secret, function (err, decoded) {
        if (err) {
            return res.status(500).render('errorpage.ejs', { error: 'Failed to authenticate token.', errors: false });
        } else {
            // すべてうまくいけば、他のルートで使用するために保存して次へ
            req.name = decoded.name;
            req.id = decoded.id;
            next();
        }
    });
}

module.exports = verifyToken;

 JWTが期限切れ(15分)もしくは不正なトークンの場合はエラーを返しています。
 また、ここではログアウト時に登録された破棄済みのクッキーと照合することによりクッキーの使い回しを防御しています。

最後に

いかがだったでしょうか。ちょっと前回から間が空いてしまいましたが、一応チャットサイトとして使えるようになったかと思います。パスワード変更ができないとか、エラー処理が中途半端とか、ログ機能がないとか、色々と細かくは実装していませんが友人同士での遊び程度では利用できるかと思っています。

Special Thanks

今回のソースはRupaliさんの支援を受けています。ありがとうございます。

次回は...

 次回はこのチャットサイトからGoogle Dialogflowを呼び出して、本格的なチャットボットを実現してみたいと思います。
 最終的には自然言語処理や画像認識、ブロックチェーンなんかにも足を伸ばせればと思ってます。

記事一覧

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3