신속한 애플리케이션 개발 프레임워크 AllcountJS를 통한 애플리케이션 개발

게시 됨: 2022-03-11

RAD(Rapid Application Development)의 아이디어는 전통적인 폭포수 개발 모델에 대한 응답으로 탄생했습니다. RAD의 많은 변형이 존재합니다. 예를 들어 애자일 개발 및 합리적 통합 프로세스. 그러나 이러한 모든 모델에는 한 가지 공통점이 있습니다. 프로토타입 및 반복적인 개발을 통해 최소한의 개발 시간으로 최대의 비즈니스 가치를 창출하는 것을 목표로 한다는 것입니다. 이를 달성하기 위해 Rapid Application Development 모델은 프로세스를 용이하게 하는 도구에 의존합니다. 이 기사에서는 그러한 도구 중 하나를 살펴보고 이를 사용하여 비즈니스 가치와 개발 프로세스 최적화에 초점을 맞추는 방법을 살펴보겠습니다.

AllcountJS는 빠른 애플리케이션 개발을 염두에 두고 구축된 새로운 오픈 소스 프레임워크입니다. 애플리케이션의 구조와 동작을 설명하는 JSON과 유사한 구성 코드를 사용하는 선언적 애플리케이션 개발 아이디어를 기반으로 합니다. 프레임워크는 Node.js, Express, MongoDB를 기반으로 구축되었으며 AngularJS 및 Twitter Bootstrap에 크게 의존합니다. 선언적 패턴에 의존하지만 프레임워크는 여전히 필요한 경우 API에 직접 액세스하여 추가 사용자 정의를 허용합니다.

RAD 프레임워크로서의 AllcountJS

왜 AllcountJS를 RAD 프레임워크로 사용합니까?

Wikipedia에 따르면 빠른 애플리케이션 개발을 약속하는 도구가 최소 100개 있지만 "빠른" 것이 얼마나 빠른지에 대한 질문이 제기됩니다. 이러한 도구를 사용하면 몇 시간 안에 특정 데이터 중심 애플리케이션을 개발할 수 있습니까? 또는 응용 프로그램이 며칠 또는 몇 주 만에 개발될 수 있다면 "빠른" 것입니다. 이러한 도구 중 일부는 작동하는 응용 프로그램을 생성하는 데 몇 분이면 충분하다고 주장합니다. 그러나 5분 이내에 유용한 응용 프로그램을 빌드하고 모든 비즈니스 요구 사항을 충족했다고 주장할 가능성은 거의 없습니다. AllcountJS는 그러한 도구라고 주장하지 않습니다. AllcountJS가 제공하는 것은 짧은 시간에 아이디어의 프로토타입을 만드는 방법입니다.

AllcountJS 프레임워크를 사용하면 테마가 있는 자동 생성 사용자 인터페이스, 사용자 관리 기능, RESTful API 및 기타 몇 가지 기능을 최소한의 노력과 시간으로 애플리케이션을 구축할 수 있습니다. 다양한 사용 사례에 AllcountJS를 사용할 수 있지만 보기가 다른 다양한 개체 컬렉션이 있는 응용 프로그램에 가장 적합합니다. 일반적으로 비즈니스 응용 프로그램은 이 모델에 적합합니다.

AllcountJS는 allcountjs.com과 이에 대한 프로젝트 추적기를 구축하는 데 사용되었습니다. allcountjs.com은 맞춤형 AllcountJS 애플리케이션이며 AllcountJS를 사용하면 정적 및 동적 보기 모두를 번거로움 없이 결합할 수 있습니다. 동적으로 로드된 부품을 정적 콘텐츠에 삽입할 수도 있습니다. 예를 들어 AllcountJS는 데모 애플리케이션 템플릿 모음을 관리합니다. allcountjs.com의 기본 페이지에는 해당 컬렉션에서 임의의 애플리케이션 템플릿을 로드하는 데모 위젯이 있습니다. 몇 가지 다른 샘플 애플리케이션은 allcountjs.com의 갤러리에서 사용할 수 있습니다.

시작하기

