著者:しょっさん
ソフトウエアを正しく作るために、エンジニアたちはどんなことを知らなければならないでしょうか。実際のコードを使って、より良くしていくためのステップを考えてみましょう。第3回は、前回のプロジェクトの計画や方針に基づいて2回の開発サイクル(イテレーション)を回します。
シェルスクリプトマガジン Vol.63は以下のリンク先でご購入できます。
図4 TypeScriptに書き換えたメインのプログラム(index.ts)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
import Express from 'express'; const app = Express(); import { Expense } from './models/expense'; import bodyParser from 'body-parser'; import cookieParser from 'cookie-parser'; import session from 'express-session'; const users = { 'user01': 'p@ssw0rd', 'user02': 'ewiojfsad' }; app.use(cookieParser()); app.use(bodyParser.urlencoded({ extended: false })); app.use(session({ secret: 'secret', resave: false, saveUninitialized: false, cookie: { maxAge: 24 * 30 * 60 * 1000 } })); app.get('/login', (req: Express.Request, res: Express.Response): void => { res.send('<h1>LOGIN</h1><form action="/login" method="post">ユーザーID:<input type="text" name="user" size="40"><br />パスワード<input type="password" name="password"><input type="submit" value="ログイン">'); }); app.post('/login', (req: Express.Request, res: Express.Response): void => { if (eval("users." + req.body.user) === req.body.password) { if (req.session) { req.session.user = req.body.user; } } res.redirect('/'); }); app.post('/expense', (req: Express.Request, res: Express.Response): void => { Expense.create(req.body) .then(() => { res.redirect('/'); }); }); app.get('/', (req: Express.Request, res: Express.Response): void => { const user = req!.session!.user || '名無しの権兵衛'; res.writeHead(200, { "Content-Type": "text/html" }); res.write(`<h1>Hello ${user}</h1><table><tr><th>ID</th><th>申請者名</th><th>日付</th><th>経費タイプ</th><th>経費詳細</th><th>金額</th></tr>`); Expense.findAll() .then(results => { for (let i in results) { res.write(`<tr><td>{results[i].id}</td><td>{results[i].user_name}</td><td>{results[i].date}</td><td>{results[i].type}</td><td>{results[i].description}</td><td>{results[i].amount}</td></tr>`); } res.write('</table><a href="/login">ログイン</a><a href="/submit">経費入力</a>'); res.end(); }); }); app.get('/submit', (req: Express.Request, res: Express.Response): void => { const user = req!.session!.user || '名無しの権兵衛'; res.send(`<h2>経費入力</h2><form action="/expense" method="post">申請者名:<input type="text" name="user_name" value="${user}"><br />日付:<input type="date" name="date"><br />経費タイプ:<input type="text" name="type"><br />経費詳細:<input type="text" name="description"><br />金額:<input type="number" name="amount"><br /><input type="submit" value="経費申請">`); }); const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`http://localhost:${port}`); }) export default app; |
図5 TypeScriptで作成した自作のモデルファイル(expense.ts)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
import { Sequelize, Model, DataTypes } from 'sequelize'; // todo: データベース接続を定義する Typescript モジュール const env = process.env.NODE_ENV || 'development'; const config = require(__dirname + '/../config/config.json')[env]; let sequelize; if (config.use_env_variable) { const config_url: any = process.env[config.use_env_variable]; sequelize = new Sequelize(config_url, config); } else { sequelize = new Sequelize(config.database, config.username, config.password, config); } class Expense extends Model { public id!: number; public user_name!: string; public date!: Date; public type!: string; public description!: string | null; public amount!: number; public readonly careated_at!: Date; public readonly updated_at!: Date; } Expense.init({ id: { type: DataTypes.INTEGER.UNSIGNED, autoIncrement: true, allowNull: false, primaryKey: true, }, user_name: { type: DataTypes.STRING(256), allowNull: false, defaultValue: '' }, date: { type: DataTypes.DATE, allowNull: false }, type: { type: DataTypes.STRING(256), allowNull: false }, description: { type: DataTypes.TEXT, }, amount: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false } }, { tableName: 'expenses', underscored: true, sequelize: sequelize }); export { Expense }; |
図6 トランスパイルオプションファイル(tsconfig.json)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
{ "compilerOptions": { "target": "es2018", "module": "commonjs", "lib": [ "es2018" ], "sourceMap": true, "outDir": "./dist", "strict": true, "moduleResolution": "node", "esModuleInterop": true, "experimentalDecorators": true, "emitDecoratorMetadata": true }, "include": [ "./src/**/*.ts" ], "exclude": [ "node_modules", "**/*.test.ts" ] } |
図7 gulpfile.jsファイルの内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
const { src, dest, parallel, series } = require('gulp'); const ts = require('gulp-typescript'); const tsconfig = require('./tsconfig.json'); // gulp固有の設定 const config = { output: 'dist/', json: { source: 'src/**/*.json' } }; // typescript のトランスパイルオプション ← tsconfig.json を再利用する const typescript = () => { return src(tsconfig.include) .pipe(ts(tsconfig.compilerOptions)) .pipe(dest(config.output)); }; // json ファイルのアウトプットディレクトリへのコピーを司る指令 const json = () => { return src(config.json.source) .pipe(dest(config.output)); }; // 実行時オプション exports.typescript = typescript; exports.default = series(parallel(typescript, json)); |
図8 jest.config.jsの内容
1 2 3 4 5 |
module.exports = { coverageDirectory: "coverage", preset: 'ts-jest', testEnvironment: "node", }; |
図9 index.test.tsの内容
1 2 3 |
test('1 adds 2 is equal 3', () => { expect(1 + 2).toBe(3); }) |
図11 super.test.tsファイルの内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
import app from '../src'; import request from 'supertest'; describe('Root', () => { it('Root index is valid', async () => { const response = await request(app).get("/"); expect(response.status).toBe(200); }); }); describe('Login', () => { const userCredentials = { user: 'user01', password: 'p@ssw0rd' }; it('login form is varid', async () => { const response = await request(app).get("/login"); expect(response.status).toBe(200); }); it('login with user credential is valid', async () => { const response = await request(app).post("/login") .send(userCredentials); expect(response.status).toBe(302); }) }); describe('submit', () => { it('A submit form is valid', async () => { const response = await request(app).get("/submit"); expect(response.status).toBe(200); }); }); |
図13 .circleci/config.ymlファイルの内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
# Javascript Node CircleCI 2.0 configuration file # # Check https://circleci.com/docs/2.0/language-javascript/ for more details # version: 2 jobs: build: docker: # specify the version you desire here - image: circleci/node:12.10.0 # Specify service dependencies here if necessary # CircleCI maintains a library of pre-built images # documented at https://circleci.com/docs/2.0/circleci-images/ # - image: circleci/mongo:3.4.4 working_directory: ~/repo steps: - checkout # Download and cache dependencies - restore_cache: keys: - v1-dependencies-{{ checksum "package.json" }} # fallback to using the latest cache if no exact match is found - v1-dependencies- - run: npm install - save_cache: paths: - node_modules key: v1-dependencies-{{ checksum "package.json" }} # npm migrate - run: npm run migrate # run tests! - run: npm test |
図14 書き換えたsuper.test.tsの内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
import app from '../src'; import request from 'supertest'; describe('Root', () => { it('Root index is valid', async () => { const response = await request(app).get("/"); expect(response.status).toBe(200); }); }); describe('Login', () => { const userCredentials = { user: 'user01', password: 'p@ssw0rd' }; it('login form is varid', async () => { const response = await request(app).get("/login"); expect(response.status).toBe(200); }); it('login with user credential is valid', async () => { const response = await request(app).post("/login") .send(userCredentials); expect(response.status).toBe(302); }) }); describe('submit', () => { it('A submit form is valid', async () => { const response = await request(app).get("/expenses/submit"); expect(response.status).toBe(200); }); }); describe('payment', () => { it('A payment list is valid', async () => { const response = await request(app).get("/expenses/payment"); expect(response.status).toBe(200); }); }); // エラーテスト describe('/expense', () => { it('This URI is not valid', async () => { const response = await request(app).get("/expense"); expect(response.status).toBe(404); }); }); |
図15 index.tsファイルの内容
1 2 3 4 5 6 7 8 9 10 |
import Express from 'express'; const router = Express.Router(); // GET / 最初に開く画面 router.get('/', (req: Express.Request, res: Express.Response) => { const user = req!.session!.user || '名無しの権兵衛'; res.send(`<h1>Hello ${user}</h1><a href="/login">ログイン</a><br /><a href="/expenses/submit">経費入力</a><br /><a href="/expenses/payment">支払い処理</a>`); }) export default router; |
図16 login.tsファイルの内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import Express from 'express'; const router = Express.Router(); // ユーザー&パスワード const users = { 'user01': 'p@ssw0rd', 'user02': 'ewiojfsad' }; // GET /login ユーザーログインフォーム router.get('/', (req: Express.Request, res: Express.Response): void => { res.send('<h1>LOGIN</h1><form action="/login" method="post">ユーザーID:<input type="text" name="user" size="40"><br />パスワード<input type="password" name="password"><input type="submit" value="ログイン"><br /><a href="/login">ログイン</a><br /><a href="/expenses/submit">経費入力</a><br /><a href="/expenses/payment">支払い処理</a>'); }); // POST / ユーザーの認証処理 router.post('/', (req: Express.Request, res: Express.Response): void => { if (eval("users." + req.body.user) === req.body.password) { if (req.session) { req.session.user = req.body.user; } } res.redirect('/'); }); export default router; |
図17 payment.tsファイルの内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import Express from 'express'; const router = Express.Router(); import { Expense } from '../../models/expense'; // GET /expenses/payment もともと、最初に開かれる画面だった部分 router.get('/', (req: Express.Request, res: Express.Response): void => { const user = req!.session!.user || '名無しの権兵衛'; res.writeHead(200, { "Content-Type": "text/html" }); res.write(`<h1>Hello ${user}</h1><table><tr><th>ID</th><th>申請者名</th><th>日付</th><th>経費タイプ</th><th>経費詳細</th><th>金額</th></tr>`); Expense.findAll() .then(results => { for (let i in results) { res.write(`<tr><td>{results[i].id}</td><td>{results[i].user_name}</td><td>{results[i].date}</td><td>{results[i].type}</td><td>{results[i].description}</td><td>{results[i].amount}</td></tr>`); } res.write('</table><a href="/login">ログイン</a><br /><a href="/expenses/submit">経費入力</a><br /><a href="/expenses/payment">支払い処理</a>'); res.end(); }); }); export default router; |
図18 submit.tsファイルの内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import Express from 'express'; const router = Express.Router(); import { Expense } from '../../models/expense'; // GET /expenses/submit 入力フォーム router.post('/', (req: Express.Request, res: Express.Response): void => { Expense.create(req.body) .then(() => { res.redirect('/'); }); }); // POST /expenses/submit 経費の申請 router.get('/', (req: Express.Request, res: Express.Response): void => { const user = req!.session!.user || '名無しの権兵衛'; res.send(`<h2>経費入力</h2><form action="/expenses/submit" method="post">申請者名:<input type="text" name="user_name" value="${user}"><br />日付:<input type="date" name="date"><br />経費タイプ:<input type="text" name="type"><br />経費詳細:<input type="text" name="description"><br />金額:<input type="number" name="amount"><br /><input type="submit" value="経費申請"><br /><a href="/login">ログイン</a><br /><a href="/expenses/submit">経費入力</a><br /><a href="/expenses/payment">支払い処理</a>`); }); export default router; |
図20 分割後のindex.tsファイルの内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
import Express from 'express'; const app = Express(); import bodyParser from 'body-parser'; import cookieParser from 'cookie-parser'; import session from 'express-session'; app.use(cookieParser()); app.use(bodyParser.urlencoded({ extended: false })); app.use(session({ secret: 'secret', resave: false, saveUninitialized: false, cookie: { maxAge: 24 * 30 * 60 * 1000 } })); // ルーティング import index from './routes/index'; import login from './routes/login'; // ログイン機能 import payment from './routes/expenses/payment'; // 支払い機能 import submit from './routes/expenses/submit'; // 請求機能 app.use('/', index); app.use('/login', login); app.use('/expenses/payment', payment); app.use('/expenses/submit', submit); const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`http://localhost:${port}`); }) export default app; |
図21 package.jsonファイルの内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
{ "name": "webapp", "version": "0.3.0", "description": "This application is sample code of Shell Script Magazine", "main": "dist/index.js", "scripts": { "dev": "ts-node ./src/index.ts", "test": "jest", "clean": "rimraf dist/*", "migrate": "sequelize db:migrate", "transpile": "gulp", "build": "npm run clean && npm run migrate && npm run transpile", "start": "node ." }, "engines": { "node": "12.10.0" }, "author": "しょっさん", "license": "ISC", "dependencies": { "cookie-parser": "^1.4.4", "express": "^4.16.4", "express-session": "^1.16.1", "gulp": "^4.0.2", "gulp-cli": "^2.2.0", "gulp-typescript": "^5.0.1", "pg": "^7.12.1", "rimraf": "^3.0.0", "sequelize": "^5.18.0", "sequelize-cli": "^5.5.1" }, "devDependencies": { "@types/bluebird": "^3.5.27", "@types/cookie-parser": "^1.4.2", "@types/express": "^4.17.1", "@types/express-session": "^1.15.14", "@types/jest": "^24.0.18", "@types/node": "^12.7.3", "@types/pg": "^7.11.0", "@types/supertest": "^2.0.8", "@types/validator": "^10.11.3", "jest": "^24.9.0", "jest-junit": "^8.0.0", "sqlite3": "^4.1.0", "supertest": "^4.0.2", "ts-jest": "^24.0.2", "ts-loader": "^6.0.4", "ts-node": "^8.3.0", "typescript": "^3.6.2" } } |