찾다
Javajava지도 시간Java로 kooperator 작성하기

Writing koperator in Java

이 튜토리얼은 특히 첫 번째 kubernetes 연산자를 빠르게 작성하는 방법을 배우고 싶은 Java 배경이 있는 개발자를 위한 것입니다. 왜 운영자인가? 몇 가지 장점이 있습니다:

  • 유지보수 대폭 감소, 키 입력 절약
  • 만드는 모든 시스템에 기본으로 제공되는 탄력성
  • Kubernetes 기본 사항에 대해 진지하게 배우고 배우는 재미

이론을 최소한으로 제한하고 "케이크 굽는" 방법을 확실한 레시피로 보여드리겠습니다. 저는 Java가 제 업무 경험과 가깝고 솔직히 Go보다 쉽기 때문에 선택했습니다(그러나 일부는 동의하지 않을 수도 있습니다).

바로 넘어가겠습니다.

이론 및 배경

긴 문서를 읽는 것을 좋아하는 사람은 없지만, 빨리 가슴 속으로 읽어 볼까요?

포드란 무엇인가요?

Pod는 공유 네트워크 인터페이스(고유한 IP 주소가 제공됨)와 저장소를 갖춘 컨테이너 그룹입니다.

레플리카세트란 무엇인가요?

복제본 세트는 포드 생성 및 삭제를 제어하여 매 순간 특정 템플릿에 정확히 지정된 수의 포드가 있도록 합니다.

배포란 무엇인가요?

배포는 복제본 세트를 소유하고 포드를 간접적으로 소유합니다. 생성하면 배포 포드가 생성되고 삭제하면 포드가 사라집니다.

서비스란 무엇인가요?

서비스는 여러 포드에 대한 단일 인터넷 엔드포인트입니다(로드를 균등하게 분배합니다). 클러스터 외부에서도 볼 수 있도록 노출할 수 있습니다. 엔드포인트 슬라이스 생성을 자동화합니다.

Kubernetes의 문제점은 처음부터 Stateless로 설계되었다는 것입니다. 복제본 세트는 포드의 ID를 추적하지 않습니다. 특정 포드가 사라지면 새 포드가 생성됩니다. 데이터베이스 및 캐시 클러스터와 같은 상태가 필요한 일부 사용 사례가 있습니다. 상태 저장 세트는 문제를 부분적으로만 완화합니다.

이것이 사람들이 유지 관리의 부담을 덜기 위해 연산자를 쓰기 시작한 이유입니다. 패턴과 다양한 SDK에 대해 깊이 다루지는 않겠습니다. 여기서부터 시작할 수 있습니다.

컨트롤러 및 조정

Kubernetes에서 작동하는 모든 것, 모든 작은 기계 장치는 제어 루프라는 간단한 개념을 기반으로 합니다. 따라서 이 제어 루프가 특정 리소스 유형에 대해 수행하는 작업은 매니페스트에 정의된 대로 무엇이고 무엇이 되어야 하는지 확인하는 것입니다. 불일치가 있는 경우 이를 수정하기 위해 몇 가지 작업을 수행하려고 시도합니다. 이것을 화해라고 합니다.

그리고 실제로 연산자는 동일한 개념이지만 사용자 지정 리소스에 대한 것입니다. 사용자 정의 리소스는 kubernetes api를 사용자가 정의한 일부 리소스 유형으로 확장하는 수단입니다. kubernetes에서 crd를 설정하면 가져오기, 나열, 업데이트, 삭제 등과 같은 모든 작업이 이 리소스에서 가능해집니다. 그리고 실제 작업은 어떻게 될까요? 맞습니다. 저희 운영자입니다.

동기 부여 예제 및 Java 앱

처음으로 기술을 테스트하는 경우 일반적으로 가장 기본적인 문제를 선택합니다. 개념이 특히 복잡하기 때문에 이 경우 hello world는 약간 길어질 것입니다. 어쨌든 대부분의 소스에서 가장 간단한 사용 사례는 정적 페이지 제공을 설정하는 것임을 확인했습니다.