RAD 프레임워크 AllcountJS의 일부 기능을 보여주기 위해 Toptal 커뮤니티라고 하는 Toptal용 간단한 애플리케이션을 만들 것입니다. 우리 블로그를 팔로우하면 이전 블로그 게시물 중 하나의 일부로 Hoodie를 사용하여 유사한 응용 프로그램이 구축되었다는 것을 이미 알고 있을 것입니다. 이 응용 프로그램을 사용하면 커뮤니티 회원이 등록하고 이벤트를 만들고 참가 신청을 할 수 있습니다.

환경을 설정하기 위해서는 Node.js, MongoDB, Git을 설치해야 합니다. 그런 다음 "npm install" 명령을 호출하여 AllcountJS CLI를 설치하고 프로젝트 초기화를 수행합니다.

 npm install -g allcountjs-cli allcountjs init toptal-community-allcount cd toptal-community-allcount npm install

AllcountJS CLI는 package.json을 미리 채우기 위해 프로젝트에 대한 몇 가지 정보를 입력하도록 요청합니다.

AllcountJS는 독립 실행형 서버 또는 종속성으로 사용할 수 있습니다. 첫 번째 예에서는 AllcountJS를 확장하지 않을 것이므로 독립 실행형 서버가 우리를 위해 작동해야 합니다.

이 새로 생성된 app-config 디렉토리 내에서 main.js JavaScript 파일의 내용을 다음 코드 스니펫으로 교체합니다.

 A.app({ appName: "Toptal Community", onlyAuthenticated: true, allowSignUp: true, appIcon: "rocket", menuItems: [{ name: "Events", entityTypeId: "Event", icon: "calendar" }, { name: "My Events", entityTypeId: "MyEvent", icon: "calendar" }], entities: function(Fields) { return { Event: { title: "Events", fields: { eventName: Fields.text("Event").required(), date: Fields.date("Date").required(), time: Fields.text("Starts at").masked("99:99").required(), appliedUsers: Fields.relation("Applied users", "AppliedUser", "event") }, referenceName: "eventName", sorting: [['date', -1], ['time', -1]], actions: [{ id: "apply", name: "Apply", actionTarget: 'single-item', perform: function (User, Actions, Crud) { return Crud.actionContextCrud().readEntity(Actions.selectedEntityId()).then(function (eventToApply) { var userEventCrud = Crud.crudForEntityType('UserEvent'); return userEventCrud.find({filtering: {"user": User.id, "event": eventToApply.id}}).then(function (events) { if (events.length) { return Actions.modalResult("Can't apply to event", "You've already applied to this event"); } else { return userEventCrud.createEntity({ user: {id: User.id}, event: {id: eventToApply.id}, date: eventToApply.date, time: eventToApply.time }).then(function () { return Actions.navigateToEntityTypeResult("MyEvent") }); } }); }) } }] }, UserEvent: { fields: { user: Fields.fixedReference("User", "OnlyNameUser").required(), event: Fields.fixedReference("Event", "Event").required(), date: Fields.date("Date").required(), time: Fields.text("Starts at").masked("99:99").required() }, filtering: function (User) { return {"user.id": User.id} }, sorting: [['date', -1], ['time', -1]], views: { MyEvent: { title: "My Events", showInGrid: ['event', 'date', 'time'], permissions: { write: [], delete: null } }, AppliedUser: { permissions: { write: [] }, showInGrid: ['user'] } } }, User: { views: { OnlyNameUser: { permissions: { read: null, write: ['admin'] } }, fields: { username: Fields.text("User name") } } } } } });

AllcountJS는 Git 리포지토리와 함께 작동하지만 단순성을 위해 이 자습서에서는 사용하지 않습니다. Toptal 커뮤니티 애플리케이션을 실행하려면 toptal-community-allcount 디렉토리에서 AllcountJS CLI 실행 명령을 호출하기만 하면 됩니다.

 allcountjs run

이 명령이 실행될 때 MongoDB가 실행 중이어야 한다는 점은 주목할 가치가 있습니다. 모든 것이 잘되면 응용 프로그램이 http://localhost:9080에서 시작되어 실행 중이어야 합니다.

로그인하려면 사용자 이름 "admin"과 암호 "admin"을 사용하십시오.

100줄 미만

main.js에 정의된 애플리케이션이 91줄의 코드만 사용했다는 것을 눈치채셨을 것입니다. 이 줄에는 http://localhost:9080으로 이동할 때 관찰할 수 있는 모든 동작 선언이 포함됩니다. 그렇다면 정확히 무슨 일이 일어나고 있는 걸까요? 응용 프로그램의 각 측면을 자세히 살펴보고 코드가 이러한 측면과 어떤 관련이 있는지 살펴보겠습니다.

