Code source : https://github.com/aelassas/bookcars
Démo : https://bookcars.dynv6.net:3002
L'idée est née d'un désir de construire sans frontières : un site Web et une application mobile de location de voitures entièrement personnalisables où chaque aspect est sous votre contrôle :
Voici la pile technologique qui a rendu cela possible :
Une décision clé en matière de conception a été prise d'utiliser TypeScript en raison de ses nombreux avantages. TypeScript offre un typage, des outils et une intégration puissants, ce qui donne lieu à un code de haute qualité, évolutif, plus lisible et maintenable, facile à déboguer et à tester.
J'ai choisi React pour ses puissantes capacités de rendu, MongoDB pour une modélisation flexible des données et Stripe pour le traitement sécurisé des paiements.
En choisissant cette pile, vous ne vous contentez pas de créer un site Web et une application mobile : vous investissez dans une fondation qui peut évoluer avec vos besoins, soutenue par des technologies open source robustes et une communauté de développeurs croissante.
React se démarque comme un excellent choix grâce à :
Dans cette section, vous verrez les pages principales du frontend, le tableau de bord d'administration et l'application mobile.
Depuis le frontend, l'utilisateur peut rechercher des voitures disponibles, choisir une voiture et payer.
Ci-dessous se trouve la page principale du frontend où l'utilisateur peut choisir les points et l'heure de prise en charge et de dépôt, et rechercher les voitures disponibles.
Vous trouverez ci-dessous le résultat de recherche de la page principale où l'utilisateur peut choisir une voiture à louer.
Vous trouverez ci-dessous la page de paiement où l'utilisateur peut définir les options de location et le paiement. Si l'utilisateur n'est pas enregistré, il peut payer et s'inscrire en même temps. Il recevra un email de confirmation et d'activation pour définir son mot de passe s'il n'est pas encore inscrit.
Vous trouverez ci-dessous la page de connexion. En production, les cookies d'authentification sont httpOnly, signés, sécurisés et stricts sur site. Ces options empêchent les attaques XSS, CSRF et MITM. Les cookies d'authentification sont également protégés contre les attaques XST via un middleware personnalisé.
Vous trouverez ci-dessous la page d'inscription.
Ci-dessous se trouve la page où l'utilisateur peut voir et gérer ses réservations.
Ci-dessous se trouve la page où l'utilisateur peut voir une réservation en détail.
Ci-dessous la page de contact.
Vous trouverez ci-dessous la page des emplacements de location de voitures.
Ci-dessous se trouve la page où le client peut voir et gérer ses notifications.
Il existe d'autres pages mais ce sont les pages principales du frontend.
Il existe trois types d'utilisateurs :
La plateforme est conçue pour fonctionner avec plusieurs fournisseurs. Chaque fournisseur peut gérer sa flotte de voitures et ses réservations depuis le tableau de bord d'administration. La plateforme peut également fonctionner avec un seul fournisseur.
À partir du tableau de bord d'administration, l'utilisateur administrateur peut créer et gérer des fournisseurs, des voitures, des emplacements, des utilisateurs et des réservations.
Lorsque l'utilisateur administrateur crée un nouveau fournisseur, le fournisseur recevra un e-mail automatique pour créer son compte afin d'accéder au tableau de bord d'administration afin qu'il puisse gérer sa flotte de voitures et ses réservations.
Vous trouverez ci-dessous la page de connexion du tableau de bord d'administration.
Vous trouverez ci-dessous la page du tableau de bord d'administration où les administrateurs et les fournisseurs peuvent voir et gérer les réservations.
Ci-dessous se trouve la page où le parc automobile est affiché et peut être géré.
Vous trouverez ci-dessous la page sur laquelle les administrateurs et les fournisseurs peuvent créer de nouvelles voitures en fournissant une image et des informations sur la voiture. Pour que les options de voiture soient incluses gratuitement, définissez 0 pour l’option de voiture correspondante. Sinon, fixez le prix de l'option ou laissez-le vide si vous ne souhaitez pas l'inclure.
Vous trouverez ci-dessous la page où les administrateurs et les fournisseurs peuvent modifier les voitures.
Vous trouverez ci-dessous la page où les administrateurs peuvent gérer les utilisateurs de la plateforme.
Vous trouverez ci-dessous la page où modifier les réservations.
Il existe d'autres pages mais ce sont les pages principales du tableau de bord d'administration.
L'API expose toutes les fonctions nécessaires au tableau de bord d'administration, au frontend et à l'application mobile. L'API suit le modèle de conception MVC. JWT est utilisé pour l'authentification. Certaines fonctions nécessitent une authentification, comme les fonctions liées à la gestion des voitures, des réservations et des clients, et d'autres ne nécessitent pas d'authentification, comme la récupération des emplacements et des voitures disponibles pour les utilisateurs non authentifiés :
index.ts est le point d'entrée principal de l'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)) }
Il s'agit d'un fichier TypeScript qui démarre un serveur à l'aide de Node.js et Express. Il importe plusieurs modules, notamment dotenv, process, fs, http, https, mongoose et app. Il vérifie ensuite si la variable d'environnement HTTPS est définie sur true et, si tel est le cas, crée un serveur HTTPS à l'aide du module https ainsi que de la clé privée et du certificat fournis. Sinon, il crée un serveur HTTP à l'aide du module http. Le serveur écoute sur le port spécifié dans la variable d'environnement PORT.
La fonction close est définie pour arrêter gracieusement le serveur lorsqu'un signal de terminaison est reçu. Il ferme le serveur et la connexion MongoDB, puis quitte le processus avec un code d'état de 0. Enfin, il enregistre la fonction de fermeture à appeler lorsque le processus reçoit un signal SIGINT, SIGTERM ou SIGQUIT.
app.ts est le point d'entrée principal de l'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
Tout d'abord, on récupère la chaîne de connexion MongoDB, puis on établit une connexion avec la base de données MongoDB. Ensuite, nous créons une application Express et chargeons des middlewares tels que cors, compression, casque et nocache. Nous avons mis en place diverses mesures de sécurité à l'aide de la bibliothèque middleware du casque. nous importons également divers fichiers d'itinéraire pour différentes parties de l'application telles que supplierRoutes, bookingRoutes, locationRoutes, notificationRoutes, carRoutes et userRoutes. Enfin, nous chargeons les itinéraires Express et exportons l'application.
Il y a 8 routes dans l'API. Chaque route possède son propre contrôleur suivant le modèle de conception MVC et les principes SOLID. Ci-dessous les principaux itinéraires :
Nous n'allons pas expliquer chaque itinéraire un par un. Prenons, par exemple, countryRoutes et voyons comment cela a été réalisé :
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
Tout d’abord, nous créons un routeur express. Ensuite, nous créons les routes en utilisant leur nom, leur méthode, leurs middlewares et leurs contrôleurs.
routeNames contient les noms de routes 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 contient la principale logique métier concernant les pays. Nous n'allons pas voir tout le code source du contrôleur car il est assez volumineux mais nous prendrons par exemple la fonction de création de contrôleur.
Ci-dessous le modèle du pays :
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
Vous trouverez ci-dessous le type env.Country TypeScript :
export interface Country extends Document { values: Types.ObjectId[] name?: string }
Un pays a plusieurs valeurs. Un par langue. Par défaut, les langues anglaise et française sont prises en charge.
Vous trouverez ci-dessous le modèle 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)) }
Ci-dessous se trouve le type 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
Une LocationValue a un code de langue (ISO 639-1) et une valeur de chaîne.
Ci-dessous se trouve la fonction de création de contrôleur :
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
Dans cette fonction, on récupère le corps de la requête, on parcourt les valeurs fournies dans le corps (une valeur par langue) et on crée une LocationValue. Enfin, nous créons le pays en fonction des valeurs de localisation créées.
Le frontend est une application Web construite avec Node.js, React, MUI et TypeScript. Depuis le frontend, le client peut rechercher les voitures disponibles en fonction des points de prise en charge et de dépôt et de l'heure, choisir une voiture et procéder au paiement :
Les définitions de types TypeScript sont définies dans le package ./packages/bookcars-types.
App.tsx est la principale application de réaction :
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
Nous utilisons le chargement différé de React pour charger chaque itinéraire.
Nous n'allons pas couvrir chaque page du frontend, mais vous pouvez ouvrir le code source et voir chacune d'elles si vous le souhaitez.
La plateforme propose une application mobile native pour Android et iOS. L'application mobile est construite avec React Native, Expo et TypeScript. Comme pour le frontend, l'application mobile permet au client de rechercher des voitures disponibles en fonction des points de prise en charge et de dépôt et de l'heure, de choisir une voiture et de procéder au paiement.
Le client reçoit des notifications push si sa réservation est mise à jour depuis le backend. Les notifications push sont créées avec Node.js, Expo Server SDK et Firebase.
Les définitions de types TypeScript sont définies dans :
./mobile/types/ est chargé dans ./mobile/tsconfig.json comme suit :
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 est le point d'entrée principal de l'application React Native :
importer 'react-native-gesture-handler' importer React, { useCallback, useEffect, useRef, useState } depuis 'react' importer { RootSiblingParent } depuis 'react-native-root-siblings' importer { NavigationContainer, NavigationContainerRef } depuis '@react-navigation/native' importer { StatusBar comme ExpoStatusBar } depuis 'expo-status-bar' importer { SafeAreaProvider } depuis 'react-native-safe-area-context' importer { Fournisseur } depuis 'react-native-paper' importer * en tant que SplashScreen depuis 'expo-splash-screen' importer * en tant que notifications de 'expo-notifications' importer { StripeProvider } depuis '@stripe/stripe-react-native' importer DrawerNavigator depuis './components/DrawerNavigator' importer * comme assistant depuis './common/helper' importer * en tant que NotificationService depuis './services/NotificationService' importer * en tant que UserService depuis './services/UserService' importer { GlobalProvider } depuis './context/GlobalContext' importer * en tant qu'environnement depuis './config/env.config' Notifications.setNotificationHandler({ handleNotification : async () => ({ ShouldShowAlert : vrai, ShouldPlaySound : vrai, ShouldSetBadge : vrai, }), }) // // Empêche l'écran de démarrage natif de se masquer automatiquement avant la déclaration du composant App // SplashScreen.preventAutoHideAsync() .then((result) => console.log(`SplashScreen.preventAutoHideAsync() a réussi : ${result}`)) .catch(console.warn) // il est bon de détecter et d'inspecter explicitement toute erreur const App = () => { const [appIsReady, setAppIsReady] = useState (false) const réponseListener = useRef<Notifications.Subscription>() const navigationRef = useRef<NavigationContainerRef<StackParams>>(null) useEffect(() => { const registre = async () => { constloggedIn = attendre UserService.loggedIn() si (connecté) { const currentUser = attendre UserService.getCurrentUser() si (utilisateuractuel?._id) { attendre helper.registerPushToken (currentUser._id) } autre { aide.erreur() } } } // // Enregistre le jeton de notifications push // registre() // // Cet écouteur est déclenché chaque fois qu'un utilisateur appuie ou interagit avec une notification (fonctionne lorsque l'application est au premier plan, en arrière-plan ou supprimée) // réponseListener.current = Notifications.addNotificationResponseReceivedListener (async (réponse) => { essayer { si (navigationRef.current) { const {données} = réponse.notification.request.content si (data.booking) { si (data.user && data.notification) { attendre NotificationService.markAsRead (data.user, [data.notification]) } navigationRef.current.navigate('Réservation', { id : data.booking }) } autre { navigationRef.current.navigate('Notifications', {}) } } } attraper (erreur) { helper.error(erreur, faux) } }) return () => { Notifications.removeNotificationSubscription(responseListener.current!) } }, []) setTimeout(() => { setAppIsReady (vrai) }, 500) const onReady = useCallback(async () => { si (appIsReady) { // // Cela indique à l'écran de démarrage de se cacher immédiatement ! Si nous appelons cela après // `setAppIsReady`, alors nous pouvons voir un écran vide pendant que l'application est // chargement de son état initial et rendu de ses premiers pixels. Alors à la place, // nous masquons l'écran de démarrage une fois que nous savons que la vue racine est déjà présente // effectué la mise en page. // attendre SplashScreen.hideAsync() } }, [appIsReady]) si (!appIsReady) { renvoyer nul } retour ( <GlobalProvider> <SafeAreaProvider> <Fournisseur> <StripeProvider publiableKey={env.STRIPE_PUBLISHABLE_KEY} MerchantIdentifier={env.STRIPE_MERCHANT_IDENTIFIER}> <RootSiblingParent> <NavigationContainer ref={navigationRef} onReady={onReady}> <ExpoStatusBar> <p>Nous n'allons pas couvrir chaque écran de l'application mobile, mais vous pouvez ouvrir le code source et voir chacun si vous le souhaitez.</p> <h2> Tableau de bord d'administration </h2> <p>Le tableau de bord d'administration est une application Web construite avec Node.js, React, MUI et TypeScript. Depuis le backend, les administrateurs peuvent créer et gérer des fournisseurs, des voitures, des emplacements, des clients et des réservations. Lorsque de nouveaux fournisseurs sont créés depuis le backend, ils recevront un email les invitant à créer un compte afin d'accéder au backend et de gérer leur flotte de voitures et leurs réservations.</p>
Les définitions de types TypeScript sont définies dans le package ./packages/bookcars-types.
App.tsx du backend suit une logique similaire à celle App.tsx du frontend.
Nous n'allons pas couvrir chaque page du tableau de bord d'administration mais vous pouvez ouvrir le code source et voir chacune si vous le souhaitez.
Créer l'application mobile avec React Native et Expo est très simple. Expo rend le développement mobile avec React Native très simple.
Utiliser le même langage (TypeScript) pour le développement backend, frontend et mobile est très pratique.
TypeScript est un langage très intéressant et présente de nombreux avantages. En ajoutant le typage statique à JavaScript, nous pouvons éviter de nombreux bugs et produire un code de haute qualité, évolutif, plus lisible et maintenable, facile à déboguer et à tester.
C'est ça ! J'espère que vous avez apprécié la lecture de cet article.
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!