ソースコード: https://github.com/aelassas/bookcars
デモ: https://bookcars.dynv6.net:3002
このアイデアは、あらゆる面をコントロールできる、完全にカスタマイズ可能なレンタカー Web サイトとモバイル アプリを境界線なく構築したいという願望から生まれました。
これを可能にした技術スタックは次のとおりです:
TypeScript には多くの利点があるため、設計上の重要な決定事項として TypeScript を使用することが決定されました。 TypeScript は強力な型指定、ツール、統合を提供し、その結果、デバッグとテストが容易な、高品質でスケーラブルで読みやすく保守しやすいコードが得られます。
私は、強力なレンダリング機能のために React を、柔軟なデータ モデリングのために MongoDB を、そして安全な支払い処理のために Stripe を選びました。
このスタックを選択すると、Web サイトやモバイル アプリを構築するだけでなく、堅牢なオープンソース テクノロジーと成長する開発者コミュニティに支えられ、ニーズに合わせて進化できる基盤に投資することになります。
React は、次の理由から優れた選択肢として際立っています。
このセクションでは、フロントエンド、管理ダッシュボード、モバイル アプリのメイン ページが表示されます。
ユーザーはフロントエンドから利用可能な車を検索し、車を選択してチェックアウトできます。
以下はフロントエンドのメイン ページで、ユーザーはここで乗車と降車の場所と時間を選択し、利用可能な車を検索できます。
以下は、ユーザーがレンタカーを選択できるメインページの検索結果です。
以下は、ユーザーがレンタル オプションを設定してチェックアウトできるチェックアウト ページです。ユーザーが登録されていない場合は、チェックアウトと登録を同時に行うことができます。まだ登録していない場合は、パスワードを設定するための確認およびアクティベーション電子メールが届きます。
以下はサインインページです。運用環境では、認証 Cookie は httpOnly、署名付き、安全かつ厳密な SameSite です。 これらのオプションは、XSS、CSRF、および MITM 攻撃を防止します。 認証 Cookie は、カスタム ミドルウェアを通じて XST 攻撃からも保護されます。
以下はサインアップページです。
以下は、ユーザーが予約を確認および管理できるページです。
以下は、ユーザーが予約の詳細を確認できるページです。
以下はお問い合わせページです。
以下はレンタカーの場所のページです。
以下は、顧客が通知を表示および管理できるページです。
他にもページがありますが、これらがフロントエンドのメインページです。
ユーザーには 3 つのタイプがあります:
プラットフォームは複数のサプライヤーと連携できるように設計されています。各サプライヤーは、管理ダッシュボードから自社の車両と予約を管理できます。このプラットフォームは、1 つのサプライヤーのみと連携することもできます。
管理者ユーザーは、管理者ダッシュボードから、サプライヤー、車両、場所、ユーザー、予約を作成および管理できます。
管理者ユーザーが新しいサプライヤーを作成すると、サプライヤーは管理ダッシュボードにアクセスするためのアカウントを作成するための自動メールを受信し、車両の保有車両と予約を管理できるようになります。
以下は管理者ダッシュボードのサインイン ページです。
以下は、管理者とサプライヤーが予約を確認および管理できる管理者ダッシュボードのダッシュボード ページです。
以下は車両群を表示・管理できるページです。
以下は、管理者とサプライヤーが画像と車の情報を提供して新しい車を作成できるページです。車種オプションを無料で付ける場合は、該当車種オプションに0を設定してください。それ以外の場合は、オプションの価格を設定するか、オプションを含めたくない場合は空のままにしておきます。
以下は、管理者とサプライヤーが車を編集できるページです。
以下は、管理者がプラットフォーム ユーザーを管理できるページです。
以下は予約を編集するページです。
他にもページがありますが、これらが管理者ダッシュボードのメインページです。
API は、管理ダッシュボード、フロントエンド、モバイル アプリに必要なすべての機能を公開します。 API は MVC 設計パターンに従っています。認証にはJWTが使用されます。車、予約、顧客の管理に関連する機能など、認証が必要な機能と、認証されていないユーザー向けの位置情報や利用可能な車の取得など、認証を必要としない機能があります。
index.ts は API のメイン エントリ ポイントです:
import 'dotenv/config' import process from 'node:process' import fs from 'node:fs/promises' import http from 'node:http' import https, { ServerOptions } from 'node:https' import * as env from './config/env.config' import * as databaseHelper from './common/databaseHelper' import app from './app' import * as logger from './common/logger' if ( await databaseHelper.connect(env.DB_URI, env.DB_SSL, env.DB_DEBUG) && await databaseHelper.initialize() ) { let server: http.Server | https.Server if (env.HTTPS) { https.globalAgent.maxSockets = Number.POSITIVE_INFINITY const privateKey = await fs.readFile(env.PRIVATE_KEY, 'utf8') const certificate = await fs.readFile(env.CERTIFICATE, 'utf8') const credentials: ServerOptions = { key: privateKey, cert: certificate } server = https.createServer(credentials, app) server.listen(env.PORT, () => { logger.info('HTTPS server is running on Port', env.PORT) }) } else { server = app.listen(env.PORT, () => { logger.info('HTTP server is running on Port', env.PORT) }) } const close = () => { logger.info('Gracefully stopping...') server.close(async () => { logger.info(`HTTP${env.HTTPS ? 'S' : ''} server closed`) await databaseHelper.close(true) logger.info('MongoDB connection closed') process.exit(0) }) } ['SIGINT', 'SIGTERM', 'SIGQUIT'].forEach((signal) => process.on(signal, close)) }
これは、Node.js と Express を使用してサーバーを起動する TypeScript ファイルです。 dotenv、process、fs、http、https、mongoose、app などのいくつかのモジュールをインポートします。次に、HTTPS 環境変数が true に設定されているかどうかを確認し、true に設定されている場合は、https モジュールと提供された秘密キーと証明書を使用して HTTPS サーバーを作成します。それ以外の場合は、http モジュールを使用して HTTP サーバーを作成します。サーバーは、PORT 環境変数で指定されたポートで待機します。
close 関数は、終了信号を受信したときにサーバーを正常に停止するように定義されています。サーバーと MongoDB 接続を閉じ、ステータス コード 0 でプロセスを終了します。最後に、プロセスが SIGINT、SIGTERM、または SIGQUIT シグナルを受信したときに呼び出される close 関数を登録します。
app.ts は API のメイン エントリ ポイントです:
import express from 'express' import compression from 'compression' import helmet from 'helmet' import nocache from 'nocache' import cookieParser from 'cookie-parser' import i18n from './lang/i18n' import * as env from './config/env.config' import cors from './middlewares/cors' import allowedMethods from './middlewares/allowedMethods' import supplierRoutes from './routes/supplierRoutes' import bookingRoutes from './routes/bookingRoutes' import locationRoutes from './routes/locationRoutes' import notificationRoutes from './routes/notificationRoutes' import carRoutes from './routes/carRoutes' import userRoutes from './routes/userRoutes' import stripeRoutes from './routes/stripeRoutes' import countryRoutes from './routes/countryRoutes' import * as helper from './common/helper' const app = express() app.use(helmet.contentSecurityPolicy()) app.use(helmet.dnsPrefetchControl()) app.use(helmet.crossOriginEmbedderPolicy()) app.use(helmet.frameguard()) app.use(helmet.hidePoweredBy()) app.use(helmet.hsts()) app.use(helmet.ieNoOpen()) app.use(helmet.noSniff()) app.use(helmet.permittedCrossDomainPolicies()) app.use(helmet.referrerPolicy()) app.use(helmet.xssFilter()) app.use(helmet.originAgentCluster()) app.use(helmet.crossOriginResourcePolicy({ policy: 'cross-origin' })) app.use(helmet.crossOriginOpenerPolicy()) app.use(nocache()) app.use(compression({ threshold: 0 })) app.use(express.urlencoded({ limit: '50mb', extended: true })) app.use(express.json({ limit: '50mb' })) app.use(cors()) app.options('*', cors()) app.use(cookieParser(env.COOKIE_SECRET)) app.use(allowedMethods) app.use('/', supplierRoutes) app.use('/', bookingRoutes) app.use('/', locationRoutes) app.use('/', notificationRoutes) app.use('/', carRoutes) app.use('/', userRoutes) app.use('/', stripeRoutes) app.use('/', countryRoutes) i18n.locale = env.DEFAULT_LANGUAGE await helper.mkdir(env.CDN_USERS) await helper.mkdir(env.CDN_TEMP_USERS) await helper.mkdir(env.CDN_CARS) await helper.mkdir(env.CDN_TEMP_CARS) await helper.mkdir(env.CDN_LOCATIONS) await helper.mkdir(env.CDN_TEMP_LOCATIONS) export default app
まず、MongoDB 接続文字列を取得し、MongoDB データベースとの接続を確立します。次に、Express アプリを作成し、cors、compression、helmet、nocache などのミドルウェアを読み込みます。ヘルメットミドルウェアライブラリを利用して、さまざまなセキュリティ対策を設定します。また、supplierRoutes、bookingRoutes、locationRoutes、notificationRoutes、carRoutes、userRoutes など、アプリケーションのさまざまな部分のさまざまなルート ファイルもインポートします。最後に、Express ルートをロードし、アプリをエクスポートします。
API には 8 つのルートがあります。各ルートには、MVC 設計パターンと SOLID 原則に従って独自のコントローラーがあります。以下は主なルートです:
各ルートを一つずつ説明するつもりはありません。たとえば、countryRoutes を取り上げ、それがどのように作成されたかを見てみましょう:
import express from 'express' import routeNames from '../config/countryRoutes.config' import authJwt from '../middlewares/authJwt' import * as countryController from '../controllers/countryController' const routes = express.Router() routes.route(routeNames.validate).post(authJwt.verifyToken, countryController.validate) routes.route(routeNames.create).post(authJwt.verifyToken, countryController.create) routes.route(routeNames.update).put(authJwt.verifyToken, countryController.update) routes.route(routeNames.delete).delete(authJwt.verifyToken, countryController.deleteCountry) routes.route(routeNames.getCountry).get(authJwt.verifyToken, countryController.getCountry) routes.route(routeNames.getCountries).get(authJwt.verifyToken, countryController.getCountries) routes.route(routeNames.getCountriesWithLocations).get(countryController.getCountriesWithLocations) routes.route(routeNames.checkCountry).get(authJwt.verifyToken, countryController.checkCountry) routes.route(routeNames.getCountryId).get(authJwt.verifyToken, countryController.getCountryId) export default routes
まず、Express Router を作成します。次に、名前、メソッド、ミドルウェア、コントローラーを使用してルートを作成します。
routeNames には countryRoutes ルート名が含まれています:
const routes = { validate: '/api/validate-country', create: '/api/create-country', update: '/api/update-country/:id', delete: '/api/delete-country/:id', getCountry: '/api/country/:id/:language', getCountries: '/api/countries/:page/:size/:language', getCountriesWithLocations: '/api/countries-with-locations/:language/:imageRequired/:minLocations', checkCountry: '/api/check-country/:id', getCountryId: '/api/country-id/:name/:language', } export default routes
countryController には、国に関する主要なビジネス ロジックが含まれています。コントローラーのソース コードは非常に大きいため、すべてを見ることはできませんが、コントローラー関数の作成を例に説明します。
以下は国別モデルです:
import { Schema, model } from 'mongoose' import * as env from '../config/env.config' const countrySchema = new Schema<env.Country>( { values: { type: [Schema.Types.ObjectId], ref: 'LocationValue', required: [true, "can't be blank"], validate: (value: any): boolean => Array.isArray(value), }, }, { timestamps: true, strict: true, collection: 'Country', }, ) const Country = model<env.Country>('Country', countrySchema) export default Country
以下は env. Country TypeScript タイプです:
export interface Country extends Document { values: Types.ObjectId[] name?: string }
国には複数の価値観があります。言語ごとに 1 つ。デフォルトでは、英語とフランス語がサポートされています。
以下は LocationValue モデルです:
import 'dotenv/config' import process from 'node:process' import fs from 'node:fs/promises' import http from 'node:http' import https, { ServerOptions } from 'node:https' import * as env from './config/env.config' import * as databaseHelper from './common/databaseHelper' import app from './app' import * as logger from './common/logger' if ( await databaseHelper.connect(env.DB_URI, env.DB_SSL, env.DB_DEBUG) && await databaseHelper.initialize() ) { let server: http.Server | https.Server if (env.HTTPS) { https.globalAgent.maxSockets = Number.POSITIVE_INFINITY const privateKey = await fs.readFile(env.PRIVATE_KEY, 'utf8') const certificate = await fs.readFile(env.CERTIFICATE, 'utf8') const credentials: ServerOptions = { key: privateKey, cert: certificate } server = https.createServer(credentials, app) server.listen(env.PORT, () => { logger.info('HTTPS server is running on Port', env.PORT) }) } else { server = app.listen(env.PORT, () => { logger.info('HTTP server is running on Port', env.PORT) }) } const close = () => { logger.info('Gracefully stopping...') server.close(async () => { logger.info(`HTTP${env.HTTPS ? 'S' : ''} server closed`) await databaseHelper.close(true) logger.info('MongoDB connection closed') process.exit(0) }) } ['SIGINT', 'SIGTERM', 'SIGQUIT'].forEach((signal) => process.on(signal, close)) }
以下は env.LocationValue TypeScript タイプです:
import express from 'express' import compression from 'compression' import helmet from 'helmet' import nocache from 'nocache' import cookieParser from 'cookie-parser' import i18n from './lang/i18n' import * as env from './config/env.config' import cors from './middlewares/cors' import allowedMethods from './middlewares/allowedMethods' import supplierRoutes from './routes/supplierRoutes' import bookingRoutes from './routes/bookingRoutes' import locationRoutes from './routes/locationRoutes' import notificationRoutes from './routes/notificationRoutes' import carRoutes from './routes/carRoutes' import userRoutes from './routes/userRoutes' import stripeRoutes from './routes/stripeRoutes' import countryRoutes from './routes/countryRoutes' import * as helper from './common/helper' const app = express() app.use(helmet.contentSecurityPolicy()) app.use(helmet.dnsPrefetchControl()) app.use(helmet.crossOriginEmbedderPolicy()) app.use(helmet.frameguard()) app.use(helmet.hidePoweredBy()) app.use(helmet.hsts()) app.use(helmet.ieNoOpen()) app.use(helmet.noSniff()) app.use(helmet.permittedCrossDomainPolicies()) app.use(helmet.referrerPolicy()) app.use(helmet.xssFilter()) app.use(helmet.originAgentCluster()) app.use(helmet.crossOriginResourcePolicy({ policy: 'cross-origin' })) app.use(helmet.crossOriginOpenerPolicy()) app.use(nocache()) app.use(compression({ threshold: 0 })) app.use(express.urlencoded({ limit: '50mb', extended: true })) app.use(express.json({ limit: '50mb' })) app.use(cors()) app.options('*', cors()) app.use(cookieParser(env.COOKIE_SECRET)) app.use(allowedMethods) app.use('/', supplierRoutes) app.use('/', bookingRoutes) app.use('/', locationRoutes) app.use('/', notificationRoutes) app.use('/', carRoutes) app.use('/', userRoutes) app.use('/', stripeRoutes) app.use('/', countryRoutes) i18n.locale = env.DEFAULT_LANGUAGE await helper.mkdir(env.CDN_USERS) await helper.mkdir(env.CDN_TEMP_USERS) await helper.mkdir(env.CDN_CARS) await helper.mkdir(env.CDN_TEMP_CARS) await helper.mkdir(env.CDN_LOCATIONS) await helper.mkdir(env.CDN_TEMP_LOCATIONS) export default app
LocationValue には言語コード (ISO 639-1) と文字列値があります。
以下はコントローラー関数の作成です:
import express from 'express' import routeNames from '../config/countryRoutes.config' import authJwt from '../middlewares/authJwt' import * as countryController from '../controllers/countryController' const routes = express.Router() routes.route(routeNames.validate).post(authJwt.verifyToken, countryController.validate) routes.route(routeNames.create).post(authJwt.verifyToken, countryController.create) routes.route(routeNames.update).put(authJwt.verifyToken, countryController.update) routes.route(routeNames.delete).delete(authJwt.verifyToken, countryController.deleteCountry) routes.route(routeNames.getCountry).get(authJwt.verifyToken, countryController.getCountry) routes.route(routeNames.getCountries).get(authJwt.verifyToken, countryController.getCountries) routes.route(routeNames.getCountriesWithLocations).get(countryController.getCountriesWithLocations) routes.route(routeNames.checkCountry).get(authJwt.verifyToken, countryController.checkCountry) routes.route(routeNames.getCountryId).get(authJwt.verifyToken, countryController.getCountryId) export default routes
この関数では、リクエストの本文を取得し、本文で指定された値 (言語ごとに 1 つの値) を反復処理して、LocationValue を作成します。最後に、作成した位置の値に基づいて国を作成します。
フロントエンドは、Node.js、React、MUI、TypeScript で構築された Web アプリケーションです。フロントエンドから、顧客は乗車場所と降車場所および時間に応じて利用可能な車を検索し、車を選択してチェックアウトに進むことができます。
TypeScript の型定義は、パッケージ ./packages/bookcars-types で定義されます。
App.tsx はメインの反応アプリです:
const routes = { validate: '/api/validate-country', create: '/api/create-country', update: '/api/update-country/:id', delete: '/api/delete-country/:id', getCountry: '/api/country/:id/:language', getCountries: '/api/countries/:page/:size/:language', getCountriesWithLocations: '/api/countries-with-locations/:language/:imageRequired/:minLocations', checkCountry: '/api/check-country/:id', getCountryId: '/api/country-id/:name/:language', } export default routes
各ルートをロードするために React 遅延読み込みを使用しています。
フロントエンドの各ページについては説明しませんが、必要に応じてソース コードを開いて各ページを確認できます。
このプラットフォームは、Android および iOS 用のネイティブ モバイル アプリを提供します。モバイル アプリは React Native、Expo、TypeScript で構築されています。フロントエンドと同様に、モバイル アプリでも、顧客は乗車場所と降車場所および時間に応じて利用可能な車を検索し、車を選択してチェックアウトに進むことができます。
顧客は、予約がバックエンドから更新されるとプッシュ通知を受け取ります。プッシュ通知は、Node.js、Expo Server SDK、Firebase を使用して構築されています。
TypeScript の型定義は次の場所で定義されます:
./mobile/types/ は、次のように ./mobile/tsconfig.json にロードされます。
import { Schema, model } from 'mongoose' import * as env from '../config/env.config' const countrySchema = new Schema<env.Country>( { values: { type: [Schema.Types.ObjectId], ref: 'LocationValue', required: [true, "can't be blank"], validate: (value: any): boolean => Array.isArray(value), }, }, { timestamps: true, strict: true, collection: 'Country', }, ) const Country = model<env.Country>('Country', countrySchema) export default Country
App.tsx は React Native アプリのメイン エントリ ポイントです:
「react-native-gesture-handler」をインポートします import React, { useCallback, useEffect, useRef, useState } from 'react' import { RootSiblingParent } from 'react-native-root-siblings' import { NavigationContainer, NavigationContainerRef } from '@react-navigation/native' import { StatusBar as ExpoStatusBar } from 'expo-status-bar' import { SafeAreaProvider } から 'react-native-safe-area-context' import { Provider } from 'react-native-paper' import * as SplashScreen from 'expo-splash-screen' import * 'expo-notifications' からの通知として import { StripeProvider } から '@ストライプ/ストライプ-反応-ネイティブ' './components/DrawerNavigator' から DrawerNavigator をインポートします import * './common/helper' からヘルパーとして import * as NoticeService from './services/NotificationService' import * as UserService from './services/UserService' './context/GlobalContext' から { GlobalProvider } をインポートします import * as env from './config/env.config' Notices.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: true、 shouldPlaySound: true、 shouldSetBadge: true、 })、 }) // // アプリコンポーネントの宣言前にネイティブのスプラッシュ画面が自動非表示になるのを防ぎます // SplashScreen.preventAutoHideAsync() .then((result) => console.log(`SplashScreen.preventAutoHideAsync() が成功しました: ${result}`)) .catch(console.warn) // エラーを明示的にキャッチして検査することは良いことです const App = () => { const [appIsReady, setAppIsReady] = useState(false) const responseListener = useRef<Notifications.Subscription>() const NavigationRef = useRef<navigationcontainerref>>(null) useEffect(() => { const register = async () => { const loggedIn = UserService.loggedIn() を待ちます if (ログイン) { const currentUser = await UserService.getCurrentUser() if (currentUser?._id) { await helper.registerPushToken(currentUser._id) } それ以外 { helper.error() } } } // // プッシュ通知トークンを登録する // 登録する() // // このリスナーは、ユーザーが通知をタップするか通知を操作するたびに起動されます (アプリがフォアグラウンド、バックグラウンド、または強制終了されたときに機能します) // responseListener.current = Notices.addNotificationResponseReceivedListener(async (応答) => { 試す { if (navigationRef.current) { const { データ } = 応答.通知.リクエスト.コンテンツ if (data.booking) { if (data.user && data.notification) { await NoticeService.markAsRead(data.user, [data.notification]) } NavigationRef.current.navigate('予約', { id: data.booking }) } それ以外 { NavigationRef.current.navigate('通知', {}) } } } キャッチ (エラー) { helper.error(エラー、偽) } }) return() => { Notices.removeNotificationSubscription(responseListener.current!) } }、[]) setTimeout(() => { setAppIsReady(true) }、500) const onReady = useCallback(async () => { if (appIsReady) { // // これにより、スプラッシュ スクリーンがすぐに非表示になるように指示されます。これを後で呼び出すと // `setAppIsReady` の場合、アプリの起動中に空白の画面が表示される場合があります。 // 初期状態をロードし、最初のピクセルをレンダリングします。そこで代わりに、 // ルート ビューがすでに存在していることがわかったら、スプラッシュ スクリーンを非表示にします。 // レイアウトを実行しました。 // SplashScreen.hideAsync() を待つ } }, [appIsReady]) if (!appIsReady) { nullを返す } 戻る ( <グローバルプロバイダー> <セーフエリアプロバイダー> <プロバイダ> <StripeProvider publicableKey={env.STRIPE_PUBLISHABLE_KEY} MerchantIdentifier={env.STRIPE_MERCHANT_IDENTIFIER}> <RootSiblingParent> <NavigationContainer ref={navigationRef} onReady={onReady}> <expostatusbar> <p>モバイル アプリの各画面については説明しませんが、必要に応じてソース コードを開いて各画面を確認できます。</p> <h2> 管理者ダッシュボード </h2> <p>管理者ダッシュボードは、Node.js、React、MUI、TypeScript で構築された Web アプリケーションです。管理者はバックエンドから、サプライヤー、車両、場所、顧客、予約を作成および管理できます。新しいサプライヤーがバックエンドから作成されると、バックエンドにアクセスして車両や予約を管理するためにアカウントを作成するよう求めるメールが送信されます。</p> <ul> <li>./backend/assets/ フォルダーには CSS と画像が含まれています。</li> <li>./backend/pages/ フォルダーには React ページが含まれています。</li> <li>./backend/components/ フォルダーには React コンポーネントが含まれています。</li> <li>./backend/services/ には API クライアント サービスが含まれています。</li> <li>./backend/App.tsx は、ルートを含むメインの React アプリです。</li> <li>./backend/index.tsx は、管理ダッシュボードのメイン エントリ ポイントです。</li> </ul> <p>TypeScript の型定義は、パッケージ ./packages/bookcars-types で定義されます。</p> <p>バックエンドの App.tsx は、フロントエンドの App.tsx と同様のロジックに従います。</p> <p>管理ダッシュボードの各ページについては説明しませんが、必要に応じてソース コードを開いて各ページを確認できます。</p> <h2> 興味のあるスポット </h2> <p>React Native と Expo を使用してモバイル アプリを構築するのは非常に簡単です。 Expo では、React Native を使用したモバイル開発が非常に簡単になります。</p> <p>バックエンド、フロントエンド、モバイル開発に同じ言語 (TypeScript) を使用すると、非常に便利です。</p> <p>TypeScript は非常に興味深い言語であり、多くの利点があります。 JavaScript に静的型付けを追加することで、多くのバグを回避し、デバッグとテストが容易な、高品質でスケーラブルで読みやすく保守しやすいコードを生成できます。</p> <p>それだけです!この記事を楽しんで読んでいただければ幸いです。</p> <h2> リソース </h2> <ol> <li>概要</li> <li>建築</li> <li>インストール中 (セルフホスト型)</li> <li>インストール(VPS)</li> <li> インストール(Docker) <ol> <li>Docker イメージ</li> <li>SSL</li> </ol> </li> <li>ストライプのセットアップ</li> <li>モバイルアプリを構築</li> <li> デモデータベース <ol> <li>Windows、Linux、macOS</li> <li>ドッカー</li> </ol> </li> <li>ソースから実行</li> <li> モバイルアプリを実行する <ol> <li>前提条件</li> <li>手順</li> <li>プッシュ通知</li> </ol> </li> <li>通貨を変更</li> <li>新しい言語を追加</li> <li>単体テストとカバレッジ</li> <li>ログ</li> </ol> </expostatusbar></navigationcontainerref>
以上がゼロから店頭まで: レンタカー Web サイトとモバイル アプリを構築する私の旅の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。