로그인 가입

신청서를 연 후 처음 보는 페이지는 로그인입니다. 양식을 제출하기 전에 "가입"이라고 표시된 확인란이 선택되어 있다고 가정하면 이 페이지는 가입 ​​페이지로도 사용됩니다.

로그인 가입

이 페이지는 인증된 사용자만 이 애플리케이션을 사용할 수 있음을 main.js 파일에 선언했기 때문에 표시됩니다. 또한 사용자가 이 페이지에서 가입할 수 있습니다. 이를 위해 다음 두 줄만 있으면 됩니다.

 A.app({ ..., onlyAuthenticated: true, allowSignUp: true, ... })

환영 페이지

로그인하면 애플리케이션 메뉴가 있는 시작 페이지로 리디렉션됩니다. 애플리케이션의 이 부분은 "menuItems" 키 아래에 정의된 메뉴 항목을 기반으로 자동으로 생성됩니다.

환영 페이지 예

몇 가지 다른 관련 구성과 함께 메뉴는 다음과 같이 main.js 파일에 정의됩니다.

 A.app({ ..., appName: "Toptal Community", appIcon: "rocket", menuItems: [{ name: "Events", entityTypeId: "Event", icon: "calendar" }, { name: "My Events", entityTypeId: "MyEvent", icon: "calendar" }], ... });

AllcountJS는 Font Awesome 아이콘을 사용하므로 구성에서 참조하는 모든 아이콘 이름은 Font Awesome 아이콘 이름에 매핑됩니다.

이벤트 찾아보기 및 편집

메뉴에서 "이벤트"를 클릭하면 아래 스크린샷에 표시된 이벤트 보기로 이동합니다. 해당 엔터티에 대한 몇 가지 일반 CRUD 기능을 제공하는 표준 AllcountJS 보기입니다. 여기에서 이벤트를 검색하고, 새 이벤트를 만들고, 기존 이벤트를 편집하거나 삭제할 수 있습니다. 이 CRUD 인터페이스에는 목록과 양식의 두 가지 모드가 있습니다. 애플리케이션의 이 부분은 다음 몇 줄의 JavaScript 코드를 통해 구성됩니다.

 A.app({ ..., entities: function(Fields) { return { Event: { title: "Events", fields: { eventName: Fields.text("Event").required(), date: Fields.date("Date").required(), time: Fields.text("Starts at").masked("99:99").required(), appliedUsers: Fields.relation("Applied users", "AppliedUser", "event") }, referenceName: "eventName", sorting: [['date', -1], ['time', -1]], ... } } } });

이 예는 AllcountJS에서 엔티티 설명이 어떻게 구성되는지 보여줍니다. 엔티티를 정의하기 위해 함수를 사용하는 방법에 주목하십시오. AllcountJS 설정의 모든 속성은 함수가 될 수 있습니다. 이러한 함수는 인수 이름을 통해 해결될 종속성을 요청할 수 있습니다. 함수가 호출되기 전에 적절한 종속성이 주입됩니다. 여기서 "Fields"는 엔터티 필드를 설명하는 데 사용되는 AllcountJS 구성 API 중 하나입니다. "Entities" 속성은 이름이 엔티티 유형 식별자이고 값이 설명인 이름-값 쌍을 포함합니다. 이벤트의 엔터티 유형이 설명되어 있으며 이 예에서는 제목이 "이벤트"입니다. 기본 정렬 순서, 참조 이름 등과 같은 다른 구성도 여기에서 정의할 수 있습니다. 기본 정렬 순서는 필드 이름 및 방향 배열을 통해 정의되는 반면 참조 이름은 문자열을 통해 정의됩니다(자세한 내용은 여기 참조).

allcountJS 함수