그래서 프로젝트는 다음과 같습니다. 우리가 제공하려는 두 페이지를 나타내는 사용자 정의 리소스를 정의합니다. 해당 리소스 운영자를 적용한 후 Spring Boot에서 서비스 제공 애플리케이션을 자동으로 설정하고 페이지 콘텐츠가 포함된 구성 맵을 생성하고 구성 맵을 앱 포드의 볼륨에 마운트하고 해당 포드에 대한 서비스를 설정합니다. 여기서 재미있는 점은 리소스를 수정하면 즉시 모든 것을 다시 바인딩하고 새로운 페이지 변경 사항이 즉시 표시된다는 것입니다. 두 번째로 재미있는 점은 리소스를 삭제하면 클러스터를 깨끗하게 유지하는 모든 항목이 삭제된다는 것입니다.

자바 앱 제공

이것은 Spring Boot에서 매우 간단한 정적 페이지 서버가 될 것입니다. spring-boot-starter-web만 필요하므로 spring 초기화로 가서 다음을 선택하세요.

  • 메이븐
  • 자바 21
  • 최신 안정 버전(저는 3.3.4)
  • 그랄 VM
  • 그리고 스프링 부트 스타터 웹

앱은 다음과 같습니다.

@SpringBootApplication
@RestController
public class WebpageServingApplication {

    @GetMapping(value = "/{page}", produces = "text/html")
    public String page(@PathVariable String page) throws IOException {
        return Files.readString(Path.of("/static/"+page));
    }

    public static void main(String[] args) {
        SpringApplication.run(WebpageServingApplication.class, args);
    }

}

경로 변수로 전달하는 모든 것은 /static 디렉토리(이 경우 page1 및 page2)에서 가져옵니다. 따라서 정적 디렉토리는 구성 맵에서 마운트되지만 이에 대해서는 나중에 설명합니다.

이제 네이티브 이미지를 빌드하여 원격 저장소에 푸시해야 합니다.

팁 1번

<plugin>
    <groupid>org.graalvm.buildtools</groupid>
    <artifactid>native-maven-plugin</artifactid>
    <configuration>
        <buildargs>
            <buildarg>-Ob</buildarg>
        </buildargs>
    </configuration>
</plugin>

GraalVM을 이와 같이 구성하면 가장 낮은 메모리 소비(약 2GB)로 가장 빠른 빌드를 얻을 수 있습니다. 저에게는 메모리가 16GB밖에 없고 설치되어 있는 것도 많기 때문에 필수였습니다.

팁 2번

<plugin>
    <groupid>org.springframework.boot</groupid>
    <artifactid>spring-boot-maven-plugin</artifactid>
    <configuration>
        <image>
            <publish>true</publish>
            <builder>paketobuildpacks/builder-jammy-full:latest</builder>
            <name>ghcr.io/dgawlik/webpage-serving:1.0.5</name>
            <env>
                <bp_jvm_version>21</bp_jvm_version>
            </env>
        </image>
        <docker>
            <publishregistry>
                <url>https://ghcr.io/dgawlik</url>
                <username>dgawlik</username>
                <password>${env.GITHUB_TOKEN}</password>
            </publishregistry>
        </docker>
    </configuration>
</plugin>
  • use paketobuildpacks/builder-jammy-full:latest while you are testing because -tiny and -base won’t have bash installed and you won’t be able to attach to container. Once you are done you can switch.
  • publish true will cause building image to push it to repository, so go ahead and switch it to your repo
  • BP_JVM_VERSION will be the java version of the builder image, it should be the same as the java of your project. As far as I know the latest java available is 21.

So now you do:

mvn spring-boot:build-image

And that’s it.

Operator with Fabric8

Now the fun starts. First you will need this in your pom:

<dependencies>
   <dependency>
      <groupid>io.fabric8</groupid>
      <artifactid>kubernetes-client</artifactid>
      <version>6.13.4</version>
   </dependency>
   <dependency>
      <groupid>io.fabric8</groupid>
      <artifactid>crd-generator-apt</artifactid>
      <version>6.13.4</version>
      <scope>provided</scope>
   </dependency>
</dependencies>

crd-generator-apt is a plugin that scans a project, detects CRD pojos and generates the manifest.

Since I mentioned it, these resources are:

@Group("com.github.webserving")
@Version("v1alpha1")
@ShortNames("websrv")
public class WebServingResource extends CustomResource<webservingspec webservingstatus> implements Namespaced {
}
</webservingspec>
public record WebServingSpec(String page1, String page2) {
}
public record WebServingStatus (String status) {
}