이 특정 엔터티 유형은 "eventName", "date", "time" 및 "appliedUsers"의 4개 필드를 갖는 것으로 정의되었으며, 이 중 처음 3개는 데이터베이스에 지속됩니다. 이러한 필드는 "required()"를 사용하여 표시된 대로 필수입니다. 이러한 규칙이 있는 필드의 값은 아래 스크린샷과 같이 프런트 엔드에서 양식을 제출하기 전에 유효성이 검사됩니다. AllcountJS는 클라이언트 측 및 서버 측 유효성 검사를 모두 결합하여 최상의 사용자 경험을 제공합니다. 네 번째 필드는 이벤트 참가를 신청한 사용자 목록을 포함하는 관계입니다. 당연히 이 필드는 데이터베이스에 유지되지 않으며 이벤트와 관련된 AppliedUser 엔터티만 선택하여 채워집니다.

allcountjs 개발 규칙

이벤트 참가 신청

사용자가 특정 이벤트를 선택하면 도구 모음에 "적용"이라는 버튼이 표시됩니다. 클릭하면 사용자의 일정에 이벤트가 추가됩니다. AllcountJS에서 이와 유사한 작업은 구성에서 간단히 선언하여 구성할 수 있습니다.

 actions: [{ id: "apply", name: "Apply", actionTarget: 'single-item', perform: function (User, Actions, Crud) { return Crud.actionContextCrud().readEntity(Actions.selectedEntityId()).then(function (eventToApply) { var userEventCrud = Crud.crudForEntityType('UserEvent'); return userEventCrud.find({filtering: {"user": User.id, "event": eventToApply.id}}).then(function (events) { if (events.length) { return Actions.modalResult("Can't apply to event", "You've already applied to this event"); } else { return userEventCrud.createEntity({ user: {id: User.id}, event: {id: eventToApply.id}, date: eventToApply.date, time: eventToApply.time }).then(function () { return Actions.navigateToEntityTypeResult("MyEvent") }); } }); }) } }]

모든 항목 유형의 "actions" 속성은 각 사용자 지정 작업의 동작을 설명하는 개체 배열을 사용합니다. 각 객체에는 작업의 고유 식별자를 정의하는 "id" 속성이 있고, "name" 속성은 표시 이름을 정의하고, "actionTarget" 속성은 작업 컨텍스트를 정의하는 데 사용됩니다. "actionTarget"을 "single-item"으로 설정하면 특정 이벤트와 함께 작업을 수행해야 함을 나타냅니다. "perform" 속성에 정의된 기능은 일반적으로 사용자가 해당 버튼을 클릭할 때 이 작업이 수행될 때 실행되는 논리입니다.

이 함수에서 종속성을 요청할 수 있습니다. 예를 들어, 이 예에서 기능은 "User", "Actions" 및 "Crud"에 따라 다릅니다. 작업이 발생하면 "사용자" 종속성을 요구하여 이 작업을 호출하는 사용자에 대한 참조를 얻을 수 있습니다. 이러한 엔터티에 대한 데이터베이스 상태 조작을 허용하는 "Crud" 종속성도 여기에서 요청됩니다. Crud 개체의 인스턴스를 반환하는 두 가지 메서드는 다음과 같습니다. "actionContextCrud()" 메서드 - "Apply" 작업이 속하기 때문에 "Event" 엔터티 유형에 대한 CRUD를 반환하고 "crudForEntityType()" 메서드 - CRUD 반환 유형 ID로 식별되는 모든 엔티티 유형에 대해

CRUD 종속성

액션의 구현은 이 이벤트가 이미 사용자에 대해 예약되었는지 확인하는 것으로 시작하고 그렇지 않은 경우 이벤트를 생성합니다. 이미 예약되어 있는 경우 "Actions.modalResult()" 호출에서 값을 반환하여 대화 상자가 표시됩니다. 모달을 표시하는 것 외에도 작업은 "보기로 이동", "보기 새로 고침", "대화 상자 표시" 등과 같은 유사한 방식으로 다른 유형의 작업을 수행할 수 있습니다.

행동의 구현

적용된 이벤트의 사용자 일정

이벤트에 성공적으로 적용한 후 브라우저는 사용자가 적용한 이벤트 목록을 보여주는 "내 이벤트" 보기로 리디렉션됩니다. 보기는 다음 구성으로 정의됩니다.

 UserEvent: { fields: { user: Fields.fixedReference("User", "OnlyNameUser").required(), event: Fields.fixedReference("Event", "Event").required(), date: Fields.date("Date").required(), time: Fields.text("Starts at").masked("99:99").required() }, filtering: function (User) { return {"user.id": User.id} }, sorting: [['date', -1], ['time', -1]], views: { MyEvent: { title: "My Events", showInGrid: ['event', 'date', 'time'], permissions: { write: [], delete: null } }, AppliedUser: { permissions: { write: [] }, showInGrid: ['user'] } } },

이 경우 우리는 "필터링"이라는 새로운 구성 속성을 사용하고 있습니다. 이전 예제와 마찬가지로 이 함수는 "사용자" 종속성에 의존합니다. 함수가 객체를 반환하면 MongoDB 쿼리로 처리됩니다. 쿼리는 현재 사용자에게만 속하는 이벤트에 대한 컬렉션을 필터링합니다.

또 다른 흥미로운 속성은 "Views"입니다. "View"는 일반 엔티티 유형이지만 MongoDB 컬렉션은 상위 엔티티 유형과 동일합니다. 이를 통해 데이터베이스의 동일한 데이터에 대해 시각적으로 다른 보기를 생성할 수 있습니다. 사실, 우리는 이 기능을 사용하여 "UserEvent", "MyEvent" 및 "AppliedUser"에 대해 두 가지 다른 보기를 생성했습니다. 하위 뷰의 프로토타입은 상위 엔티티 유형으로 설정되므로 재정의되지 않은 속성은 상위 유형에서 "상속"됩니다.

견해

이벤트 참석자 나열

이벤트에 신청하면 다른 사용자가 참석할 계획인 모든 사용자의 목록을 볼 수 있습니다. 이것은 main.js의 다음 구성 요소의 결과로 생성됩니다.

 AppliedUser: { permissions: { write: [] }, showInGrid: ['user'] } // ... appliedUsers: Fields.relation("Applied users", "AppliedUser", "event")

"AppliedUser"는 "MyEvent" 엔터티 유형에 대한 읽기 전용 보기입니다. 이 읽기 전용 권한은 권한 개체의 "쓰기" 속성에 빈 배열을 설정하여 적용됩니다. 또한 "읽기" 권한이 정의되어 있지 않으므로 기본적으로 모든 사용자에게 읽기가 허용됩니다.

myevent 유형에 대한 적용 사용자

기본 구현 확장

RAD 프레임워크의 전형적인 백드로(backdraw)는 유연성 부족입니다. 앱을 구축하고 사용자 지정해야 하는 경우 심각한 장애물에 직면할 수 있습니다. AllcountJS는 확장성을 염두에 두고 개발되었으며 내부의 모든 빌딩 블록을 교체할 수 있습니다.

이를 달성하기 위해 AllcountJS는 자체 DI(Dependency Injection) 구현을 사용합니다. DI를 통해 개발자는 확장점을 통해 프레임워크의 기본 동작을 재정의할 수 있으며 동시에 기존 구현을 재사용할 수 있습니다. RAD 프레임워크 확장의 많은 측면이 문서에 설명되어 있습니다. 이 섹션에서는 프레임워크의 많은 구성 요소 중 두 가지인 서버 측 논리 및 보기를 확장하는 방법을 살펴보겠습니다.

계속해서 Toptal 커뮤니티 예제를 통해 외부 데이터 소스를 통합하여 이벤트 데이터를 집계하겠습니다. 각 이벤트 전날 이벤트 계획을 논의하는 Toptal 블로그 게시물이 있다고 가정해 보겠습니다. Node.js를 사용하면 블로그의 RSS 피드를 구문 분석하고 이러한 데이터를 추출할 수 있어야 합니다. 이렇게 하려면 "요청", "xml2js"(Toptal 블로그 RSS 피드 로드), "q"(약속 구현) 및 "순간"(날짜 구문 분석)과 같은 몇 가지 추가 npm 종속성이 필요합니다. 이러한 종속성은 다음 명령 집합을 호출하여 설치할 수 있습니다.

 npm install xml2js npm install request npm install q npm install moment