What is common in all resource manifests in Kubernetes is that most of them has spec and status. So you can see that the spec will consist of two pages pasted in heredoc format. Now, the proper way to handle things would be to manipulate status to reflect whatever operator is doing. If for example it is waiting on deployment to finish it would have status = “Processing”, on everything done it would patch the status to “Ready” and so on. But we will skip that because this is just simple demo.

Good news is that the logic of the operator is all in main class and really short. So step by step here it is:

KubernetesClient client = new KubernetesClientBuilder()
    .withTaskExecutor(executor).build();

var crdClient = client.resources(WebServingResource.class)
    .inNamespace("default");


var handler = new GenericResourceEventHandler(update -> {
   synchronized (changes) {
       changes.notifyAll();
   }
});

crdClient.inform(handler).start();

client.apps().deployments().inNamespace("default")
     .withName("web-serving-app-deployment").inform(handler).start();

client.services().inNamespace("default")
   .withName("web-serving-app-svc").inform(handler).start();

client.configMaps().inNamespace("default")
    .withName("web-serving-app-config").inform(handler).start();

So the heart of the program is of course Fabric8 Kuberenetes client built in first line. It is convenient to customize it with own executor. I used famous virtual threads, so when waiting on blocking io java will suspend the logic and move to main.

How here is a new part. The most basic version would be to run forever the loop and put Thread.sleep(1000) in it or so. But there is more clever way - kubernetes informers. Informer is websocket connection to kubernetes api server and it informs the client each time the subscribed resource changes. There is more to it you can read on the internet for example how to use various caches which fetch updates all at once in batch. But here it just subscribes directly per resource. The handler is a little bit bloated so I wrote a helper class GenericResourceEventHandler.

public class GenericResourceEventHandler<t> implements ResourceEventHandler<t> {

    private final Consumer<t> handler;

    public GenericResourceEventHandler(Consumer<t> handler) {
        this.handler = handler;
    }


    @Override
    public void onAdd(T obj) {
        this.handler.accept(obj);
    }

    @Override
    public void onUpdate(T oldObj, T newObj) {
        this.handler.accept(newObj);
    }

    @Override
    public void onDelete(T obj, boolean deletedFinalStateUnknown) {
        this.handler.accept(null);
    }
}
</t></t></t></t>

Since we only need to wake up the loop in all of the cases then we pass it a generic lambda. The idea for the loop is to wait on lock in the end and then the informer callback releases the lock each time the changes are detected.

Next:

for (; ; ) {

    var crdList = crdClient.list().getItems();
    var crd = Optional.ofNullable(crdList.isEmpty() ? null : crdList.get(0));


    var skipUpdate = false;
    var reload = false;

    if (!crd.isPresent()) {
        System.out.println("No WebServingResource found, reconciling disabled");
        currentCrd = null;
        skipUpdate = true;
    } else if (!crd.get().getSpec().equals(
            Optional.ofNullable(currentCrd)
                    .map(WebServingResource::getSpec).orElse(null))) {
        currentCrd = crd.orElse(null);
        System.out.println("Crd changed, Reconciling ConfigMap");
        reload = true;
    }

If there is no crd then there is nothing to be done. And if the crd changed then we have to reload everything.

var currentConfigMap = client.configMaps().inNamespace("default")
        .withName("web-serving-app-config").get();

if(!skipUpdate && (reload || desiredConfigMap(currentCrd).equals(currentConfigMap))) {
    System.out.println("New configmap, reconciling WebServingResource");
    client.configMaps().inNamespace("default").withName("web-serving-app-config")
            .createOrReplace(desiredConfigMap(currentCrd));
    reload = true;
}

This is for the case that ConfigMap is changed in between the iterations. Since it is mounted in pod then we have to reload the deployment.

var currentServingDeploymentNullable = client.apps().deployments().inNamespace("default")
        .withName("web-serving-app-deployment").get();
var currentServingDeployment = Optional.ofNullable(currentServingDeploymentNullable);

if(!skipUpdate && (reload || !desiredWebServingDeployment(currentCrd).getSpec().equals(
        currentServingDeployment.map(Deployment::getSpec).orElse(null)))) {

    System.out.println("Reconciling Deployment");
    client.apps().deployments().inNamespace("default").withName("web-serving-app-deployment")
            .createOrReplace(desiredWebServingDeployment(currentCrd));
}

var currentServingServiceNullable = client.services().inNamespace("default")
            .withName("web-serving-app-svc").get();
var currentServingService = Optional.ofNullable(currentServingServiceNullable);

if(!skipUpdate && (reload || !desiredWebServingService(currentCrd).getSpec().equals(
        currentServingService.map(Service::getSpec).orElse(null)))) {

    System.out.println("Reconciling Service");
    client.services().inNamespace("default").withName("web-serving-app-svc")
            .createOrReplace(desiredWebServingService(currentCrd));
}

If any of the service or deployment differs from the defaults we will replace them with the defaults.

synchronized (changes) {
    changes.wait();
}

Then the aforementioned lock.

So now the only thing is to define the desired configmap, service and deployment.

private static Deployment desiredWebServingDeployment(WebServingResource crd) {
    return new DeploymentBuilder()
            .withNewMetadata()
            .withName("web-serving-app-deployment")
            .withNamespace("default")
            .addToLabels("app", "web-serving-app")
            .withOwnerReferences(createOwnerReference(crd))
            .endMetadata()
            .withNewSpec()
            .withReplicas(1)
            .withNewSelector()
            .addToMatchLabels("app", "web-serving-app")
            .endSelector()
            .withNewTemplate()
            .withNewMetadata()
            .addToLabels("app", "web-serving-app")
            .endMetadata()
            .withNewSpec()
            .addNewContainer()
            .withName("web-serving-app-container")
            .withImage("ghcr.io/dgawlik/webpage-serving:1.0.5")
            .withVolumeMounts(new VolumeMountBuilder()
                    .withName("web-serving-app-config")
                    .withMountPath("/static")
                    .build())
            .addNewPort()
            .withContainerPort(8080)
            .endPort()
            .endContainer()
            .withVolumes(new VolumeBuilder()
                    .withName("web-serving-app-config")
                    .withConfigMap(new ConfigMapVolumeSourceBuilder()
                            .withName("web-serving-app-config")
                            .build())
                    .build())
            .withImagePullSecrets(new LocalObjectReferenceBuilder()
                    .withName("regcred").build())
            .endSpec()
            .endTemplate()
            .endSpec()
            .build();
}

private static Service desiredWebServingService(WebServingResource crd) {
    return new ServiceBuilder()
            .editMetadata()
            .withName("web-serving-app-svc")
            .withOwnerReferences(createOwnerReference(crd))
            .withNamespace(crd.getMetadata().getNamespace())
            .endMetadata()
            .editSpec()
            .addNewPort()
            .withPort(8080)
            .withTargetPort(new IntOrString(8080))
            .endPort()
            .addToSelector("app", "web-serving-app")
            .endSpec()
            .build();
}

private static ConfigMap desiredConfigMap(WebServingResource crd) {
    return new ConfigMapBuilder()
            .withMetadata(
                    new ObjectMetaBuilder()
                            .withName("web-serving-app-config")
                            .withNamespace(crd.getMetadata().getNamespace())
                            .withOwnerReferences(createOwnerReference(crd))
                            .build())
            .withData(Map.of("page1", crd.getSpec().page1(),
                    "page2", crd.getSpec().page2()))
            .build();
}

private static OwnerReference createOwnerReference(WebServingResource crd) {
    return new OwnerReferenceBuilder()
            .withApiVersion(crd.getApiVersion())
            .withKind(crd.getKind())
            .withName(crd.getMetadata().getName())
            .withUid(crd.getMetadata().getUid())
            .withController(true)
            .build();
}

The magic of the OwnerReference is that you mark the resource which is it’s parent. Whenever you delete the parent k8s will delete automatically all the dependant resources.

But you can’t run it yet. You need a docker credentials in kubernetes:

kubectl delete secret regcred

kubectl create secret docker-registry regcred \
  --docker-server=ghcr.io \
  --docker-username=dgawlik \
  --docker-password=$GITHUB_TOKEN

Run this script once. Then we also need to set up the ingress:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: demo-ingress
spec:
  rules:
    - http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web-serving-app-svc
                port:
                  number: 8080

The workflow

So first you build the operator project. Then you take target/classes/META-INF/fabric8/webservingresources.com.github.webserving-v1.yml and apply it. From now on the kubernetes is ready to accept your crd. Here it is:

apiVersion: com.github.webserving/v1alpha1
kind: WebServingResource
metadata:
  name: example-ws
  namespace: default
spec:
  page1: |
    <h1 id="Hola-amigos">Hola amigos!</h1>
    <p>Buenos dias!</p>
  page2: |
    <h1 id="Hello-my-friend">Hello my friend</h1>
    <p>Good evening</p>

You apply the crd kubectl apply -f src/main/resources/crd-instance.yaml. And then you run Main of the operator.

Then monitor the pod if it is up. Next just take the ip of the cluster:

minikube ip

And in your browser navigate to /page1 and /page2.

Then try to change the crd and apply it again. After a second you should see the changes.

The end.

Conclusion

A bright observer will notice that the code has some concurrency issues. A lot can happen in between the start and the end of the loop. But there are a lot of cases to consider and tried to keep it simple. You can do it as aftermath.

Like wise for the deployment. Instead of running it in IDE you can build the image the same way as for serving app and write deployment of it. That’s basically demystification of the operator — it is just a pod like every other.

I hope you found it useful.

Thanks for reading.

I almost forgot - here is the repo:

https://github.com/dgawlik/operator-hello-world

위 내용은 Java로 kooperator 작성하기의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.
플랫폼 독립성은 기업 수준의 Java 응용 프로그램에 어떻게 도움이됩니까?플랫폼 독립성은 기업 수준의 Java 응용 프로그램에 어떻게 도움이됩니까?May 03, 2025 am 12:23 AM

Java는 플랫폼 독립성으로 인해 엔터프라이즈 수준의 응용 프로그램에서 널리 사용됩니다. 1) 플랫폼 독립성은 JVM (Java Virtual Machine)을 통해 구현되므로 JAVA를 지원하는 모든 플랫폼에서 코드가 실행될 수 있습니다. 2) 크로스 플랫폼 배포 및 개발 프로세스를 단순화하여 유연성과 확장 성을 더 많이 제공합니다. 3) 그러나 성능 차이 및 타사 라이브러리 호환성에주의를 기울이고 순수한 Java 코드 사용 및 크로스 플랫폼 테스트와 같은 모범 사례를 채택해야합니다.