다른 JavaScript 파일을 만들고 toptal-community-allcount 디렉토리에 "toptal-community.js"라는 이름을 지정하고 다음으로 채우겠습니다.

 var request = require('request'); var Q = require('q'); var xml2js = require('xml2js'); var moment = require('moment'); var injection = require('allcountjs'); injection.bindFactory('port', 9080); injection.bindFactory('dbUrl', 'mongodb://localhost:27017/toptal-community'); injection.bindFactory('gitRepoUrl', 'app-config'); injection.bindFactory('DiscussionEventsImport', function (Crud) { return { importEvents: function () { return Q.nfcall(request, "https://www.toptal.com/blog.rss").then(function (responseAndBody) { var body = responseAndBody[1]; return Q.nfcall(xml2js.parseString, body).then (function (feed) { var events = feed.rss.channel[0].item.map(function (item) { return { eventName: "Discussion of " + item.title, date: moment(item.pubDate, "DD MMM YYYY").add(1, 'day').toDate(), time: "12:00" }}); var crud = Crud.crudForEntityType('Event'); return Q.all(events.map(function (event) { return crud.find({query: {eventName: event.eventName}}).then(function (createdEvent) { if (!createdEvent[0]) { return crud.createEntity(event); } }); } )); }); }) } }; }); var server = injection.inject('allcountServerStartup'); server.startup(function (errors) { if (errors) { throw new Error(errors.join('\n')); } });

이 파일에서는 "Event" 엔터티 유형에 대한 가져오기 작업을 추가하여 main.js 파일에서 사용할 수 있는 "DiscussionEventsImport"라는 종속성을 정의하고 있습니다.

 { id: "import-blog-events", name: "Import Blog Events", actionTarget: "all-items", perform: function (DiscussionEventsImport, Actions) { return DiscussionEventsImport.importEvents().then(function () { return Actions.refreshResult() }); } }

JavaScript 파일을 일부 변경한 후 서버를 다시 시작하는 것이 중요하므로 이전 인스턴스를 종료하고 이전과 동일한 명령을 실행하여 다시 시작할 수 있습니다.

 node toptal-community.js

모든 것이 제대로 되었다면 "블로그 이벤트 가져오기" 작업을 실행한 후 아래 스크린샷과 같은 것을 볼 수 있습니다.

블로그 이벤트 가져오기 작업

여기까지는 좋았지만 여기서 멈추지 말자. 기본 보기는 작동하지만 때때로 지루할 수 있습니다. 약간 사용자 정의합시다.

카드 좋아하세요? 모두가 카드를 좋아합니다! 카드 보기를 만들려면 app-config 디렉터리에 있는 events.jade라는 파일에 다음을 입력합니다.

 extends main include mixins block vars - var hasToolbar = true block content .refresh-form-controller(ng-app='allcount', ng-controller='EntityViewController') +defaultToolbar() .container.screen-container(ng-cloak) +defaultList() .row: .col-lg-4.col-md-6.col-xs-12(ng-repeat="item in items") .panel.panel-default .panel-heading h3 {{item.date | date}} {{item.time}} div button.btn.btn-default.btn-xs(ng-if="!isInEditMode", lc-tooltip="View", ng-click="navigate(item.id)"): i.glyphicon.glyphicon-chevron-right |   button.btn.btn-danger.btn-xs(ng-if="isInEditMode", lc-tooltip="Delete", ng-click="deleteEntity(item)"): i.glyphicon.glyphicon-trash .panel-body h3 {{item.eventName}} +noEntries() +defaultEditAndCreateForms() block js +entityJs()

그런 다음 main.js의 "Event" 엔티티에서 "customView: "events""로 참조하기만 하면 됩니다. 앱을 실행하면 기본 표 형식 대신 카드 기반 인터페이스가 표시되어야 합니다.

main.js의 이벤트 엔터티

결론

오늘날 웹 애플리케이션의 개발 흐름은 일부 작업이 계속해서 반복되는 많은 웹 기술에서 유사합니다. 그만한 가치가 있습니까? 웹 애플리케이션이 개발되는 방식을 재고해야 할 때입니까?

AllcountJS는 신속한 애플리케이션 개발 프레임워크에 대한 대안적 접근 방식을 제공합니다. 엔터티 설명을 정의하여 응용 프로그램의 골격을 만든 다음 주변에 보기 및 동작 사용자 지정을 추가합니다. 보시다시피 AllcountJS를 사용하여 100줄 미만의 코드로 간단하면서도 완전한 기능을 갖춘 응용 프로그램을 만들었습니다. 모든 생산 요구 사항을 충족하지는 않지만 사용자 정의가 가능합니다. 이 모든 것이 AllcountJS를 웹 애플리케이션을 빠르게 부트스트랩하는 데 좋은 도구로 만듭니다.