Java는 플랫폼 독립성을 고려하여 IoT (Internet of Things) 장치의 개발에서 어떤 역할을합니까?Java는 플랫폼 독립성을 고려하여 IoT (Internet of Things) 장치의 개발에서 어떤 역할을합니까?May 03, 2025 am 12:22 AM

javaplaysaSignificantroleiniotduetoitsplatformincentence.1) itallowscodetobewrittenonceandevices.2) java'secosystemprovidesusefullibrariesforiot.3) itssecurityfeaturesenhanceiotiotsystemsafety.hormormory.hormory.hustupletety.houghmormory

Java에서 플랫폼 별 문제를 발견 한 시나리오와 해결 방법을 설명하십시오.Java에서 플랫폼 별 문제를 발견 한 시나리오와 해결 방법을 설명하십시오.May 03, 2025 am 12:21 AM

thejava.nio.filepackage.1) withsystem.getProperty ( "user.dir") andtherelativeatthereplattHefilePsiple.2) thepathtopilebtoafne 컨버터링 주제

개발자를위한 Java의 플랫폼 독립성의 이점은 무엇입니까?개발자를위한 Java의 플랫폼 독립성의 이점은 무엇입니까?May 03, 2025 am 12:15 AM

Java'SplatformIndenceSnictIficantIficantBecauseItAllowsDeveloperstowRiteCodeOnceAntOnitonAnyplatformwithajvm.이 "WriteOnce, Runanywhere"(WORA) 접근자 : 1) 교차 플랫폼 컴퓨팅 성, DeploymentAcrossDifferentoSwithoutissswithoutissuesswithoutissuesswithoutswithoutisssues를 활성화합니다

다른 서버에서 실행 해야하는 웹 애플리케이션에 Java를 사용하는 장점은 무엇입니까?다른 서버에서 실행 해야하는 웹 애플리케이션에 Java를 사용하는 장점은 무엇입니까?May 03, 2025 am 12:13 AM

Java는 크로스 서버 웹 응용 프로그램을 개발하는 데 적합합니다. 1) Java의 "Write Once, Run Everywhere"철학은 JVM을 지원하는 모든 플랫폼에서 코드를 실행합니다. 2) Java는 Spring 및 Hibernate와 같은 도구를 포함하여 개발 프로세스를 단순화하는 풍부한 생태계를 가지고 있습니다. 3) Java는 성능 및 보안에서 훌륭하게 성능을 발휘하여 효율적인 메모리 관리 및 강력한 보안 보증을 제공합니다.

JVM은 Java의 'Write Once, Run Aloneeringly'(Wora) 기능에 어떻게 기여합니까?JVM은 Java의 'Write Once, Run Aloneeringly'(Wora) 기능에 어떻게 기여합니까?May 02, 2025 am 12:25 AM

JVM은 바이트 코드 해석, 플랫폼 독립 API 및 동적 클래스 로딩을 통해 Java의 Wora 기능을 구현합니다. 1. 바이트 코드는 크로스 플랫폼 작동을 보장하기 위해 기계 코드로 해석됩니다. 2. 표준 API 추상 운영 체제 차이; 3. 클래스는 런타임에 동적으로로드되어 일관성을 보장합니다.

최신 버전의 Java는 플랫폼 별 문제를 어떻게 해결합니까?최신 버전의 Java는 플랫폼 별 문제를 어떻게 해결합니까?May 02, 2025 am 12:18 AM

JAVA의 최신 버전은 JVM 최적화, 표준 라이브러리 개선 및 타사 라이브러리 지원을 통해 플랫폼 별 문제를 효과적으로 해결합니다. 1) Java11의 ZGC와 같은 JVM 최적화는 가비지 수집 성능을 향상시킵니다. 2) Java9의 모듈 시스템과 같은 표준 라이브러리 개선은 플랫폼 관련 문제를 줄입니다. 3) 타사 라이브러리는 OpenCV와 같은 플랫폼 최적화 버전을 제공합니다.

JVM이 수행 한 바이트 코드 검증 프로세스를 설명하십시오.JVM이 수행 한 바이트 코드 검증 프로세스를 설명하십시오.May 02, 2025 am 12:18 AM

JVM의 바이트 코드 검증 프로세스에는 네 가지 주요 단계가 포함됩니다. 1) 클래스 파일 형식이 사양을 준수하는지 확인, 2) 바이트 코드 지침의 유효성과 정확성을 확인하고 3) 유형 안전을 보장하기 위해 데이터 흐름 분석을 수행하고 4) 검증의 철저한 성능 균형을 유지합니다. 이러한 단계를 통해 JVM은 안전하고 올바른 바이트 코드 만 실행되도록하여 프로그램의 무결성과 보안을 보호합니다.

See all articles

핫 AI 도구

Undresser.AI Undress

Undresser.AI Undress

사실적인 누드 사진을 만들기 위한 AI 기반 앱

AI Clothes Remover

AI Clothes Remover

사진에서 옷을 제거하는 온라인 AI 도구입니다.

Undress AI Tool

Undress AI Tool

무료로 이미지를 벗다

Clothoff.io

Clothoff.io

AI 옷 제거제

Video Face Swap

Video Face Swap

완전히 무료인 AI 얼굴 교환 도구를 사용하여 모든 비디오의 얼굴을 쉽게 바꾸세요!

뜨거운 도구

SublimeText3 영어 버전

SublimeText3 영어 버전

권장 사항: Win 버전, 코드 프롬프트 지원!

안전한 시험 브라우저

안전한 시험 브라우저

안전한 시험 브라우저는 온라인 시험을 안전하게 치르기 위한 보안 브라우저 환경입니다. 이 소프트웨어는 모든 컴퓨터를 안전한 워크스테이션으로 바꿔줍니다. 이는 모든 유틸리티에 대한 액세스를 제어하고 학생들이 승인되지 않은 리소스를 사용하는 것을 방지합니다.

스튜디오 13.0.1 보내기

스튜디오 13.0.1 보내기

강력한 PHP 통합 개발 환경

Atom Editor Mac 버전 다운로드

Atom Editor Mac 버전 다운로드

가장 인기 있는 오픈 소스 편집기

VSCode Windows 64비트 다운로드

VSCode Windows 64비트 다운로드

Microsoft에서 출시한 강력한 무료 IDE 편집기